🐳

NestJSとGraphQLを用いたクリーンアーキテクチャの実装

2022/08/23に公開約14,900字

TypeScriptでユースケースを達成しNestJSとGraphQLでそれらをコントロールするための実装を考案しました。主にNestJSとGraphQL、Prismaの使い方を中心に解説するため実装する処理は完全ではありません。

キーワード

  • Clean architecture
  • Docker
  • MySQL
  • Node.js
  • Prisma
  • NestJS
  • Fastify
  • GraphQL
  • Mercurius

はじめに

実装するアプリケーションを俯瞰的に解説します。


テーブルの関係

ここでは、上図のような2つのテーブルとそのリレーションをORMを用いてデータベースへ登録する方法とクリーンアーキテクチャでユーザを登録、閲覧するための処理のみを解説します。ORMはPrisma、データベースはMySQLを使用しました。
クリーンアーキテクチャの場合、ユーザに関係する操作は次のように行われます。


アプリケーションのクラス図

UserResolverクラスはクリーンアーキテクチャで語られるControllerとPresenterの役割を担います。そこからUserRepository(抽象クラス)をUserInteractorへ渡し、各ユースケースを達成します。

DockerとNodeモジュールのインストール

Docker上で動作するサービスの概要は下記のとおりです。内容をcompose.ymlファイルとし、適当な空のディレクトリに配置してください。ポートやイメージの変更、Dockerfileを使用する場合は適宜変更してください。

compose.yml
version: "3"
services:
  backend:
    image: node:16
    working_dir: /usr/src/app
    ports:
      - 4000:3000
    volumes:
      - ./backend:/usr/src/app
    command: bash -c 'yarn start:dev'
  db:
    image: mysql:8
    ports:
      - 3306:3306
    environment:
      MYSQL_ROOT_PASSWORD: admin
      MYSQL_DATABASE: test_db
      TZ: Asia/Tokyo

次に必要なモジュールをインストールします。Dockerサービス内で次のコマンドを実行してください。Dockerサービスへのコマンド実行方法は様々なものがありますがここではdocker-compose run —-rm backend bashをあらかじめ実行し、コピー&ペーストしていくと楽かと思います。

backend
yarn global add @nestjs/cli && nest new ./ && yarn add prisma --dev && yarn prisma init && yarn add graphql @nestjs/graphql mercurius @nestjs/mercurius fastify @nestjs/platform-fastify

コマンドを実行すると使用するパッケージマネージャを聞かれるのでyarnを選択します。ここまでで次のモジュールがインストールされます。

  • @nestjs/cli (global)
  • prisma (devDependencies)
  • graphql
  • @nestjs/graphql
  • mercurius
  • @nestjs/mercurius
  • fastify
  • @nestjs/platform-fastify

NestJSとGraphQLを使う場合ExpressやFastify, Apollo, Mercuriusなどが同時に使用できますが、バージョンパフォーマンスの都合でFastifyとMercuriusの構成で進めていきます。

Prisma

Prismaとはオープンソースの次世代ORMのひとつです。ここではDockerコンテナ上のMySQL(dbコンテナ)と接続します。その他のデータベースを使う場合は公式ドキュメントを参照してください。

準備

はじめに、変更が必要なファイルは.envschema.prismaです。自動生成されるので新たに作る必要はありません。

backend/.env
DATABASE_URL="mysql://root:admin@db/test_db"
backend/prisma/schema.prisma
// ...省略

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

次にアプリケーションが使用するテーブルの関係を記述します。schema.prismaに下記の内容を追加してください。

backend/prisma/schema.prisma
// ...省略

model User {
  id       String @id @default(uuid()) @db.VarChar(63)
  username String @db.VarChar(255)
  posts    Post[]
}

model Post {
  id        String   @id @default(uuid()) @db.VarChar(63)
  title     String   @db.VarChar(127)
  body      String   @db.VarChar(255)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author    User     @relation(fields: [authorId], references: [id])
  authorId  String   @db.VarChar(63)
}

schema.prismaを記述するためには次の項目を吟味する必要があります。

  • モデル名(テーブル名)
  • フィールド名(列名)
  • フィールドのデータ型
  • キー項目
  • リレーション

Prismaはリレーションにおいて多対多を暗黙的明示的に決定する方法がありますが、ここでは明示的に宣言しています。

Migration

定義したモデルをデータベースへ反映するために次のコマンドを実行し、初回マイグレーションを作成します。docker-compose up -d dbdbサービスを起動した後に実行してください。

backend
yarn prisma migrate dev --name init

マイグレーションの結果次のようなテーブルが出力されます。

MySQLへ作成されるテーブル一覧
Post
+-----------+--------------+------+-----+----------------------+-------------------+
| Field     | Type         | Null | Key | Default              | Extra             |
+-----------+--------------+------+-----+----------------------+-------------------+
| id        | varchar(63)  | NO   | PRI | NULL                 |                   |
| title     | varchar(127) | NO   |     | NULL                 |                   |
| body      | varchar(255) | NO   |     | NULL                 |                   |
| createdAt | datetime(3)  | NO   |     | CURRENT_TIMESTAMP(3) | DEFAULT_GENERATED |
| updatedAt | datetime(3)  | NO   |     | NULL                 |                   |
| authorId  | varchar(63)  | NO   | MUL | NULL                 |                   |
+-----------+--------------+------+-----+----------------------+-------------------+

