👀

文化祭で始めるトレンド技術スタックWebアプリケーション開発【バックエンド編】

2022/10/29に公開

やぁ。

始めましての方もそうでない方も、やぁ。
dino a.k.a. shioです🧂

前回「文化祭で始めるトレンド技術スタックWebアプリケーション開発【紹介編】」という記事を執筆したので、今回はその続きで「バックエンド編」を書いていこうと思います。

https://3i.shikosai.net

https://github.com/3I-shikosai32/shikosai32-front

https://github.com/3I-shikosai32/shikosai32-server

OZの概要紹介RTA

概要を見るために別記事に飛んでもらうというのもアレなので、今回私たちが作成したWebアプリケーションもとい出し物「OZ」の概要をサクッと紹介させていただきます。

私たちは10月のおわりに開催された茨香祭にて、「OZ」という名の出し物を運営しました。
その概要は以下の通りです。

  • 現地で「ポーカー」や「大富豪」「XENO」といったテーブルゲームを提供する。
  • ユーザーはゲームをすると、その結果に応じてサイト内でポイントが付与される。
  • ポイントをもとに、サイト内でランキングがリアルタイムで変動する。
  • ゲーム後、ユーザーはサイト内のポイントを現地の景品と交換できる。

技術選定とその理由

さて、「OZ」の概要も説明し終えたので、早速技術選定云々についてのお話をしていきます。
今回「OZ」のバックエンドで使用した主な技術は以下の通りです。

  • GraphQL
  • NestJS
  • Prisma

順に見ていきましょう。

GraphQL

GraphQLはAPI向けに作られたクエリ言語です。
いわゆるRESTと異なる点は、クライアントはAPIを関数的に叩けるという点にあります。
というのも、RESTはあるURLに対してGETPOSTといったリクエストを送りますが、対してGraphQLはある単一のエンドポイント(/graphqlが慣習的)に対してQueryMutationといったResolverをリクエストします。

Open APIの登場により、RESTGraphQLと同じような型安全性を獲得しましたが、依然「関数的に叩ける」というのはGraphQL独自の特徴です。
「OZ」での実例を見てみましょう。

以下のクエリでは、findUsersというGraphQL Queryを呼び出すことで複数のUserを取得しています。
ここで、findUsersの引数whereを指定することで「ADMINroleを持つUser」のみをリクエストしています。

query {
  findUsers(where: { role: { equals: ADMIN } }) {
    id
    role
    characterStatus {
      character
      characterPointDay1
      characterPointDay2
    }
    totalPointDay1
    totalPointDay2
  }
}

重要なことはもう一つあります。
それは、「ここで列挙しているfindUsersの返り値だけがfindUsersが返すプロパティではないということです。
この例ではidからtotalPointDay2までのプロパティをリクエストしていますが、実際にはfindUsersはもっと多くのプロパティを返すことができます。
つまるところ、GraphQLではクライアントが本当に欲しい情報を好き勝手に取捨選択できるということです。

GraphQLを選んだワケ

