tag:crieit.net,2005:https://crieit.net/tags/GraphQL/feed
「GraphQL」の記事 - Crieit
Crieitでタグ「GraphQL」に投稿された最近の記事
2022-08-31T19:41:17+09:00
https://crieit.net/tags/GraphQL/feed
tag:crieit.net,2005:PublicArticle/18289
2022-08-31T18:48:03+09:00
2022-08-31T19:41:17+09:00
https://crieit.net/posts/NestJS-Prisma-PlanetScale-REST-API-GraphQL
NestJSとPrismaとPlanetScaleでREST APIとGraphQLサーバを作る
<h1 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h1>
<p>Node.jsをベースとしたAPIを作ろうとしたときに、REST APIとGraphQLを同時に生やしたいと思ったので、その流れについて記事にしようと思います。</p>
<h2 id="この記事の目標"><a href="#%E3%81%93%E3%81%AE%E8%A8%98%E4%BA%8B%E3%81%AE%E7%9B%AE%E6%A8%99">この記事の目標</a></h2>
<p>NestJSでREST APIとGraphQLが同時に動くサーバを作成する</p>
<h2 id="構成"><a href="#%E6%A7%8B%E6%88%90">構成</a></h2>
<ul>
<li>データベース:PlanetScale</li>
<li>ORM:Prisma</li>
<li>フレームワーク:NestJS</li>
</ul>
<h1 id="REST APIの実装"><a href="#REST+API%E3%81%AE%E5%AE%9F%E8%A3%85">REST APIの実装</a></h1>
<p>最初にREST APIを作成し、その次にGraphQLを作成します。</p>
<h2 id="NestJSでプロジェクトを作成"><a href="#NestJS%E3%81%A7%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90">NestJSでプロジェクトを作成</a></h2>
<p>Prismaの利用を前提としたNestJSの公式チュートリアルがあるので、やっておくと理解しやすいですが、</p>
<p><a target="_blank" rel="nofollow noopener" href="https://docs.nestjs.com/recipes/prisma">https://docs.nestjs.com/recipes/prisma</a></p>
<p>やや説明不足&実装不足でこのままだと動かないので、以下の記事も参考にすると良いでしょう。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://zenn.dev/tossy_yukky/articles/0075f9f0054b39d4ef59">https://zenn.dev/tossy_yukky/articles/0075f9f0054b39d4ef59</a></p>
<p>まず、NestJS CLIを使ってプロジェクトを作成します。<br />
わざわざグローバルインストールする必要はないのでnpxで作ります。</p>
<pre><code class="sh">npx @nestjs/cli new プロジェクト名
</code></pre>
<p>するとプロジェクト名に設定した名前のフォルダが生成されます。<br />
シェル上で、使用したいパケージマネージャを選択するプロンプトが表示されるので、好きなものを選択してください。今回はyarnを選びます。</p>
<pre><code class="sh">? Which package manager would you ❤️ to use? (Use arrow keys)
npm
> yarn
pnpm
</code></pre>
<p>選ぶとインストールがいずれ完了します。<br />
完了したら、</p>
<pre><code class="sh">cd プロジェクト名
yarn start
</code></pre>
<p>でNestJSが起動することを確認してみましょう。<br />
<a target="_blank" rel="nofollow noopener" href="http://localhost:3000">http://localhost:3000</a> にアクセスすると、<code>Hello World!</code>と表示されているはずです。</p>
<p>ちなみに、</p>
<pre><code class="sh">yarn start:dev
</code></pre>
<p>とすると変更監視モードで起動できます。コードの変更をすぐに確認したいときは、こちらで起動し続けると便利です。</p>
<h2 id="GitHubにリポジトリを作成"><a href="#GitHub%E3%81%AB%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA%E3%82%92%E4%BD%9C%E6%88%90">GitHubにリポジトリを作成</a></h2>
<p>ここまで来たら、GitHubに接続しましょう。<br />
package.jsonの内容を自分に合わせて書き換えて、GitHubにPublishしました。</p>
<h2 id="Prismaを導入する"><a href="#Prisma%E3%82%92%E5%B0%8E%E5%85%A5%E3%81%99%E3%82%8B">Prismaを導入する</a></h2>
<p>Prisma CLIを開発環境にインストールします。</p>
<pre><code class="sh">yarn add -D prisma
</code></pre>
<p>Prisma Clientをインストールします。</p>
<pre><code class="sh">yarn add @prisma/client
</code></pre>
<p>Prismaを初期化します。</p>
<pre><code class="sh">yarn prisma init
</code></pre>
<p>.envファイルが作られますが、現時点で.gitignoreに<code>.env</code>が指定されていません。<br />
セキュリティ上問題があるので、<code>.env</code>を追加してください。</p>
<pre><code class="gitignore">.env
</code></pre>
<p>ここまで終わったら、データベースを接続していきます。</p>
<h2 id="PlanetScaleを用意"><a href="#PlanetScale%E3%82%92%E7%94%A8%E6%84%8F">PlanetScaleを用意</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://qiita.com/tak001/items/cfbaa9dcb542929ff235">https://qiita.com/tak001/items/cfbaa9dcb542929ff235</a></p>
<p>この辺の記事を参考にして、PlanetScaleプロジェクトを作成してください。<br />
作成できたら、Branchesタブから<code>main</code>ブランチを選択し、Connectボタンを押して、接続情報を表示してください。</p>
<p><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/682f9e8b-6459-2283-10d1-73f1308f0a68.png" alt="image.png" /></p>
<p>「Connect with」でPrismaを選択すると、<code>.env</code>と<code>schema.prisma</code>が表示されます。<br />
<code>.env</code>はプロジェクトルートに、<code>schema.prisma</code>はprismaディレクトリに既に作成されているので、表示された内容でファイルを書き換えてください。</p>
<h2 id="Prismaでデータベーステーブルを作成する"><a href="#Prisma%E3%81%A7%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">Prismaでデータベーステーブルを作成する</a></h2>
<p>Prisma Migrateでデータベースのテーブルを作っていきます。<br />
Prismaの公式チュートリアルも参考にしてください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases/using-prisma-migrate-typescript-planetscale">https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases/using-prisma-migrate-typescript-planetscale</a></p>
<p>先ほど書き換えた<code>schema.prisma</code>に、データベースのデータモデルを追加します。</p>
<p>prisma/schema.prisma</p>
<pre><code>generator client {
provider = "prisma-client-js"
previewFeatures = ["referentialIntegrity"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
referentialIntegrity = "prisma"
}
model User {
id Int @default(autoincrement()) @id
email String @unique
name String?
posts Post[]
}
model Post {
id Int @default(autoincrement()) @id
title String
content String?
published Boolean? @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}
</code></pre>
<p>Prismaのスキーマは独自記法なので、意味不明だと思います。以下の公式リファレンスを適宜参照してください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference">https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference</a></p>
<p>簡単な説明だけすると、modelの後に書いたもの(Post、Profile、User)がそれぞれテーブルになります。<br />
波括弧の中で定義しているのは雑に言えばカラムです。<br />
@〇〇(<code>@id</code>など)となっているものはカラムに対する設定です。後ろに括弧を付けると、引数のようにオプションの値を受け取ることができます。<br />
@@〇〇(<code>@@index</code>など)となっているものはテーブルに対する設定です。<br />
IntやStringとなっている部分はカラムの型です。デフォルトはNOT NULLです。<code>?</code>を付けるとNULLABLEになります。<br />
リレーションを張る場合は、モデル自体を示すもの(例えばauthor)と、それに紐づくidを示すもの(例えばauthorId)が必要です。今回はauthorがUserモデルを指し、設定で<code>@relation(fields: [authorId], references: [id])</code>とすることで、authorIdとUser.idが紐づいていることを表しています。詳しくは以下を参照してください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/concepts/components/prisma-schema/relations">https://www.prisma.io/docs/concepts/components/prisma-schema/relations</a></p>
<p><code>posts Post[]</code>のような感じで、一対多を表すこともできます。<br />
多対多については以下を参照してください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations">https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations</a></p>
<p>ここまでできたら早速PlanetScaleにテーブル定義を反映させてみましょう。</p>
<pre><code class="sh">yarn prisma db push
</code></pre>
<p>PlanetScaleで<code>main</code>ブランチを選択し、Schemaタブを開くと、先ほど定義したスキーマがSQLに変換されて表示されているはずです。</p>
<h2 id="NestJSのサービスを構成"><a href="#NestJS%E3%81%AE%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%82%92%E6%A7%8B%E6%88%90">NestJSのサービスを構成</a></h2>
<p>PrismaとNestJSを繋げるために、<code>src</code>ディレクトリ内に<code>prisma.service.ts</code>を作ってください。</p>
<p>src/prisma.service.ts</p>
<pre><code>import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}
</code></pre>
<p><code>@Injectable()</code>という見慣れない書き方が出てきたと思いますが、これは<strong>デコレータ</strong>と言います。Pythonとかだと馴染みある機能だと思いますが、JavaScriptではまだ実験的な機能のようです。詳しく知りたい方は以下を参照してください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://zenn.dev/miruoon_892/articles/365675fa5343ed">https://zenn.dev/miruoon_892/articles/365675fa5343ed</a></p>
<p>NestJSではデコレータを利用した書き方がたくさん出てきます。<br />
デコレータは、後ろに続くメソッドやプロパティをラップできます。そのおかげで、シンプルなコードで強力な機能が使えるようになるわけです。</p>
<p>さて、次にモデルごとに便利な操作関数を作りましょう。<br />
<code>src/user.service.ts</code>と<code>src/post.service.ts</code>を作ります。</p>
<p>src/user.service.ts</p>
<pre><code>import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { User, Prisma } from '@prisma/client';
@Injectable()
export class UserService {
constructor(private prisma: PrismaService) {}
async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
): Promise<User | null> {
return this.prisma.user.findUnique({
where: userWhereUniqueInput,
include: {
posts: true,
},
});
}
async users(params: {
skip?: number;
take?: number;
cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.user.findMany({
skip,
take,
cursor,
where,
orderBy,
include: {
posts: true,
},
});
}
async createUser(data: Prisma.UserCreateInput): Promise<User> {
return this.prisma.user.create({
data,
include: {
posts: true,
},
});
}
async updateUser(params: {
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
return this.prisma.user.update({
data,
where,
});
}
async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
return this.prisma.user.delete({
where,
});
}
}
</code></pre>
<p>src/post.service.ts</p>
<pre><code>import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { Post, Prisma } from '@prisma/client';
@Injectable()
export class PostService {
constructor(private prisma: PrismaService) {}
async post(
postWhereUniqueInput: Prisma.PostWhereUniqueInput,
): Promise<Post | null> {
return this.prisma.post.findUnique({
where: postWhereUniqueInput,
include: {
author: true,
},
});
}
async posts(params: {
skip?: number;
take?: number;
cursor?: Prisma.PostWhereUniqueInput;
where?: Prisma.PostWhereInput;
orderBy?: Prisma.PostOrderByWithRelationInput;
}): Promise<Post[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.post.findMany({
skip,
take,
cursor,
where,
orderBy,
include: {
author: true,
},
});
}
async createPost(data: Prisma.PostCreateInput): Promise<Post> {
return this.prisma.post.create({
data,
include: {
author: true,
},
});
}
async updatePost(params: {
where: Prisma.PostWhereUniqueInput;
data: Prisma.PostUpdateInput;
}): Promise<Post> {
const { data, where } = params;
return this.prisma.post.update({
data,
where,
include: {
author: true,
},
});
}
async deletePost(where: Prisma.PostWhereUniqueInput): Promise<Post> {
return this.prisma.post.delete({
where,
});
}
}
</code></pre>
<p><code>include: { posts: true }</code>や<code>include: { author: true }</code>というのは、リレーションクエリの設定です。デフォルトではリレーションするデータを取得することはできないので、明示的に指定する必要があります。<br />
後述するGraphQLでもこのサービスを再利用するので重要です。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries">https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries</a></p>
<p>具体的には、リレーションクエリを指定しないと以下のようなレスポンスになります。(後述する動作確認の段階まで進むと実行できるようになります。)</p>
<pre><code class="sh">curl http://localhost:3000/post/1
{"id":1,"title":"titleTest","content":"contentTest","published":true,"authorId":1}
</code></pre>
<p>リレーションクエリを指定すると以下のようになります。</p>
<pre><code class="sh">curl http://localhost:3000/post/1
{"id":1,"title":"titleTest","content":"contentTest","published":true,"authorId":1,"author":{"id":1,"email":"test.jp","name":"namosuke"<span>}</span><span>}</span>
</code></pre>
<h2 id="NestJSのコントローラを構成"><a href="#NestJS%E3%81%AE%E3%82%B3%E3%83%B3%E3%83%88%E3%83%AD%E3%83%BC%E3%83%A9%E3%82%92%E6%A7%8B%E6%88%90">NestJSのコントローラを構成</a></h2>
<p>最後に、コントローラを書いて、APIのエンドポイントと便利関数を繋げます。</p>
<p>src/app.controller.ts</p>
<pre><code>import {
Controller,
Get,
Param,
Post,
Body,
Put,
Delete,
} from '@nestjs/common';
import { UserService } from './user.service';
import { PostService } from './post.service';
import { User as UserModel, Post as PostModel } from '@prisma/client';
@Controller()
export class AppController {
constructor(
private readonly userService: UserService,
private readonly postService: PostService,
) {}
@Get('post/:id')
async getPostById(@Param('id') id: string): Promise<PostModel> {
return this.postService.post({ id: Number(id) });
}
@Get('feed')
async getPublishedPosts(): Promise<PostModel[]> {
return this.postService.posts({
where: { published: true },
});
}
@Get('filtered-posts/:searchString')
async getFilteredPosts(
@Param('searchString') searchString: string,
): Promise<PostModel[]> {
return this.postService.posts({
where: {
OR: [
{
title: { contains: searchString },
},
{
content: { contains: searchString },
},
],
},
});
}
@Post('post')
async createDraft(
@Body() postData: { title: string; content?: string; authorEmail: string },
): Promise<PostModel> {
const { title, content, authorEmail } = postData;
return this.postService.createPost({
title,
content,
author: {
connect: { email: authorEmail },
},
});
}
@Post('user')
async signupUser(
@Body() userData: { name?: string; email: string },
): Promise<UserModel> {
return this.userService.createUser(userData);
}
@Put('publish/:id')
async publishPost(@Param('id') id: string): Promise<PostModel> {
return this.postService.updatePost({
where: { id: Number(id) },
data: { published: true },
});
}
@Delete('post/:id')
async deletePost(@Param('id') id: string): Promise<PostModel> {
return this.postService.deletePost({ id: Number(id) });
}
}
</code></pre>
<p>GETリクエストの場合は<code>@Get('post/:id')</code>みたいなデコレータでエンドポイントから受け取るパラメータを指定して、<code>@Param('id')</code>に流すみたいなそんな感じですね。<br />
POSTリクエストの場合は<code>@Post</code>から<code>@Body() postData: { title: string; content?: string; authorEmail: string }</code>みたいにして受け取ります。<br />
ちなみに、<code>@Param</code>も<code>@Body</code>も中身はString型になります。</p>
<p>公式チュートリアルはここで終わりですが、このままだとエラーが出るので以下のように<code>src/app.module.ts</code>を書き換えて修正します。<br />
<code>providers</code>にサービスを追加しています。</p>
<p>src/app.module.ts</p>
<pre><code>import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
import { UserService } from './user.service';
import { PostService } from './post.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService, PrismaService, UserService, PostService],
})
export class AppModule {}
</code></pre>
<p>ここまで書けばREST APIの実装は終わりです。</p>
<h2 id="REST APIの動作確認"><a href="#REST+API%E3%81%AE%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D">REST APIの動作確認</a></h2>
<p>動作確認方法は以下に詳しく書かれています。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://zenn.dev/tossy_yukky/articles/0075f9f0054b39d4ef59#%E8%B5%B7%E5%8B%95%E3%81%A8%E7%A2%BA%E8%AA%8D">https://zenn.dev/tossy_yukky/articles/0075f9f0054b39d4ef59#%E8%B5%B7%E5%8B%95%E3%81%A8%E7%A2%BA%E8%AA%8D</a></p>
<p><code>yarn start</code>したら http://localhost:3000 にサーバが立つので、curlとか使って動作するか試してみてください。</p>
<h3 id="実行例"><a href="#%E5%AE%9F%E8%A1%8C%E4%BE%8B">実行例</a></h3>
<pre><code class="sh">curl -XPOST -d 'name=test3&[email protected]' http://localhost:3000/user
{"id":4,"email":"[email protected]","name":"test3","posts":[]}
curl -XPOST -d 'title=niceTitle&content=niceContent&[email protected]' http://localhost:3000/post
{"id":3,"title":"niceTitle","content":"niceContent","published":false,"authorId":4,"author":{"id":4,"email":"[email protected]","name":"test3"<span>}</span><span>}</span>
curl -XPUT http://localhost:3000/publish/3
{"id":3,"title":"niceTitle","content":"niceContent","published":true,"authorId":4,"author":{"id":4,"email":"[email protected]","name":"test3"<span>}</span><span>}</span>
curl http://localhost:3000/feed
[{"id":1,"title":"titleTest","content":"contentTest","published":true,"authorId":1,"author":{"id":1,"email":"test.jp","name":"namosuke"<span>}</span><span>}</span>,{"id":2,"title":"はろー","content":"コンテンツ","published":true,"authorId":3,"author":{"id":3,"email":"[email protected]","name":"test"<span>}</span><span>}</span>,{"id":3,"title":"niceTitle","content":"niceContent","published":true,"authorId":4,"author":{"id":4,"email":"[email protected]","name":"test3"<span>}</span><span>}</span>]
</code></pre>
<h2 id="Prisma Studioの利用"><a href="#Prisma+Studio%E3%81%AE%E5%88%A9%E7%94%A8">Prisma Studioの利用</a></h2>
<p>データベースの中身をいじりたいときはPrisma Studioを使うと楽ちんです。機能がシンプルになったphpMyAdminみたいなイメージです。</p>
<pre><code class="sh">yarn prisma studio
</code></pre>
<p>と入力すると、 http://localhost:5555 でPrisma Studioが立ち上がります。</p>
<p><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/73364eb6-d89c-236b-9c51-5a163923cebd.png" alt="image.png" /></p>
<p><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/21f5f09e-9b53-4da8-9fae-9cea6a023b46.png" alt="image.png" /></p>
<p>データの簡単な追加、修正はここでやれば良さそうです。</p>
<h2 id="シードの利用"><a href="#%E3%82%B7%E3%83%BC%E3%83%89%E3%81%AE%E5%88%A9%E7%94%A8">シードの利用</a></h2>
<p>開発していると、初期データとして同じレコードを一度に投入したくなることがあります。<br />
特にPlanetScaleでは<code>main</code>ブランチを<code>production</code>ブランチに統合する際にすべてのレコードが失われるので、必要性が高いでしょう。<br />
そんなときは、Prismaを利用してシードスクリプトを作成すると便利です。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.prisma.io/docs/guides/database/seed-database">https://www.prisma.io/docs/guides/database/seed-database</a></p>
<h2 id="APIドキュメントを自動生成"><a href="#API%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%E3%82%92%E8%87%AA%E5%8B%95%E7%94%9F%E6%88%90">APIドキュメントを自動生成</a></h2>
<p>NestJSにはOpenAPI形式のドキュメントを扱うフレームワークSwaggerを利用して、APIドキュメントを自動生成してくれる機能があります。<br />
まずは必要なパッケージをインストールしましょう。</p>
<pre><code class="sh">yarn add @nestjs/swagger
</code></pre>
<p>次に、<code>src/main.ts</code>でSwaggerを初期化します。</p>
<p>src/main.ts</p>
<pre><code>import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('ユーザ投稿API')
.setDescription('ユーザが投稿できるAPIです')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}
bootstrap();
</code></pre>
<p>あとは<code>yarn start</code>するだけで、 http://localhost:3000/api に自動的にSwaggerのドキュメントページが立ち上がります。<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/5bed8412-ec44-03f2-0d3c-36a77aec3fc3.png" alt="image.png" /></p>
<p>ドキュメント内で実際にAPIを実行してみることもできます。<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/aa857f9d-0d13-df1c-19ba-ca5aec109d84.png" alt="image.png" /></p>
<p>ドメインルートにドキュメントを設置したい場合は</p>
<pre><code class="ts">SwaggerModule.setup('api', app, document);
</code></pre>
<p>となっている部分を</p>
<pre><code class="ts">SwaggerModule.setup('', app, document);
</code></pre>
<p>に変えることで、 http://localhost:3000 で表示できるようになります。</p>
<p>ちなみに、OpenAPIのJSONでの定義ファイルは http://localhost:3000/api-json からダウンロードできます。<br />
同様にYAMLでの定義ファイルは http://localhost:3000/api-yaml からダウンロードできます。<br />
(ドキュメントをドメインルートに設置している場合はそれぞれ http://localhost:3000/-json 、 http://localhost:3000/-yaml からダウンロードできます。)</p>
<h1 id="GraphQLの実装"><a href="#GraphQL%E3%81%AE%E5%AE%9F%E8%A3%85">GraphQLの実装</a></h1>
<p>GraphQLはREST APIの進化版のようなものです。一つの処理のために何度もAPIを呼んだり、実際にAPIを呼ぶまでレスポンスの形式がわからなかったりといった苦痛を解消してくれます。詳しくは以下を参照してください。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.apollographql.com/blog/graphql/basics/graphql-vs-rest/">https://www.apollographql.com/blog/graphql/basics/graphql-vs-rest/</a></p>
<p>ここからは以下を参考にしていきます。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://docs.nestjs.com/graphql/quick-start">https://docs.nestjs.com/graphql/quick-start</a></p>
<p><a target="_blank" rel="nofollow noopener" href="https://zenn.dev/rince/articles/50a66241d04f0b">https://zenn.dev/rince/articles/50a66241d04f0b</a></p>
<p>必要なパッケージをインストールしていきます。</p>
<pre><code class="sh">yarn add @nestjs/graphql @nestjs/apollo graphql apollo-server-express
</code></pre>
<p>GraphQLの開発では、コードからスキーマを生成する<strong>コードファースト</strong>と、スキーマからコードを生成する<strong>スキーマファースト</strong>という2つのアプローチがあります。<br />
どちらにせよ処理に必要なコードを書かないといけないので、コードファーストのほうが良いと思います。コードファーストで進めます。</p>
<h2 id="NestJSのモジュールを構成"><a href="#NestJS%E3%81%AE%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%82%92%E6%A7%8B%E6%88%90">NestJSのモジュールを構成</a></h2>
<p>AppModuleに色々追加していきます。</p>
<p>src/app.module.ts</p>
<pre><code>import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
import { PostService } from './post.service';
import { PostResolver } from './post.resolver';
import { join } from 'path';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
}),
],
controllers: [AppController],
providers: [
AppService,
PrismaService,
UserService,
UserResolver,
PostService,
PostResolver,
],
})
export class AppModule {}
</code></pre>
<h2 id="NestJSのモデルを構成"><a href="#NestJS%E3%81%AE%E3%83%A2%E3%83%87%E3%83%AB%E3%82%92%E6%A7%8B%E6%88%90">NestJSのモデルを構成</a></h2>
<p>続いて、GraphQLのスキーマとして必要な型を設定していきます。<br />
<code>src/user.model.ts</code>と<code>src/post.model.ts</code>を作ります。</p>
<p>src/user.model.ts</p>
<pre><code>import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Post } from 'src/post.model';
@ObjectType()
export class User {
@Field((type) => ID)
id: number;
@Field()
email: string;
@Field({ nullable: true })
name: string | null;
@Field((type) => [Post], { nullable: true })
posts: Post[] | null;
}
</code></pre>
<p>src/post.model.ts</p>
<pre><code>import { Field, ID, ObjectType } from '@nestjs/graphql';
import { User } from 'src/user.model';
@ObjectType()
export class Post {
@Field((type) => ID)
id: number;
@Field()
title: string;
@Field({ nullable: true })
content?: string;
@Field()
published: boolean;
@Field((type) => User, { nullable: true })
author?: User;
}
</code></pre>
<p>型を使うために相互に参照し合っているのが面白いですね。<br />
<code>@Field</code>には、曖昧さを無くすためにGraphQLの型を指定できます。例えばTypeScriptの型<code>: number</code>では<code>Int</code>なのか<code>Float</code>なのか<code>ID</code>なのかわからないので、明示的に指定してあげましょう。</p>
<p>記述する際には<code>prisma/schema.prisma</code>を見ながら書くと楽です。<code>prisma/schema.prisma</code>からモデルを自動生成してくれる非公式パッケージ(<a target="_blank" rel="nofollow noopener" href="https://www.npmjs.com/package/prisma-nestjs-graphql">prisma-nestjs-graphql</a>)もあるようですが、動作に不安があるので手書きのほうが安心だと思います。</p>
<h2 id="NestJSのリゾルバを構成"><a href="#NestJS%E3%81%AE%E3%83%AA%E3%82%BE%E3%83%AB%E3%83%90%E3%82%92%E6%A7%8B%E6%88%90">NestJSのリゾルバを構成</a></h2>
<p>いよいよGraphQL版のコントローラみたいなやつ、リゾルバを書いていきます。<br />
ここに書かれたメソッドが、そのままGraphQLから呼び出せるようになります。<br />
<code>src/user.resolver.ts</code>と<code>src/post.resolver.ts</code>を作成します。</p>
<p>src/user.resolver.ts</p>
<pre><code>import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UserService } from 'src/user.service';
import { User } from './user.model';
@Resolver(() => User)
export class UserResolver {
constructor(private userService: UserService) {}
@Query(() => [User])
async users() {
return this.userService.users({});
}
@Query(() => User)
async user(@Args('id') id: number) {
return this.userService.user({ id });
}
@Mutation(() => User)
async createPost(@Args('email') email: string, @Args('name') name: string) {
return this.userService.createUser({ email, name });
}
}
</code></pre>
<p>src/post.resolver.ts</p>
<pre><code>import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PostService } from 'src/post.service';
import { UserService } from './user.service';
import { Post } from './post.model';
@Resolver(() => Post)
export class PostResolver {
constructor(
private postService: PostService,
private userService: UserService,
) {}
@Query(() => [Post])
async posts() {
return this.postService.posts({});
}
@Query(() => Post)
async post(@Args('id') id: number) {
return this.postService.post({ id });
}
@Mutation(() => Post)
async createPost(
@Args('title') title: string,
@Args('content') content: string,
) {
return this.postService.createPost({ title, content });
}
@ResolveField()
async author(@Parent() post: Post) {
return this.userService.user({ id: post.author.id });
}
}
</code></pre>
<p>REST APIを作成するときに作ったサービスをそのまま使っています。<br />
サービスに作った便利関数をREST APIでもGraphQLでも使えるわけですね。</p>
<p>ちなみに、<code>@ResolveField()</code>という部分では、入れ子にしてデータを深掘って取得できるフィールドを指定しています。<br />
これが無いと、REST APIで取得できる以上のデータが取得できず、せっかくのGraphQLの強みが活かせません。<br />
例えば今回は<code>post</code>のリゾルバに<code>author</code>を指定しているので、特定の投稿から著者を取得し、さらに著者の持つ全ての投稿を同時に取得できるようになります。</p>
<p><code>@ResolveField()</code>を設定しなかった場合:<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/428011f8-90ab-d9c5-900a-6e9ab4c5e905.png" alt="image.png" /></p>
<p><code>@ResolveField()</code>を設定した場合:<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/ddca20a3-3043-8354-336a-5095554a8654.png" alt="image.png" /></p>
<h2 id="GraphQLの動作確認"><a href="#GraphQL%E3%81%AE%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D">GraphQLの動作確認</a></h2>
<p>これでおしまい!<br />
<code>yarn start:dev</code>したあとに http://localhost:3000/graphql を開いてplaygroundを確認してみましょう。</p>
<p>スキーマを確認したり…<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/cf3f1c57-b90e-940b-772b-e2cfda55cf65.png" alt="image.png" /></p>
<p>自動生成されたdocsを確認したり…<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/9dc6fee9-adf5-cc85-aefd-daca010e6188.png" alt="image.png" /></p>
<p>クエリを投げてみたり…<br />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218499/2cca6608-5ec3-af51-4bad-6f93db7683ce.png" alt="image.png" /></p>
<p>大丈夫そうですね!ばっちりです!</p>
ウラル
tag:crieit.net,2005:PublicArticle/15888
2020-05-08T19:25:38+09:00
2020-05-08T19:25:38+09:00
https://crieit.net/posts/b3c547c34df6393a1f86f8072aaf510a
アニメのレコメンドサービスを作りました。
<p><a href="https://crieit.now.sh/upload_images/b0f24117575660588f657c26c91d370e5eb525f79a7b2.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/b0f24117575660588f657c26c91d370e5eb525f79a7b2.png?mw=700" alt="" /></a></p>
<h1 id="サービスURL"><a href="#%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9URL">サービスURL</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://annict-suggest.netlify.app/">https://annict-suggest.netlify.app/</a></p>
<p><a href="https://crieit.now.sh/upload_images/102e92a702e89059033b1dff5b0f87c55eb5336deb10d.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/102e92a702e89059033b1dff5b0f87c55eb5336deb10d.jpg?mw=700" alt="" /></a></p>
<h1 id="アニメの類似性をどう計算するか"><a href="#%E3%82%A2%E3%83%8B%E3%83%A1%E3%81%AE%E9%A1%9E%E4%BC%BC%E6%80%A7%E3%82%92%E3%81%A9%E3%81%86%E8%A8%88%E7%AE%97%E3%81%99%E3%82%8B%E3%81%8B">アニメの類似性をどう計算するか</a></h1>
<h2 id="コサイン類似度"><a href="#%E3%82%B3%E3%82%B5%E3%82%A4%E3%83%B3%E9%A1%9E%E4%BC%BC%E5%BA%A6">コサイン類似度</a></h2>
<p>人工知能を使わずにアニメのレコメンドサービスを作ろうと思ったのがきっかけです。<br />
<a href="https://crieit.net/posts/5308d8a3ed140ecc15e1310dad28e9e9">ユークリッド距離は触ったことがある</a>ので、他の指標としてコサイン類似度が面白そうだと思いました。<br />
ユークリッド距離は2点間の距離、コサイン類似度は2点のベクトル同士の角度です。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.albert2005.co.jp/knowledge/data_mining/cluster/cluster_summary">クラスター分析の手法①(概要)</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/tetsutaroendo/items/61942d25ae2a017831f2">コサイン類似度を利用し、集団の類似性を測ってみる</a></li>
</ul>
<h3 id="使った指標"><a href="#%E4%BD%BF%E3%81%A3%E3%81%9F%E6%8C%87%E6%A8%99">使った指標</a></h3>
<p>約3300の作品に対して「見た」「見てない」のベクトルを作ってコサイン類似度を算出しようとしました。<br />
「あにこれ」のように成分分析されているタグの類似度を計算するのもありだと思いました。</p>
<h3 id="挫折"><a href="#%E6%8C%AB%E6%8A%98">挫折</a></h3>
<p>導入は比較的楽なように思えたのですが、計算量が尋常ではありませんでした。事前にフィルタリングを何もかけていなかったため、3300レコードx3300レコードの計算をしようとしていて、あまりに時間がかかるので諦めました。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/harperfu6/items/3238d8f78c8a8d8cf114">アイテムの類似性について考察してみる</a></li>
</ul>
<h3 id="結局SQL"><a href="#%E7%B5%90%E5%B1%80SQL">結局SQL</a></h3>
<p>例:ID4342の作品を見たユーザを抽出して、<br />
それらのユーザが他に見た作品のうち共通している人数が多い順に30件を取得する。</p>
<pre><code class="sql">select
sum(st2.watch_status) as count,
st2.work_id,
w.title
from status st2
-- ID:4342の作品を見たユーザを取得
inner join (
select
st.user_id
from status st
where st.work_id = 4342
)st3
on st2.user_id = st3.user_id
inner join works w
on w.annict_id = st2.work_id
-- 作品自身を除く
where st2.work_id != 4342
group by st2.work_id
order by count desc
limit 30
</code></pre>
<h1 id="今回使った技術"><a href="#%E4%BB%8A%E5%9B%9E%E4%BD%BF%E3%81%A3%E3%81%9F%E6%8A%80%E8%A1%93">今回使った技術</a></h1>
<ul>
<li>GraphQL(Annict API)</li>
<li>ReactJS</li>
<li>NodeJS(TypeScript)</li>
<li>twitterAPI</li>
<li>netlify</li>
</ul>
<h2 id="データの棲み分け"><a href="#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E6%A3%B2%E3%81%BF%E5%88%86%E3%81%91">データの棲み分け</a></h2>
<p>最新のデータが欲しい場合はAnnictAPI(GraphQL)、分析データが欲しい場合はDBから読み込み、というようにデータの棲み分けを行っています。</p>
<h2 id="GraphQLでエイリアスを使う"><a href="#GraphQL%E3%81%A7%E3%82%A8%E3%82%A4%E3%83%AA%E3%82%A2%E3%82%B9%E3%82%92%E4%BD%BF%E3%81%86">GraphQLでエイリアスを使う</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://developers.annict.jp/graphql-api/reference/">Annict API</a><br />
ユーザが見たアニメと見ているアニメ両方が欲しい場合、<br />
エイリアスを使うと複数条件が記述できる。</p>
<pre><code class="javascript">query {
user(username:"${username}"){
annictId,
works(state:WATCHED){
nodes{
annictId
title
}
}
ing:works(state:WATCHING){
nodes{
annictId
title
}
}
}
}
</code></pre>
<h2 id="CSP(コンテンツセキュリティポリシー)"><a href="#CSP%28%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%83%9D%E3%83%AA%E3%82%B7%E3%83%BC%29">CSP(コンテンツセキュリティポリシー)</a></h2>
<p>アニメのOGPがない場合は公式twitterアカウントの画像を使用しているが、CSPなどで同じサイトでないコンテンツは表示できなくなったので、<br />
URLに「twitter」が含まれる場合はサーバにプロキシさせて画像を読み込むようにした。</p>
<h1 id="ご意見"><a href="#%E3%81%94%E6%84%8F%E8%A6%8B">ご意見</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://ikens.net/ckoshien_tech/annict-suggest?v=1">こちら</a>から使ってみた感想・ご意見をお寄せください。</p>
ckoshien
tag:crieit.net,2005:PublicArticle/15427
2019-09-26T22:08:54+09:00
2019-09-26T22:08:54+09:00
https://crieit.net/posts/React-PWA-Rails-GraphQL-RPG
React PWA + Rails GraphQLで作ったポモドーロRPGに使った技術やその選定理由を書いてみた
<p>先日、『<a target="_blank" rel="nofollow noopener" href="https://www.g-g-g-g.games">g4</a>』というポモドーロ+RPGなサービスをリリースしました。</p>
<p>そのサービスで使った技術について聞かれることがあったので残しておきます。</p>
<h1 id="どんなサービス?"><a href="#%E3%81%A9%E3%82%93%E3%81%AA%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%3F">どんなサービス?</a></h1>
<p>ポモドーロ・タイマーを使い25分間集中すると経験値をもらえ、その経験値でレベルが上がる。<br />
って言う感じのやつです。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.g-g-g-g.games">https://www.g-g-g-g.games</a></p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.g-g-g-g.games"><a href="https://crieit.now.sh/upload_images/4857e38ac110f7f2ccb9a3a6c9851f425d8c46a290533.jpeg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/4857e38ac110f7f2ccb9a3a6c9851f425d8c46a290533.jpeg?mw=700" alt="https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_51402_6c1fef60-9f24-2233-821b-1b4b16575e21.jpeg" /></a></a></p>
<h2 id="こんな特徴があります。"><a href="#%E3%81%93%E3%82%93%E3%81%AA%E7%89%B9%E5%BE%B4%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82">こんな特徴があります。</a></h2>
<ul>
<li>ポモドーロ・タイマーやRPG的なUIはリッチで動きがある</li>
<li>現在のステータスをOGP画像にしてシェアできる</li>
<li>上昇する能力値や覚えるスキルは登録した文章を解析して決まる</li>
</ul>
<h1 id="構成はこんな感じ"><a href="#%E6%A7%8B%E6%88%90%E3%81%AF%E3%81%93%E3%82%93%E3%81%AA%E6%84%9F%E3%81%98">構成はこんな感じ</a></h1>
<p><a href="https://crieit.now.sh/upload_images/a91960cfcbabfe4677662c7e3a35b9225d8c468610acb.jpeg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/a91960cfcbabfe4677662c7e3a35b9225d8c468610acb.jpeg?mw=700" alt="https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_51402_1f7797cd-59e5-b556-2afe-b0a964acd0ac.jpeg" /></a></p>
<h2 id="フロントエンドの選定理由"><a href="#%E3%83%95%E3%83%AD%E3%83%B3%E3%83%88%E3%82%A8%E3%83%B3%E3%83%89%E3%81%AE%E9%81%B8%E5%AE%9A%E7%90%86%E7%94%B1">フロントエンドの選定理由</a></h2>
<p>フロントエンドはSSRしたReactアプリをfly.ioにおいています</p>
<h3 id="[React]"><a href="#%5BReact%5D">[React]</a></h3>
<p>自分は過去に仕事でNuxt.jsや生Vue.jsを使ったことがあり、個人ではExpoやNext.jsでReactにも触っていました。</p>
<p>今回Reactを選択した理由は以下です。</p>
<ul>
<li>型が欲しかった。Typescriptを使いたかった</li>
<li>Reactのほうが書いていて楽しい(個人の感想です)
<ul>
<li>React Hooksめっちゃいい</li>
</ul></li>
<li>Apolloを使うならVueよりReactのほうが色々揃っている</li>
</ul>
<h3>[<a target="_blank" rel="nofollow noopener" href="https://fly.io">fly.io</a>]</h3>
<p>SSRするためにNode.jsをホスティングするやつが欲しかったので選定。<br />
<a target="_blank" rel="nofollow noopener" href="https://mizchi.hatenablog.com/entry/2019/02/21/235403">mizchiさんのブログ</a>で知り、面白そうだなと思ったのがきっかけ。<br />
べつにfirebase hostingでもよかったですが、以下が決め手</p>
<ul>
<li>firebase hosting同様初期導入費用がほぼ0</li>
<li>CDN</li>
<li>キャッシュコントロール</li>
<li>svgを使ったOGP画像の生成ができる</li>
</ul>
<p>特に、</p>
<blockquote>
<p>svgを使ったOGP画像の生成ができる</p>
</blockquote>
<p>に有用性を感じて選定したんですが、現時点ではカスタムフォントと日本語への対応が十分でなく、別途Cloud Runを使うことになってしまっています(後述)<br />
(サポートに聞いたらリポジトリ教えてもらったので(OSS)対応できるようなら自分で対応してなげたいなぁ)</p>
<h5 id="キャッシュコントロールもよい"><a href="#%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5%E3%82%B3%E3%83%B3%E3%83%88%E3%83%AD%E3%83%BC%E3%83%AB%E3%82%82%E3%82%88%E3%81%84">キャッシュコントロールもよい</a></h5>
<p>キャッシュの操作も面白くて、<br />
g4の公開用ページではAPIからもらった結果をキャッシュして、キャッシュがあればそれをそのまま返すということをしています。</p>
<p>これによってAPIサーバーへの負荷はかなり低くなってると思います。<br />
APIサーバー側からは、データ更新時にfly.ioのキャッシュを削除する操作をしていて、データが更新されるまではfly.ioはキャッシュを使い続けてます。</p>
<p>この辺のAPIが普通に使いやすくて簡単にかけるのでめっちゃ良きです。</p>
<h5 id="fly.io良いよ"><a href="#fly.io%E8%89%AF%E3%81%84%E3%82%88">fly.io良いよ</a></h5>
<p>fly.io自体ははデプロイも早いし、導入コストも安いので<a target="_blank" rel="nofollow noopener" href="https://zeit.co">now</a>とかと並べて、手軽にNode.jsをホスティングするための選択肢にしてもいいんじゃないかなって思います。</p>
<h3 id="Next.jsを選ばなかった理由"><a href="#Next.js%E3%82%92%E9%81%B8%E3%81%B0%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F%E7%90%86%E7%94%B1">Next.jsを選ばなかった理由</a></h3>
<p>SSRする予定ではありましたが、OGPだけ生成できればいいなというレベルだったので、通常のReactアプリで作りました。<br />
なるべく継続開発するため、アップデートの手間と依存を減らしたかったのも少しある</p>
<h3 id="[PWA]を選定した理由"><a href="#%5BPWA%5D%E3%82%92%E9%81%B8%E5%AE%9A%E3%81%97%E3%81%9F%E7%90%86%E7%94%B1">[PWA]を選定した理由</a></h3>
<p>最初は、<a target="_blank" rel="nofollow noopener" href="https://expo.io">Expo</a>で作ろうかと思っていました。</p>
<p>もともとSPAでアプリっぽいUIを作る仕事を受けたときに、Nuxt.js + Firebaseで作り、マルチブラウザ対応が辛いと思った経験がありました。</p>
<p>RNならその辺いい感じに作れるかもと思って<a target="_blank" rel="nofollow noopener" href="https://shwld.net/seicho-release/">みんなの成長</a> というアプリでExpoを試しみて、使い勝手が良い事までは確認してました。</p>
<p>ただ今回はモバイルより、デスクトップアプリのほうが欲しいということに気づき、<br />
Electronで作りやすかったり、PWAならそのままインストールもできちゃうのでWebでいいかなって思いPWAにしました。</p>
<p>なのでマルチブラウザ辛いは解決してないです。<br />
ここは個人開発なので修行と割り切って本業に生かしていきます。<br />
Safari対応が結構めんどいけど、ユーザー多くて無視できない...</p>
<p>マルチブラウザと言っても、現状Chrome、Safari以外はほぼ無視してまっす。</p>
<h3>[<a target="_blank" rel="nofollow noopener" href="https://www.apollographql.com">Apollo Client (react-apollo)</a>]</h3>
<p>これもSPAでアプリっぽいUIを作る仕事で、Nuxt.js + Firebaseで作ったときに、Firebaseのデータベース(このときはrealtime database使ってた)をフロントに反映するのが辛くて、同じようにREST APIも辛いだろうなと思って、GraphQLに手をだしたのがきっかけでした。</p>
<p>GraphQLだけでもすごく良いのですが、Apollo Clientがいいのはキャッシュコントールで、<br />
クエリ毎にキャッシュを優先するかサーバーを優先するかを選択できます。</p>
<p>また、キャッシュがあれば再取得しないなどを簡単に書けたり、とにかくこれはSPAのベストプラクティスだなって感じの実装をシンプルに書けます。</p>
<p>Mutationするとキャッシュが書き換わったりするのもめっちゃ良き</p>
<p>正直この辺のこと考えながらSPA実装するのってかなり大変じゃない?ってか大変だった<br />
アプリライクなUIのSPAならApollo Client使おうぜ!</p>
<p>こいつのおかげでこれまたAPIサーバーの負荷はまあまあ抑えられてそうです。</p>
<h2 id="[Firebase Auth, Storage]について"><a href="#%5BFirebase+Auth%2C+Storage%5D%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">[Firebase Auth, Storage]について</a></h2>
<p>バックエンドはRailsですが、APIサーバー作るのに正直Deviseとか使いたくないです(完)</p>
<p>Twitter連携やら諸々簡単なので選ばない理由がない。<br />
Auth0は選択肢に入るけど今回GCPなのでGoogleでまとめられるし、そこまで込み入った認証必要ないしでこれにしました。</p>
<p>Storageは別に何でもよかったですが、Firebase Authの認証ユーザー単位での権限指定が楽なので使い勝手は良いです。</p>
<h2 id="デザインについて"><a href="#%E3%83%87%E3%82%B6%E3%82%A4%E3%83%B3%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">デザインについて</a></h2>
<p>g4のデザインはレスポンシブのヘルパーとして<a target="_blank" rel="nofollow noopener" href="https://github.com/twbs/bootstrap/blob/master/dist/css/bootstrap-grid.css">bootstrap-grid</a>だけ使わせてもらってます。<br />
めっちゃ便利なのでおすすめ。</p>
<p>それ以外はcss全部自分で書いてます。</p>
<p>コンポーネントファーストで作成しており、Atomic Designの粒度で、<a target="_blank" rel="nofollow noopener" href="https://storybook.js.org">Storybook</a>作ってコンポーネントを作ってる感じです。<br />
Storybookは割とぶっ壊れやすかったり、他のビルド設定と競合とか重複したり、辛みもありますが、<a target="_blank" rel="nofollow noopener" href="https://storybook.js.org/docs/testing/structural-testing/">storyshots</a>によるスナップショットの差分確認ができるのと、テストの安心感があるので、無いと無いで不安ではあります。</p>
<h2 id="バックエンドの選定理由"><a href="#%E3%83%90%E3%83%83%E3%82%AF%E3%82%A8%E3%83%B3%E3%83%89%E3%81%AE%E9%81%B8%E5%AE%9A%E7%90%86%E7%94%B1">バックエンドの選定理由</a></h2>
<h3>[<a target="_blank" rel="nofollow noopener" href="https://graphql-ruby.org">graphql-ruby</a> & Ruby on Rails]</h3>
<p>Apollo Clientの項で書きましたが、GraphQLを使うことは決まっていました。<br />
GraphQLをマネージドでホスティングしてくれるやつを探したりしたんですが、普段Railsエンジニアなので慣れてるからテストとかも書きやすいしRailsでいっかで、graphql-rubyにしました。</p>
<p>Railsにするとランニングコストは割とかかるんですが、今回は個人開発のメインに据えて長く継続開発したいを目的にしてたので、<br />
稼働してないものにお金払うみたいなことにならない想定(自分が使い続ける)で、コストかける覚悟はしました。</p>
<p>graphql-rubyは仕事でも使ってるので、今後も押していきたい存在。</p>
<h3 id="[GCP/App Engine]"><a href="#%5BGCP%2FApp+Engine%5D">[GCP/App Engine]</a></h3>
<p>App Engineについては<a target="_blank" rel="nofollow noopener" href="https://qiita.com/shwld/items/e86ee3f642c7857dd56e">こちらの記事</a>で書きました。</p>
<p>要約するとHerokuでも良かったけど、使ってみたかったのでGCPにした。です。</p>
<h2 id="[GCP/Natural Language API]"><a href="#%5BGCP%2FNatural+Language+API%5D">[GCP/Natural Language API]</a></h2>
<p>便利。Azureとかにもありますが、今回はGoogle統一で行きました。くらいの選定理由</p>
<h2 id="[GCP/Cloud Run]について"><a href="#%5BGCP%2FCloud+Run%5D%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">[GCP/Cloud Run]について</a></h2>
<p>Cloud Runは今回使う予定は全くありませんでした。</p>
<p>今利用している理由としては、</p>
<ul>
<li>fly.ioでOGPを生成できるとふんでいたができなかった。</li>
<li>とりあえず手っ取り早くカスタムしたDockerimageでfry.ioをあまりお金をかけずに使えるとこはないか。</li>
</ul>
<p>からの、Cloud Run。</p>
<p>中身は日本語とフォントをインストールした普通のNodeイメージに<br />
fly.ioのサーバーを単純に乗っけて起動しているだけです。(プレビルドして実行したいがやり方が分からずdevサーバー起動しているのでなんとかしたい。誰かやり方知りませんか。)</p>
<p>なので、いずれはfly.ioだけでできるようして、Cloud Runをやめたいです。</p>
<h4 id="Cloud Run超便利"><a href="#Cloud+Run%E8%B6%85%E4%BE%BF%E5%88%A9">Cloud Run超便利</a></h4>
<p>そんな理由で使い始めましたが、驚くくらい簡単かつ手軽にカスタムDockerイメージが使えるので、Cloud Runめっちゃ便利です。<br />
今後活用していきたい。</p>
<h2 id="おわりに"><a href="#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB">おわりに</a></h2>
<p>割と構成的にはガチよりになりました。<br />
自分の技術力総動員してる感じなのでテンション上がってめっちゃ楽しい!</p>
<p>g4の運用を支える技術についても今後書いていきたいです。</p>
<p>みんなでレベル上げしましょう!</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.g-g-g-g.games">https://www.g-g-g-g.games</a></p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.g-g-g-g.games"><a href="https://crieit.now.sh/upload_images/4857e38ac110f7f2ccb9a3a6c9851f425d8c46a290533.jpeg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/4857e38ac110f7f2ccb9a3a6c9851f425d8c46a290533.jpeg?mw=700" alt="https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_51402_6c1fef60-9f24-2233-821b-1b4b16575e21.jpeg" /></a></a></p>
shwld
tag:crieit.net,2005:PublicArticle/14995
2019-05-18T21:52:15+09:00
2019-05-18T21:52:15+09:00
https://crieit.net/posts/ReactNative
ReactNativeアプリを作り始めました。
<h1 id="背景"><a href="#%E8%83%8C%E6%99%AF">背景</a></h1>
<p>アニメ視聴遅れ管理サービスを作っているのですが、<a href="https://crieit.net/boards/annict-access/RN">ReactNativeアプリ</a>で好きなアニメランキングを作りはじめました。<br />
これがランキングを作る際の番組選択画面にあたります。<br />
(warningが出てるのは許してください....。)</p>
<p><a href="https://crieit.now.sh/upload_images/73aa3ebbbfecf178ae23f56d1cbdbed45cdffa0224711.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/73aa3ebbbfecf178ae23f56d1cbdbed45cdffa0224711.jpg?mw=700" alt="image" /></a></p>
<h1 id="GraphQLでサーバからデータを取得する"><a href="#GraphQL%E3%81%A7%E3%82%B5%E3%83%BC%E3%83%90%E3%81%8B%E3%82%89%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B">GraphQLでサーバからデータを取得する</a></h1>
<p>以前、<a href="https://crieit.net/posts/React-GraphQL-API">ReactでGraphQL APIにアクセスする</a>という記事を書いたので今回は割愛します。</p>
<p>今回作成したクエリはこちら。<br />
<strong>${year}</strong> で取得したい年を渡しています。<br />
クエリの内容としては、${year}で指定した番組のタイトル、画像URL、annict上の番組ID、シーズンを視聴者数順にソートして返します。</p>
<pre><code class="javascript">let query = gql`{
searchWorks(
seasons:["${year}-spring","${year}-summer","${year}-autumn","${year}-winter"],
orderBy: { field: WATCHERS_COUNT, direction: DESC }
){
edges{
node{
title
image{recommendedImageUrl}
annictId
seasonName
seasonYear
}
}
}
}`
</code></pre>
<h1 id="長すぎるタイトルを省略する"><a href="#%E9%95%B7%E3%81%99%E3%81%8E%E3%82%8B%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB%E3%82%92%E7%9C%81%E7%95%A5%E3%81%99%E3%82%8B">長すぎるタイトルを省略する</a></h1>
<p>TextコンポーネントにこんなPropsがあるらしい。<br />
<a target="_blank" rel="nofollow noopener" href="https://qiita.com/kondoakio/items/5a27aaf8e6a57b1fc106">【React Native】三点リーダーでテキストを省略</a></p>
<p>今回使ったProps。</p>
<pre><code class="javascript">numberOfLines={3}
ellipsizeMode="tail"
</code></pre>
<h1 id="番組データを3カラムで表示する"><a href="#%E7%95%AA%E7%B5%84%E3%83%87%E3%83%BC%E3%82%BF%E3%82%923%E3%82%AB%E3%83%A9%E3%83%A0%E3%81%A7%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B">番組データを3カラムで表示する</a></h1>
<p>deprecatedになっているListViewを使っているところは気になりますが、FlatListに読み替えてしまえばかなりの良記事だと思います。<br />
<a target="_blank" rel="nofollow noopener" href="https://qiita.com/mat_aki/items/2db69acf61cf15ad70de">React Native の ListView で2カラムの表示を簡単に</a></p>
<h1 id="番組画像に作品タイトルをオーバーレイさせる"><a href="#%E7%95%AA%E7%B5%84%E7%94%BB%E5%83%8F%E3%81%AB%E4%BD%9C%E5%93%81%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB%E3%82%92%E3%82%AA%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%A4%E3%81%95%E3%81%9B%E3%82%8B">番組画像に作品タイトルをオーバーレイさせる</a></h1>
<p>元々UIに疎かった私。<br />
画像に半透明のレイヤーを被せてその上に文字を表示するのを何というかということも知らなかったので、「画像 文字 のせる」というところからググりはじめて「オーバーレイ」と呼ぶことを知ったので、「react native overlay text」でググると...。<br />
<a target="_blank" rel="nofollow noopener" href="https://stackoverflow.com/questions/49250047/how-to-place-a-text-over-image-in-react-native">How to place a text over image in react native?<br />
</a></p>
<p>Imageコンポーネントはchildrenを持てないようなので、<br />
<strong>ImageBackGround</strong>コンポーネントを使います。</p>
<h1 id="実装まとめ"><a href="#%E5%AE%9F%E8%A3%85%E3%81%BE%E3%81%A8%E3%82%81">実装まとめ</a></h1>
<p>今回行った実装の一部です。<br />
Imageコンポーネントに画像URLを渡していますが、<br />
空文字やnullなどは怒られてしまうので条件分岐をさせて回避します。<br />
これで作品画像にタイトルがオーバーレイしたリストを3カラムで表示させることができます。</p>
<pre><code class="javascript">return (
<FlatList
style=<span>{</span><span>{</span> flex: 1, paddingTop: 20, backgroundColor: '#dddddd' <span>}</span><span>}</span>
contentContainerStyle=<span>{</span><span>{</span> flexDirection: 'row', flexWrap: 'wrap' <span>}</span><span>}</span>
data={store.getState().data}
renderItem={(rowData)=>{
let image
if(rowData.item.node.image !== null){
image = (
<ImageBackground
style={
{
width: (Dimensions.get('window').width - 20) / 3 - 20,
height: 80,
marginTop:10,
marginBottom:10,
marginLeft:10,
marginRight:10
}
}
source={
{
uri: rowData.item.node.image.recommendedImageUrl
}
}>
<View
style={
{
position: 'absolute',
//width: '100%',
bottom: 0,
justifyContent: 'center',
alignItems: 'center'<span>}</span><span>}</span>>
<Text
style=<span>{</span><span>{</span>
fontWeight:'bold',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
width:(Dimensions.get('window').width - 20) / 3 - 20
<span>}</span><span>}</span>
numberOfLines={3}
ellipsizeMode="tail"
>{rowData.item.node.title}</Text>
</View>
</ImageBackground>
)
}else{
image = null
}
return (
<View style={
{ padding: 1,
backgroundColor: 'white',
margin: 2,
width: (Dimensions.get('window').width - 20) / 3,
height: 100
}
}
>
{image}
</View>
)
<span>}</span><span>}</span>
/>
)
</code></pre>
ckoshien
tag:crieit.net,2005:PublicArticle/14906
2019-04-07T21:09:34+09:00
2019-04-08T23:33:53+09:00
https://crieit.net/posts/NodeJS-GraphQL
NodeJSでGraphQLのサーバ側処理を実装してみる
<h1 id="背景"><a href="#%E8%83%8C%E6%99%AF">背景</a></h1>
<p>先週はクライアントからGraphQLへのアクセス方法を学習しました。<br />
- <a href="https://crieit.net/posts/React-GraphQL-API">ReactでGraphQL APIにアクセスする</a></p>
<p>今週は「サーバサイドはどう実装するのか?」という部分を調査しました。</p>
<h1 id="パッケージインストール"><a href="#%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB">パッケージインストール</a></h1>
<p>今回は既に実装しているアプリケーションサーバにgraphqlを組み込みます。</p>
<pre><code>$ npm install graphql express-graphql -save
</code></pre>
<h1 id="スキーマ定義"><a href="#%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E%E5%AE%9A%E7%BE%A9">スキーマ定義</a></h1>
<p>jsonでもtypescriptでもないので一旦外部ファイル(<strong>graphql/schema.graphql</strong>)として定義しました。<br />
graphqlに対応しているlintとかあるみたいなのですが、<br />
今回は調査が追いついていません。</p>
<h2 id="Queryタイプに型とクエリを定義する"><a href="#Query%E3%82%BF%E3%82%A4%E3%83%97%E3%81%AB%E5%9E%8B%E3%81%A8%E3%82%AF%E3%82%A8%E3%83%AA%E3%82%92%E5%AE%9A%E7%BE%A9%E3%81%99%E3%82%8B">Queryタイプに型とクエリを定義する</a></h2>
<p>id(選手ID)でフィルタリングしてPlayer(選手)を返すクエリと<br />
players(選手リスト)を返すクエリを想定します。<br />
Playerオブジェクトは以下のようなプロパティを持つとします。<br />
DBからデータを取得して返却する際にプロパティ同士がマッピングされます。</p>
<pre><code class="ts">type Query {
player(id: Int!): Player
players(name: String): [Player]
},
type Player {
id: Int
name: String
team_name :String
team_id: Int
}
</code></pre>
<h1 id="エンドポイントの作成"><a href="#%E3%82%A8%E3%83%B3%E3%83%89%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90">エンドポイントの作成</a></h1>
<ul>
<li>スキーマを外部ファイルから読み込む</li>
<li>buildSchemaに渡してスキーマを生成する</li>
<li>express_graphqlに
<ul>
<li>第一引数にスキーマ</li>
<li>第二引数にメソッド定義</li>
<li>第三引数はAPIエンドポイントでGraphiQL(GUI)を動かせるようにするかどうか</li>
</ul></li>
</ul>
<p>を渡す。</p>
<p><strong>App.ts</strong></p>
<pre><code class="typescript">import { graphql} from './graphql'
import * as express_graphql from 'express-graphql';
import { buildSchema } from 'graphql';
(中略)
let schemaStr = readFileSync('./graphql/schema.graphql',{encoding:'utf8'})
var schema = buildSchema(schemaStr);
let root = {
player:(args)=>{
let result = new graphql().getPlayer(args)
console.log(result)
return result
}
};
app.use('/graphql', express_graphql({
schema: schema,
rootValue: root,
graphiql: true
}));
</code></pre>
<h1 id="メソッド実装"><a href="#%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E5%AE%9F%E8%A3%85">メソッド実装</a></h1>
<p>GraphQLの処理部分は分けて実装したかったので、別途graphql.tsを作ってそちらに実装します。</p>
<ul>
<li>App.tsで定義したGraphQLのメソッド定義を書く</li>
</ul>
<p><strong>graphql.ts</strong></p>
<pre><code class="typescript">import { playerService } from "../services/playerService";
export class graphql{
public getPlayer(args:any):Promise<any>{
return new Promise((resolve,reject)=>{
(async()=>{
//既存のDBアクセスメソッドにリクエストパラメータを渡す
let player = await new playerService().findId(args.id)
console.log(player)
resolve(player[0])
})()
.catch((err)=>{
console.log(err)
reject(err)
})
})
}
}
</code></pre>
<h1 id="完成イメージ(動画)"><a href="#%E5%AE%8C%E6%88%90%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%28%E5%8B%95%E7%94%BB%29">完成イメージ(動画)</a></h1>
<div class="iframe-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/m4oMPWFn24A" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>
<h1 id="参考リンク"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF">参考リンク</a></h1>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://dev.classmethod.jp/server-side/node-js-server-side/graphql-tutorial-nodejsexpress/">GraphQLをNode.jsとexpressでためしてみる</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://www.m3tech.blog/entry/graphql-apollo-react-express-nodejs">GraphQL入門 - React.js & Express.js & Apollo の簡単チュートリアル</a></li>
</ul>
ckoshien
tag:crieit.net,2005:PublicArticle/14895
2019-03-31T23:50:35+09:00
2019-04-01T09:55:15+09:00
https://crieit.net/posts/React-GraphQL-API
ReactでGraphQL APIにアクセスする
<p><a href="https://crieit.now.sh/upload_images/0db2bc9716c1a8284f3a9afd4702101b5ca0d1aec3480.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/0db2bc9716c1a8284f3a9afd4702101b5ca0d1aec3480.png?mw=700" alt="image" /></a></p>
<h1 id="背景"><a href="#%E8%83%8C%E6%99%AF">背景</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://developers.annict.jp/graphql-api/">AnnictのGraphQL API</a>からデータを取得したい!<br />
参照:<a href="https://crieit.net/boards/annict-access">アニメ視聴遅れ管理サービスの開発</a></p>
<h1 id="reactではどう実装するか"><a href="#react%E3%81%A7%E3%81%AF%E3%81%A9%E3%81%86%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B%E3%81%8B">reactではどう実装するか</a></h1>
<p>Apollo Clientが便利なようです。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/seya/items/e1d8e77352239c4c4897">Apollo Client + React 入門</a></li>
</ul>
<h2 id="必要なパッケージのインストール"><a href="#%E5%BF%85%E8%A6%81%E3%81%AA%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB">必要なパッケージのインストール</a></h2>
<pre><code class="cmd">npm install apollo-boost react-apollo graphql-tag graphql --save
</code></pre>
<h2 id="ApolloClientの作成"><a href="#ApolloClient%E3%81%AE%E4%BD%9C%E6%88%90">ApolloClientの作成</a></h2>
<ul>
<li>エンドポイントを設定します。</li>
<li>アクセストークンをヘッダに設定します。</li>
</ul>
<pre><code class="javascript">//APIエンドポイント
let url = let url = 'https://api.annict.com/graphql'
const client = new ApolloClient({
uri: url,
request: operation => {
operation.setContext({
headers: {
authorization: 'Bearer ' + code
}
})
}
})
</code></pre>
<h2 id="graphqlを記述する"><a href="#graphql%E3%82%92%E8%A8%98%E8%BF%B0%E3%81%99%E3%82%8B">graphqlを記述する</a></h2>
<p>このクエリは、ログインしているユーザ <strong>(viewer)</strong> の<br />
見た作品 <strong>(state:WATCHED)</strong> を シーズン別降順 <strong>(orderBy: {field: SEASON, direction: DESC)</strong> でフィルタリングして返すという内容です。</p>
<pre><code class="javascript">let query = gql`{
viewer {
works(state: WATCHED, orderBy: {field: SEASON, direction: DESC}) {
nodes {
title
seasonName
seasonYear
}
}
}
}`
</code></pre>
<p>当初はタイトル画像のようなクエリのアプローチをしていたのですが、annictのdiscordコミュニティでannict作者のshimbacoさんに「こう書くといいですよ」と教えていただきました。</p>
<h2 id="最後に"><a href="#%E6%9C%80%E5%BE%8C%E3%81%AB">最後に</a></h2>
<p>とりあえずコンソールにログを出力するところまで。<br />
これをstoreに入れて表示してreact dndでソートするところまでが目標。</p>
<pre><code class="javascript">export const fetchWatchedTitles=(code)=>{
(async()=>{
let url = 'https://api.annict.com/graphql'
let query = gql`{
viewer {
works(state: WATCHED, orderBy: {field: SEASON, direction: DESC}) {
nodes {
title
seasonName
seasonYear
}
}
}
}`
const client = new ApolloClient({
uri: url,
request: operation => {
operation.setContext({
headers: {
authorization: 'Bearer ' + code
}
})
}
})
let response = await client.query({
query
})
if(response !== null){
console.log(response.data.viewer.works.nodes)
}
})().catch(
error=>{
console.log(error);
}
)
}
</code></pre>
<h1 id="参考リンク"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF">参考リンク</a></h1>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/shimbaco/items/e3f2f8650b08e1e060bd">AnnictのGraphQL APIを使ってアニメデータを取得しよう</a></li>
</ul>
ckoshien
tag:crieit.net,2005:PublicArticle/14584
2018-10-25T12:36:02+09:00
2018-10-31T18:03:16+09:00
https://crieit.net/posts/React-Netlify-GitHub-GraphQL-TypeScript
エンジニアライブを支える技術 ~ もしくは、React + Netlify + GitHub + GraphQL + TypeScript でイベント用のウェブサイトを作った話 ~
<p>エンジニアが出演する (音楽の) ライブイベントを企画したので、そのためのウェブサイトを作りました。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://engineer-live-tokyo.netlify.com" target="_blank">EngineerLiveTokyo</a></p>
<p>これで利用したシステム構成が、短時間でそこそこ使いやすいサイトを作るのに使い回しの効きそうなものだと思うので紹介します。</p>
<h2 id="エンジニアライブを支える技術"><a href="#%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2%E3%83%A9%E3%82%A4%E3%83%96%E3%82%92%E6%94%AF%E3%81%88%E3%82%8B%E6%8A%80%E8%A1%93">エンジニアライブを支える技術</a></h2>
<p>タイトルのまんまですが、技術スタック的な視点では以下の組み合わせで構築しています。</p>
<ul>
<li>React</li>
<li>Netlify</li>
<li>GitHub</li>
<li>GraphQL</li>
<li>TypeScript</li>
</ul>
<h2 id="React + Netlify"><a href="#React+%2B+Netlify">React + Netlify</a></h2>
<p>まずサイトを手早く作るために <a target="_blank" rel="nofollow noopener" href="https://github.com/facebook/create-react-app" target="_blank">create-react-app</a> のテンプレートを元に SPA で画面を作っていきました。</p>
<ul>
<li>トップページ</li>
<li>出演者一覧ページ</li>
<li>出演者詳細ページ</li>
<li>ニュース一覧ページ</li>
<li>ニュース詳細ページ</li>
</ul>
<p>というシンプルな画面構成なので React を使えば (デザインは置いといて) 速攻で作れました。最終的にはペライチみたいなサイトでいいかなと思って出演者/ニュース一覧ページは無くしてしまいましたが。</p>
<p>しかしここで特に「支える技術」として伝えたいのは Netlify のお手軽さで、Netlify を使うことで</p>
<ul>
<li>GitHub のブランチに更新を push したら CI でデプロイ</li>
<li>SPA リロード時の404対策</li>
<li>SPA の OGP 対策のための pre-rendering</li>
</ul>
<p>などを他の外部ツールに頼ることなく Netlify のみで一瞬で実現することができました。特に最後のものは、今回のエンジニアライブサイトのように各ページを LP としてシェアしてもらいたいようなサイトを SPA で構築するときに頭が痛い点の1つだと思うので、これがお手軽なのはかなりありがたかったです。</p>
<p>そして小規模なウェブサイトであればフリープランで使えますし、うーん、とにかく良い…</p>
<h2 id="GitHub issues as CMS"><a href="#GitHub+issues+as+CMS">GitHub issues as CMS</a></h2>
<p>サイトを更新していく方法は色々ありますが、参加者の大半がソフトウェアエンジニアということで、GitHub の issues に記事などをポストするとそれがサイトのコンテンツに反映されるようにしました。</p>
<p>エンジニアライブのサイトでは、定期的に更新されるコンテンツとして</p>
<ul>
<li>ニュース (お知らせや出演者の活動レポートなど)</li>
<li>出演者情報</li>
</ul>
<p>の2つのカテゴリのページがあります。</p>
<p>それぞれ、普通に GitHub の issue として内容を書いたらそれに <code>post</code> や <code>lineup</code> のラベルをつけると、対応するカテゴリのページとして実際にサイトに表示される仕組みにしました。フロントエンド側はリアルタイムに情報を GitHub から取得するので、GitHub 側でラベルを付けた issue を作成または更新すると即座にサイトにも反映されます。</p>
<p>例えばニュースカテゴリのページを作りたかったら以下のように <code>post</code> ラベルをつけます。</p>
<p><a href="https://crieit.now.sh/upload_images/b7658e62155e6d4cfa9bb337dd6c25565bd139d165d47.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/b7658e62155e6d4cfa9bb337dd6c25565bd139d165d47.png?mw=700" alt="tech-stack-for-engineer-live-1.png" /></a></p>
<p>GitHub を CMS として使うことで、普段使っているものと全く同じ感覚で markdown でコンテンツを入稿できて、ユーザー管理なども GitHub のものをそのまま使うことができるのが楽なところです。</p>
<p>例えば「出演者情報ページでは写真が欲しい」といった理由から内容に一部レギュレーションを規定する必要はありましたが (必ず画像を入れてねみたいな) 、基本的には issue から取得した HTML をそのまま埋め込むだけなので markdown で表現可能などんな内容でも書くことができます。</p>
<p>ただし、YouTube 動画を埋め込みたい要件があり、それがどうしても実現できなかったためにショートコード機能を追加しています。ショートコードというのは Hugo や WordPress にある機能で、特定の文法規則にしたがって記載しておくとそれが決まった形の HTML に展開されます。</p>
<p>具体的には、issue 本文に</p>
<pre><code>youtube xxxxxxx
</code></pre>
<p>と書いておくと (<code>xxxxxxx</code> は YouTube の動画 ID) 、それがサイト上では iframe の埋め込み動画として表示されるというものです。多少強引ですが、これはフロントエンド側で直接取得した HTML 文字列を変換することで対処しました。</p>
<h2 id="GraphQL"><a href="#GraphQL">GraphQL</a></h2>
<p>GitHub issues からのコンテンツデータの取得には GraphQL を使いました。</p>
<p>REST API でも同じことはできますが、GraphQL を使うことで、シンプルな GET だけならほとんど API リファレンスなどを見なくても「必要な情報を必要なだけ得る」ためのクエリがすぐに書けるのはサッサと作りたい場面ではかなりありがたいです。今回のような「<code>post</code> ラベルがついた issue だけ取得したい」といった仕様を満たすのも簡単です。</p>
<p>また、特筆すべき点として、GitHub は <a target="_blank" rel="nofollow noopener" href="https://developer.github.com/v4/explorer/" target="_blank">GraphQL Explorer</a> という GraphQL フロントエンド (GraphiQL) を用意してくれています。GraphiQL を使ったことがある方はわかると思うのですが、これが IDE 的な補完やドキュメント参照もその場でできる大変素晴らしく便利なやつです。</p>
<p><a href="https://crieit.now.sh/upload_images/e8d092d2c14be7c91ab011e9a22ad5725bd139de6a3d5.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/e8d092d2c14be7c91ab011e9a22ad5725bd139de6a3d5.png?mw=700" alt="tech-stack-for-engineer-live-2.png" /></a></p>
<p>前述した「ほとんど API リファレンスを見なくても…」というのにはこの GraphQL Explorer の存在が大きいです。</p>
<p>GraphQL のクライアントには <a target="_blank" rel="nofollow noopener" href="https://www.apollographql.com" target="_blank">Apollo</a> を使いました。今回、実は最初に JavaScript で開発していたのを後から一気に TypeScript に書き換えたのですが、そのときも react-apollo を使っていると型定義の追加が簡単にできて型付けのための書き変えを難なく完了することができました。</p>
<h2 id="TypeScript"><a href="#TypeScript">TypeScript</a></h2>
<p>前節でも書いた通り、最初は JavaScript で開発をスタートしました。理由は単純で「JS をそのまま使う方が開発速度はきっと速いだろう」というものでした。</p>
<p>が、最終的にはすべてを TypeScript で書き直しました。確かに JS の方がコード量は少なくなるのですが、開発中に結局 props を意識しないといけなくてそれがすぐにわからなくて悩むことや、静的型付けの方が結果的に細かいバグが減って開発効率も良かったためです。</p>
<p>特に VSCode などの IDE 的なサポートが強いエディタで開発する場合には、TS の導入に大きな障害がないのであれば、ごく小規模なサイトを高速開発したいような場面でも最初から TS を使った方が良いかもしれません。</p>
<h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2>
<p>実は、一番最初はこのサイトの作るのに自分の趣味を優先して <a target="_blank" rel="nofollow noopener" href="https://github.com/5t111111/EngineerLiveTokyo" target="_blank">Swift の Web Application Framework である Vapor を使ってフルスタックで作っていた</a>のですが、やっぱり色々時間がかかってしまったので、実益を取って React SPA + バックエンド CMS に切り替えた背景があります。React を用いて静的サイトになったことで Netlify を利用することもできたため、結果的には CI などの開発フローも含めて全体的な開発効率を大きく高めることができて正解でした。</p>
<p>ただ、今回のようなシンプルな構成の場合に柔軟で高速な開発を行うには、どちらかというとバックエンドにこだわるよりはフロントエンドをデータ層と分離できていることが大事で、その上で適切に抽象化されたバックエンドとのインターフェースがあって楽に取り替えがきくことが重要であると感じています。</p>
<p>そういう点で、コンテンツを更新していく仕組みは別になんでもいいと考えていて、GitHub じゃなくても NetlifyCMS や Contentful などのマネージドな Headless CMS を使ってもよいし、別にプレーンテキストのファイルでもクラウドストレージでも、手軽に更新できるものならなんでもよいでしょう。この辺りを突き詰めていくとプラガブルにデータソースを選択できる <a target="_blank" rel="nofollow noopener" href="https://www.gatsbyjs.org" target="_blank">GatsbyJS</a> を使うのが思想的にはマッチする気がしてきますが、記事更新のたびに静的サイトをビルドするのがちょっと面倒で今回は使いませんでした。</p>
<p>まあ、今回高速開発が求められた理由というのが、単純にイベントそのものの準備もあってあんまり周りのシステムに時間をかけたくなかったことがありますので、もし次回があればせっかくの機会なのでもっと趣味を全開にして作ってみたいなと思います。とはいえ、躊躇なく CSS Grid Layout を使ったりできたのでストレス解消にはなりましたが…</p>
<p>Lighthouse のスコアも、Web フォント使ってるところでパフォーマンスだけ満点取れなかったけどまあ十分かな。</p>
<p><a href="https://crieit.now.sh/upload_images/b642158b0e3a31f1d4386c28a99f78685bd13a09a20c2.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/b642158b0e3a31f1d4386c28a99f78685bd13a09a20c2.png?mw=700" alt="tech-stack-for-engineer-live-3.png" /></a></p>
<p>最後に宣伝になりますが、エンジニアライブは <strong>2018/11/25 (日) に「真昼の月・夜の太陽」</strong> で開催します。僕は band.rb というグループで出演します。他にもエンジニアとして活躍中の方が多数出演するので、興味のある方は以下のページより参加をご登録ください!</p>
<p><a target="_blank" rel="nofollow noopener" href="https://engineerlive.connpass.com/event/104979/" target="_blank">エンジニアライブ東京 #1 - connpass</a></p>
WAKASUGI 5T111111