User
+----------+--------------+------+-----+---------+-------+
| Field    | Type         | Null | Key | Default | Extra |
+----------+--------------+------+-----+---------+-------+
| id       | varchar(63)  | NO   | PRI | NULL    |       |
| username | varchar(255) | NO   |     | NULL    |       |
+----------+--------------+------+-----+---------+-------+

Seeding

Prismaのシード機能を用いることで開発環境で簡単に基本データの登録や検証をすることができます。はじめにpackage.jsonに下記の内容を追加してください。

backend/package.json
// ...省略
  "prisma": {
    "seed": "ts-node prisma/seeds/seed.ts"
  }
}

次にseed.tsにシードを記述していきます。シードはPrismaClient APIを用いて記述します。下記のコードは一例です。

backend/prisma/seeds/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
async function main() {
  const yamada = await prisma.user.create({
    data: { username: 'Yamada Taro' },
  });
  console.log(yamada);

  const yamadaPost = await prisma.post.create({
    data: {
      title: 'Hello World',
      body: 'This post was written by Yamada',
      authorId: yamada.id,
    },
  });
  console.log(yamadaPost);
}

main();

最後に次のコマンドでシードを実行します。

backend
 yarn prisma db seed

筆者の環境では次のような出力が得られました。

seed出力
{ id: 'fbbdfbbe-14ad-4447-8298-0edd24a585d1', username: 'Yamada Taro' }
{
  id: '8c40b37e-f125-4063-8449-35ef915e5124',
  title: 'Hello World',
  body: 'This post was written by Yamada',
  createdAt: 2022-08-21T17:29:55.577Z,
  updatedAt: 2022-08-21T17:29:55.577Z,
  authorId: 'fbbdfbbe-14ad-4447-8298-0edd24a585d1'
}

GraphQL

ここからはGraphQLを実装していきます。最初にクリーンアーキテクチャにおけるControllerを準備します。これはクエリの解析と適切なレスポンスを返すためのものです。user.module.ts, user.model.ts, user.resolver.tsの3つのファイルを新たに追加します。user.module.tsのみコマンドで生成することができます。

backend
yarn nest g mo controllers/graphql/user

ここではCRUDのうちCreateとReadのみを実装していきます。各ファイルは下記のように編集してください。

backend/src/controllers/graphql/user/user.model.ts
import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class UserModel {
  @Field(() => String)
  id: string;

  @Field(() => String)
  username: string;
}
backend/src/controllers/graphql/user/user.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { UserModel } from './user.model';

@Resolver(() => UserModel)
export class UserResolver {
  @Query(() => [UserModel], { name: 'user', nullable: true })
  async getUsers() {
    return [
      {
        id: 'record 1',
        username: 'Return from NestJS',
      },
    ];
  }
}
backend/src/controllers/graphql/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserResolver } from './user.resolver';

@Module({
  providers: [UserResolver],
})
export class UserModule {}

次に公式ドキュメントに従ってapp.module.tsを編集していきます。前述のとおりここではApollo serverの代わりにMercuriusを使用します。

backend/src/app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './controllers/graphql/user/user.module';