RESTではなくGraphQLを選択したのは、その開発効率にあります。
というのも、RESTで完全な型安全性を獲得するには、先に述べたOpen APIのようにいわゆるドキュメントを整備しなくてはなりません。
しかし今回開発を行うメンバーは自分も含め3人であり、また開発期間も2ヶ月とかなり余裕のない状態でした。
そんな中、Stoplight Studioとかで一々Open APIを書いている暇はありません。
対して、GraphQLと後述するNestJSのコードファースト開発を組み合わせれば、書いたコードがそのままスキーマとなるため、開発工数がググんと減ります。
スキーマがAPIサーバーのコードに依存するため、フロントエンドとの並行開発が難しくなるという問題はありますが、それはフロント側がスキーマへの依存がなるべく少なくなるような設計でコードを書いてくれればなんとかなります(なりました

NestJS

NestJSAngularに強く影響されたTypeScriptREST/GraphQL APIサーバーフレームワークです。
TypeScriptではまだ実験的な機能である「デコレータ」を用いて、いわゆるControllerServiceの定義を行います。

また、NestJSにはDIコンテナが標準搭載されており、これを使うことで依存性の注入が容易に行えます。

依存性の注入(Dependency Injection)

依存性の注入とは、あるオブジェクトや関数が、依存する他のオブジェクトや関数を何らかの形(コンストラクタや引数など)で外部から受け取るデザインパターンです。
これをすることにより、あるクラスAがあるクラスBをAの各メソッド上でインスタンス化した場合よりも、AのBに対する依存性を低くすることができます。

さらには、この依存先を単なるクラスではなくインターフェースにする(具象ではなく抽象に依存する)ことにより、高いテスタビリティを得ることもできます。

どれくらい簡単なのか、「OZ」の実例を見てみましょう。

以下のUserQueryでは、UserReaderUseCaseInterfaceというインターフェースをコンストラクタの引数に取ることで、UserReaderUseCaseInterfaceに依存しています。

user-query.resolver.ts
@Resolver()
export class UserQuery {
  private readonly logger = new Logger(UserQuery.name);

  constructor(
    @Inject(InjectionToken.USER_READER_USE_CASE)
    private readonly userReaderUseCase: UserReaderUseCaseInterface,
    // ...
  ) {}

// ...

しかしこのままでは、UserQueryは実行時にどう処理をすればいいのか分かりません。
UserReaderUseCaseInterfaceはあくまでインターフェースであり、メソッドの名前と引数と返り値の型を示すだけであって、具体的な処理内容についての情報を持たないからです。

そこで、以下のように実装クラスを外部から注入します。

user.module.ts
@Module({
  // ...
  providers: [
    // ...
    { provide: InjectionToken.USER_READER_USE_CASE, useClass: UserReaderUseCase },
    // ...
  ],
  // ...
})
export class UserModule {}

たったこれだけで、実行時にはUserReaderUseCaseInterfaceUserReaderUseCaseの紐づけ──つまり抽象と具象の紐づけが行われ、晴れてUserQueryは依存先の処理内容を理解することができます。

しかし中にはこう思う方もいるでしょう。

「え、普通にnew UserQuery(new UserReaderUseCase())ってした方が簡単じゃん」

今回の例では実装を端折っているのでそう思われるかもしれませんが、実際の実装では

new UserQuery(
  new UserReaderUseCase(new UserRepository(new PrismaService())),
  new UserDataLoader(new UserRepository(new PrismaService())),
  new DataLoaderCacheService<UserModel, string>(),
  new DateService(),
)

となり、実に辛いです。
また、もしクラス同士が相互に依存していたらどうすればいいでしょうか?
注入するクラスを動的に変更したい場合はどうすればいいでしょうか?
そういった複雑な要件も、NestJSのDIコンテナは解決してくれます。
今回「OZ」でNestJSを採用したのも、この魅力あるDIコンテナあってこそです。

NestJSを選んだワケ

第一に、先に述べた通りDIコンテナが標準で提供されているためです。
これにより、後述するオニオンアーキテクチャや似たり寄ったりのクリーンアーキテクチャを、非常に構築しやすくなります。
これは競合であるApollo Serverにはない特徴です。

第二に、NestJS一つでRESTなAPIもGraphQLなAPIも構築できるためです。
「OZ」開発メンバーの技術スタックは、途中でGraphQLを断念してRESTに移行する可能性が少なからずありました。
そんな場合でも、外部との通信を担うPresentation層とビジネスロジックの表現を担うUseCase層の分離が正しくされていれば、GraphQLからRESTへの移行が一瞬で完了できるのは非常に嬉しい話です。

Prisma

Prismaは自分が知る中では最も型安全なTypeScriptのORMです。
schema.prismaという専用のファイルでデータ構造を表現します。
以下は、「OZ」の実例です。

実例
schema.prisma
generator client {
  provider = "prisma-client-js"
}

generator nestgraphql {
  provider                              = "prisma-nestjs-graphql"
  output                                = "./generated"
  outputFilePattern                     = "{model}/{name}/{type}.ts"
  noAtomicOperations                    = true
  purgeOutput                           = true
  noTypeId                              = true
  requireSingleFieldsInWhereUniqueInput = ture
}

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

enum Role {
  USER
  ADMIN
}

enum Character {
  TREE
  FOX
  GOKU
  CAT
  PUDDING
  REAPER
}

enum Game {
  NONE
  XENO
  COIN_DROPPING
  ICE_RAZE
  PRESIDENT
  POKER
  WE_DIDNT_PLAYTEST
}

model User {
  id     String @id @default(auto()) @map("_id") @db.ObjectId
  name   String @unique
  email  String
  role   Role   @default(USER)
  authId String @unique @map("auth_id")
  totalPointDay1  Int @default(0) @map("total_point_day1")
  totalPointDay2  Int @default(0) @map("total_point_day2")
  consumablePoint Int @default(0) @map("consumption_point")
  participateGame Game @default(NONE) @map("participate_game")
  pullableGachaTimes Int @default(0) @map("pullable_gacha_times")
  characterStatuses CharacterStatus[]
  giftHistories GiftHistory[]
  createdAt DateTime @default(now()) @map("created_at")
  @@map("users")
}

type ItemCompletedHistory {
  isDelivered Boolean @default(false) @map("is_delivered")
  createdAt   DateTime  @default(now()) @map("created_at")
  deliveredAt DateTime? @map("delivered_at")
}

model CharacterStatus {
  id        String    @id @default(auto()) @map("_id") @db.ObjectId
  character Character
  iconUrl   String    @map("icon_url")
  avatarUrl String    @map("avatar_url")
  isActive Boolean @map("is_active")
  characterPointDay1 Int @default(0) @map("character_point_day1")
  characterPointDay2 Int @default(0) @map("character_point_day2")
  user   User   @relation(fields: [userId], references: [id])
  userId String @map("user_id") @db.ObjectId
  items                Item[]                @relation(fields: [itemIds], references: [id])
  itemIds              String[]              @map("item_ids") @db.ObjectId
  itemCompletedHistory ItemCompletedHistory? @map("item_completed_history")
  @@map("character_statuses")
}

model Item {
  id       String @id @default(auto()) @map("_id") @db.ObjectId
  iconUrl  String @map("icon_url")
  layerUrl String @map("layer_url")
  character Character
  layer     Int
  characterStatuses  CharacterStatus[] @relation(fields: [characterStatusIds], references: [id])
  characterStatusIds String[]          @map("characterStatus_ids") @db.ObjectId
  @@map("items")
}

model Gift {
  id      String @id @default(auto()) @map("_id") @db.ObjectId
  name    String
  iconUrl String @map("icon_url")
  price     Int
  remaining Int
  giftHistories GiftHistory[]
  @@map("gifts")
}

model GiftHistory {
  id          String  @id @default(auto()) @map("_id") @db.ObjectId
  isDelivered Boolean @default(false) @map("is_delivered")
  user   User   @relation(fields: [userId], references: [id])
  userId String @map("user_id") @db.ObjectId
  exchangedGift Gift   @relation(fields: [giftId], references: [id])
  giftId        String @map("gift_id") @db.ObjectId
  createdAt   DateTime  @default(now()) @map("created_at")
  deliveredAt DateTime? @map("delivered_at")
  @@map("gift_histories")
}

テーブル間のリレーションだけでなく、カラムごとの制約も実に直感的に記述できるのが分かると思います。

また、対応しているDBが豊富なのも魅力のひとつです。
今回は予算と容量の関係でMongoDBを選択しました。

Prismaを選んだワケ

これに関しては正直な話、自分の経験がPrismaしかなかったのでPrisma一択でした...。
TypeORMのようにテーブルをクラス(Entity)にマッピングするORMもいいなぁと思ったりはしますが、型安全性や使いやすさの観点からいつも他のORMに手を出せずにいます...。
ただまぁ今回(も)Prismaを選んだことに間違いはなかったと思っています。
というのも、今回のチームでのバックエンド開発者は私一人であり、またレビュアーも2人いるかいないかだったため、いかに効率よくバグのないコードを書けるかがカギでした。
その点、Prismaは強力な型付けによって構文的なバグをいち早く私に知らせ、またschema.prismaは破壊的な変更を要する要件の変更にも柔軟かつ迅速に対応させてくれました。
この開発スピードは、従来のORMでは成し得なかったと思います。
Prismaアリガトウ...!

脆弱性を減らす努力

「OZ」のサーバーでは、少ない開発期間と人員の中でもできる限りの脆弱性対策をしました。
以下に示すのは、その中で使用した技術たちです。

  • dataloader
  • graphql-validation-complexity

dataloader

dataloaderGraphQL APIにおいて避けがたい、あの忌まわしきN+1問題を解決するための仕組みです。
ごく小規模なプロダクトであればN+1問題について気にする必要はあまりないのですが、「OZ」では最終的に200名以上の方に登録をして頂きました。
そんな中ヤツを放置していたら、「OZ」のサーバーがUserに関するGraphQL Queryを実行する際に発生する無駄なDBクエリは底知れないものになってしまいます。
そのため、私たちは必ずdataloaderを実装しなければなりませんでした。

ちなみにNestJSにおけるdataloaderの実装方法はこちらにまとめてあります。
詰まった際にはぜひご覧ください👍

https://zenn.dev/dino3616/articles/c726ec63b11775

最終的に、dataloaderを実装できた「OZ」のサーバーは、全てのResolverにおいて発生するDBクエリを最大3回に抑えることに成功しました。
これにより、どれだけ複雑なネストをするクエリに対しても、スピーディーにレスポンスすることができます。

graphql-validation-complexity

上述のdataloaderの実装により、複雑なネストをするクエリに対するレスポンスの速度を保障することはできました。
しかし、「複雑なネストをするクエリ」を送れてしまうことは、それ自体が大きな脆弱性になりかねません。
というのも、GraphQLにおいてone-to-manymany-to-manyのリレーションがあるとき、クライアントは事実上無限重のネストをしたクエリをサーバーにリクエストすることができてしまいます。
以下は、「OZ」の実際のスキーマを使用したQueryの例です。

query {
  findUsers {
    giftHistories {
      user {
        giftHistories {
          user {
            giftHistories {
              user {
                giftHistories {
                  user {
                    # ...
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

これくらいのネストであればサーバーは一瞬にしてレスポンスオブジェクトを返すことができますが、これが無限に続くとなれば話は別です。
その場合、サーバーは構文を解析するだけで一種の無限ループに陥ってしまいます。
(まぁ実際には無限バイトのリクエストを送れるわけはないんですが...)

そのような事態を防ぐのが、graphql-validation-complexityです。
このライブラリは、GraphQLのクエリに対して「複雑度」を算出します。
そして、サーバー側にこの「複雑度」の閾値を設けることができるのです。
このことは、ある一定の複雑度を持つクエリを遮断することができるということを意味します。

以下は、実際の「OZ」のサーバーに対してくっそ複雑なクエリを送った場合のレスポンスです。

曰く、「クエリが複雑度の上限を超えている」というエラーが返ってきているのが分かります。

以上、dataloadergraphql-validation-complexityの組み合わせにより、「OZ」のサーバーにおいて複雑なネストをするクエリは脅威ではなくなりました。
いえいv

変更容易性の追求

私(たち)「OZ」バックエンド開発チームが妥協を許さなかった部分は、もう一つあります。
それは、変更容易性の追求です。

オニオンアーキテクチャ(的なもの)の導入

執り行った変更容易性の追求の根底として、オニオンアーキテクチャ的なアーキテクチャの導入があります。
以下は、「OZ」バックエンドの実際のディレクトリ構成です。
そこかしこに端折っている部分があるのはご了承くだしあ。

📦src
 ┣ 📂app
 ┃ ┗ 📜app.module.ts
 ┣ 📂cache
 ┣ 📂common
 ┣ 📂config
 ┣ 📂guard
 ┣ 📂health-check
 ┣ 📂infra
 ┃ ┣ 📂date
 ┃ ┣ 📂firebase
 ┃ ┣ 📂prisma
 ┃ ┃ ┣ 📂generated
 ┃ ┃ ┣ 📂seed
 ┃ ┗ 📂pubsub
 ┣ 📂module
 ┃ ┣ 📂character-status
 ┃ ┣ 📂gift
 ┃ ┣ 📂gift-history
 ┃ ┣ 📂item
 ┃ ┣ 📂user
 ┃ ┃ ┣ 📂controller
 ┃ ┃ ┃ ┣ 📂dto
 ┃ ┃ ┃ ┃ ┣ 📂args
 ┃ ┃ ┃ ┃ ┣ 📂enum
 ┃ ┃ ┃ ┃ ┣ 📂input
 ┃ ┃ ┃ ┃ ┗ 📂object
 ┃ ┃ ┃ ┣ 📜user-mutation.resolver.ts
 ┃ ┃ ┃ ┣ 📜user-query.resolver.ts
 ┃ ┃ ┃ ┣ 📜user-subscription.resolver.ts
 ┃ ┃ ┃ ┗ 📜user.resolver.ts
 ┃ ┃ ┣ 📂dataloader
 ┃ ┃ ┣ 📂domain
 ┃ ┃ ┃ ┣ 📂model
 ┃ ┃ ┃ ┗ 📂service
 ┃ ┃ ┃ ┃ ┣ 📂repository
 ┃ ┃ ┃ ┃ ┗ 📂use-case
 ┃ ┃ ┣ 📂repository
 ┃ ┃ ┣ 📂use-case
 ┃ ┃ ┗ 📜user.module.ts
 ┃ ┗ 📜index.ts
 ┗ 📜main.ts

ざっと見ると、まずNestJSの慣習に従ってディレクトリが大まかなモジュールに分割されているのが分かるかと思います。
その中に構築されているのがオニオンアーキテクチャです。
ちょうど以下の図のような感じですね。
(書いててなんか思ってたんと違うと思いかけましたがこちらの記事を参考にしたのでこれはきっと多分オニオンアーキテクチャに違いありません)

ここで変更容易性のカギとなるのはdomain/serviceです。
ここにはそのモジュール内で実装するクラスたちのインターフェースが置かれています。
つまり、use-caserepositoryのクラスたちはみなdomain/serviceの定義に従って実装し、他のクラスに依存する際にはdomain/serviceにある抽象に依存します。
こうすることにより、依存性逆転の原則が確立され、自ずと依存性の低いクラスが完成します。

こうすることに何のメリットがあるのか、不思議に思う方もいるかもしれません。
ということで、オニオンアーキテクチャをするメリット・デメリットを激軽くまとめてみました。

メリット・デメリット

メリット

  • 小さな責務でテストができる
  • 変更が容易
    • GraphQLからRESTに変えようとなったときはControllerを変えればいい
    • PrismaからTypeORMに変えようとなったときはRepositoryを変えればいい

デメリット

  • ドメインモデルが頻繁に変更されないことを前提としているため、もしドメインモデルにクソデカ変更が入るようものなら脳は破壊される
  • ドメインモデル以外にも引数のための型が必要になったりと、定義すべきオブジェクトが普通に書くよりもかなり多くなる
  • 一人で管理する場合は相応の根気がいる

おわりに

今回は茨香祭で作成したWebアプリケーション「OZ」のバックエンドにおいて、技術的に工夫したところをまとめてみました。
この記事を読んでくださった方に「はえー、参考にしてみるかな」とか思っていただけたりしたら、それほど嬉しいことはありません。

次回は「OZ」のフロントエンド担当者がフロントエンドにまつわるお話を書いてくれるかもしれません。
ぜひともお楽しみに...。

またね。

Discussion