@Module({
  imports: [
    GraphQLModule.forRoot<MercuriusDriverConfig>({
      driver: MercuriusDriver,
      autoSchemaFile: true,
      sortSchema: true,
    }),
    UserModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

autoSchemaFilejoin(process.cwd(), 'src/auto_generate/schema.gql')のようにすると、該当のパスにスキーマファイルが出力されます。trueの場合はメモリ内へ展開されます。
次にmain.tsを編集します。ExpressではなくFastifyを使用するためこちらも公式ドキュメントに従って編集していきます。

backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(3000, '0.0.0.0');
}
bootstrap();

ここまででGraphQLを用いたリクエストを受け、レスポンスを返すことができます。実際にdocker-compose up backendbackendサービスを起動してApollo Studioから確認することもできます。Apollo Studioを使用することでフロントエンドを実装していなくてもGraphQLサーバの挙動を確認することができます(バックエンドにApollo Serverを使う必要もありません)。Apollo Studioから確認するためにはCORSの設定をする必要があります。main.tsに下記の内容を追加してください。

backend/src/main.ts
// ...省略
    new FastifyAdapter(),
    {
      cors: {
        origin: 'https://studio.apollographql.com',
      },
    },
  );
  await app.listen(3000, '0.0.0.0');
// ...省略

GraphQLにはIntrospectionという仕組みがあり、GraphQLサーバで利用可能なクエリや型をクライアントが取得することができます。Apollo Studioもこの仕組を利用し、URLを入力した時点で既に利用可能なフィールドを選択する事ができます。

Clean architecture

ここからはTypeScriptでクリーンアーキテクチャにおけるUse caseより内側の層を実装していきます。NestJSやGraphQLの内容はあまり関係がありませんので、必要なければ読み飛ばしても問題ありません。
backend/src配下は次のようになります。

backend/src
├─domains
│  ├─applications
│  │  └─user.interactor.ts
│  └─models
│      └─user.ts
├─infrastructures
│  └─user.mysql.ts
└─usecases
    └─user
        ├─user.io.ts
        ├─user.repository.ts
        └─user.usecase.ts

新たにdomains, infrastructures, usecasesディレクトリを作成し、上記のような構成にしてください。

Model

クリーンアーキテクチャのEntityにあたるUserクラスは下記のようになります。

backend/src/domains/models/user.ts
export class User {
  id: string;
  username: string;

  constructor(id: string, username: string) {
    this.id = id;
    this.username = username;
  }
}

Use caseとRepository

クリーンアーキテクチャでは依存性の逆転やテスタビリティ向上のために様々な箇所でインターフェースが用いられています。このアプリケーションではuser.usecase.ts, user.repository.tsの2つのファイルが該当します。また、UserCreateUseCaseは入力用のクラスが必要になります。

backend/src/usecases/user/user.io.ts
export class UserCreateInput {
  username: string;
  constructor(username: string) {
    this.username = username;
  }
}
backend/src/usecases/user/user.usecase.ts
import { User } from '@prisma/client';
import { UserCreateInput } from './user.io';

export interface UserCreateUseCase {
  execute(input: UserCreateInput): Promise<User>;
}
backend/src/usecases/user/user.repository.ts
import { User } from 'src/domains/models/user';

export interface UserRepository {
  create(user: User): Promise<void>;
  readById(id: string): Promise<User>;
  readAll(): Promise<Array<User>>;
}

user.usecase.tsではユーザに何ができるかを表現します。例えば、新しいユーザの登録や削除などです。user.repository.tsはデータアクセス用のインターフェースです。インターフェースとすることで、MySQLやFirebase, インメモリなどのデータアクセスに影響を受けずにInteractorを開発することができます。ここでは具象クラスをuser.mysql.tsへ記述します。

backend/src/infrastructure/user.mysql.ts
import { PrismaClient } from '@prisma/client';
import { User } from 'src/domains/models/user';
import { UserRepository } from 'src/usecases/user/user.repository';

export class UserMySQLRepository implements UserRepository {
  private prisma: PrismaClient = new PrismaClient();

  async create(user: User): Promise<void> {
    await this.prisma.user.create({
      data: {
        id: user.id,
        username: user.username,
      },
    });
  }

  async readById(id: string): Promise<User> {
    return await this.prisma.user.findFirst({
      where: { id: id },
    });
  }

  async readAll(): Promise<Array<User>> {
    return this.prisma.user.findMany();
  }
}

次はUse caseを実装したUserCreateInteractorです。Controller(user.resolver.ts)から呼び出されるクラスになります。

backend/src/domains/applications/user.interactor.ts
import { randomUUID } from 'crypto';
import { UserCreateInput } from 'src/usecases/user/user.io';
import { UserRepository } from 'src/usecases/user/user.repository';
import { UserCreateUseCase } from 'src/usecases/user/user.usecase';
import { User } from '../models/user';

export class CreateUserInteractor implements UserCreateUseCase {
  private repository: UserRepository;

  constructor(repository: UserRepository) {
    this.repository = repository;
  }

  async execute(input: UserCreateInput): Promise<User> {
    const user = new User(randomUUID(), input.username);
    this.repository.create(user);
    return user;
  }
}

ここまでで冒頭のクラス図のように処理が実行されます。

Controllerの編集

最後にRepositoryとInteractorを利用するuser.resolver.tsを下記のように編集します。

backend/src/controllers/graphql/user/user.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateUserInteractor } from 'src/domains/applications/user.interactor';
import { UserMySQLRepository } from 'src/infrastructures/user.mysql';
import { UserCreateInput } from 'src/usecases/user/user.io';
import { UserRepository } from 'src/usecases/user/user.repository';
import { UserModel } from './user.model';

@Resolver(() => UserModel)
export class UserResolver {
  private repository: UserRepository = new UserMySQLRepository();

  @Query(() => UserModel, { name: 'user' })
  async getUserById(@Args('user_id') id: string) {
    return this.repository.readById(id);
  }

  @Query(() => [UserModel], { name: 'users' })
  async getAllUsers() {
    return this.repository.readAll();
  }

  @Mutation(() => UserModel)
  async createUser(@Args('username') username: string) {
    const interactor = new CreateUserInteractor(this.repository);
    const input = new UserCreateInput(username);
    const user = await interactor.execute(input);
    return user;
  }
}

これでGraphQLのクエリを使用して新たなユーザを追加したり全てのユーザや単一のユーザを取得したりすることができます。

参考


Discussion

ログインするとコメントできます