文化祭で始めるトレンド技術スタックWebアプリケーション開発【バックエンド編】
やぁ。
始めましての方もそうでない方も、やぁ。
dino a.k.a. shioです🧂
前回「文化祭で始めるトレンド技術スタックWebアプリケーション開発【紹介編】」という記事を執筆したので、今回はその続きで「バックエンド編」を書いていこうと思います。
OZの概要紹介RTA
概要を見るために別記事に飛んでもらうというのもアレなので、今回私たちが作成したWebアプリケーションもとい出し物「OZ」の概要をサクッと紹介させていただきます。
私たちは10月のおわりに開催された茨香祭にて、「OZ」という名の出し物を運営しました。
その概要は以下の通りです。
- 現地で「ポーカー」や「大富豪」「XENO」といったテーブルゲームを提供する。
- ユーザーはゲームをすると、その結果に応じてサイト内でポイントが付与される。
- ポイントをもとに、サイト内でランキングがリアルタイムで変動する。
- ゲーム後、ユーザーはサイト内のポイントを現地の景品と交換できる。
技術選定とその理由
さて、「OZ」の概要も説明し終えたので、早速技術選定云々についてのお話をしていきます。
今回「OZ」のバックエンドで使用した主な技術は以下の通りです。
- GraphQL
- NestJS
- Prisma
順に見ていきましょう。
GraphQL
GraphQL
はAPI向けに作られたクエリ言語です。
いわゆるREST
と異なる点は、クライアントはAPIを関数的に叩けるという点にあります。
というのも、REST
はあるURLに対してGET
やPOST
といったリクエストを送りますが、対してGraphQL
はある単一のエンドポイント(/graphql
が慣習的)に対してQuery
やMutation
といったResolver
をリクエストします。
Open API
の登場により、REST
もGraphQL
と同じような型安全性を獲得しましたが、依然「関数的に叩ける」というのはGraphQL
独自の特徴です。
「OZ」での実例を見てみましょう。
以下のクエリでは、findUsers
というGraphQL Queryを呼び出すことで複数のUser
を取得しています。
ここで、findUsers
の引数where
を指定することで「ADMIN
のrole
を持つ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
NestJS
はAngular
に強く影響されたTypeScript
のREST
/GraphQL
APIサーバーフレームワークです。
TypeScriptではまだ実験的な機能である「デコレータ」を用いて、いわゆるController
やService
の定義を行います。
また、NestJS
にはDIコンテナが標準搭載されており、これを使うことで依存性の注入が容易に行えます。
依存性の注入(Dependency Injection)
依存性の注入とは、あるオブジェクトや関数が、依存する他のオブジェクトや関数を何らかの形(コンストラクタや引数など)で外部から受け取るデザインパターンです。
これをすることにより、あるクラスAがあるクラスBをAの各メソッド上でインスタンス化した場合よりも、AのBに対する依存性を低くすることができます。
さらには、この依存先を単なるクラスではなくインターフェースにする(具象ではなく抽象に依存する)ことにより、高いテスタビリティを得ることもできます。
どれくらい簡単なのか、「OZ」の実例を見てみましょう。
以下のUserQuery
では、UserReaderUseCaseInterface
というインターフェースをコンストラクタの引数に取ることで、UserReaderUseCaseInterface
に依存しています。
@Resolver()
export class UserQuery {
private readonly logger = new Logger(UserQuery.name);
constructor(
@Inject(InjectionToken.USER_READER_USE_CASE)
private readonly userReaderUseCase: UserReaderUseCaseInterface,
// ...
) {}
// ...
しかしこのままでは、UserQuery
は実行時にどう処理をすればいいのか分かりません。
UserReaderUseCaseInterface
はあくまでインターフェースであり、メソッドの名前と引数と返り値の型を示すだけであって、具体的な処理内容についての情報を持たないからです。
そこで、以下のように実装クラスを外部から注入します。
@Module({
// ...
providers: [
// ...
{ provide: InjectionToken.USER_READER_USE_CASE, useClass: UserReaderUseCase },
// ...
],
// ...
})
export class UserModule {}
たったこれだけで、実行時にはUserReaderUseCaseInterface
とUserReaderUseCase
の紐づけ──つまり抽象と具象の紐づけが行われ、晴れて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」の実例です。
実例
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
dataloader
はGraphQL
APIにおいて避けがたい、あの忌まわしきN+1問題を解決するための仕組みです。
ごく小規模なプロダクトであればN+1問題について気にする必要はあまりないのですが、「OZ」では最終的に200名以上の方に登録をして頂きました。
そんな中ヤツを放置していたら、「OZ」のサーバーがUser
に関するGraphQL Queryを実行する際に発生する無駄なDBクエリは底知れないものになってしまいます。
そのため、私たちは必ずdataloader
を実装しなければなりませんでした。
ちなみにNestJS
におけるdataloader
の実装方法はこちらにまとめてあります。
詰まった際にはぜひご覧ください👍
最終的に、dataloader
を実装できた「OZ」のサーバーは、全てのResolverにおいて発生するDBクエリを最大3回に抑えることに成功しました。
これにより、どれだけ複雑なネストをするクエリに対しても、スピーディーにレスポンスすることができます。
graphql-validation-complexity
上述のdataloader
の実装により、複雑なネストをするクエリに対するレスポンスの速度を保障することはできました。
しかし、「複雑なネストをするクエリ」を送れてしまうことは、それ自体が大きな脆弱性になりかねません。
というのも、GraphQL
においてone-to-many
やmany-to-many
のリレーションがあるとき、クライアントは事実上無限重のネストをしたクエリをサーバーにリクエストすることができてしまいます。
以下は、「OZ」の実際のスキーマを使用したQueryの例です。
query {
findUsers {
giftHistories {
user {
giftHistories {
user {
giftHistories {
user {
giftHistories {
user {
# ...
}
}
}
}
}
}
}
}
}
}
これくらいのネストであればサーバーは一瞬にしてレスポンスオブジェクトを返すことができますが、これが無限に続くとなれば話は別です。
その場合、サーバーは構文を解析するだけで一種の無限ループに陥ってしまいます。
(まぁ実際には無限バイトのリクエストを送れるわけはないんですが...)
そのような事態を防ぐのが、graphql-validation-complexity
です。
このライブラリは、GraphQL
のクエリに対して「複雑度」を算出します。
そして、サーバー側にこの「複雑度」の閾値を設けることができるのです。
このことは、ある一定の複雑度を持つクエリを遮断することができるということを意味します。
以下は、実際の「OZ」のサーバーに対してくっそ複雑なクエリを送った場合のレスポンスです。
曰く、「クエリが複雑度の上限を超えている」というエラーが返ってきているのが分かります。
以上、dataloader
とgraphql-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-case
やrepository
のクラスたちはみなdomain/service
の定義に従って実装し、他のクラスに依存する際にはdomain/service
にある抽象に依存します。
こうすることにより、依存性逆転の原則が確立され、自ずと依存性の低いクラスが完成します。
こうすることに何のメリットがあるのか、不思議に思う方もいるかもしれません。
ということで、オニオンアーキテクチャをするメリット・デメリットを激軽くまとめてみました。
メリット・デメリット
メリット
- 小さな責務でテストができる
- 変更が容易
-
GraphQL
からREST
に変えようとなったときはController
を変えればいい -
Prisma
からTypeORM
に変えようとなったときはRepository
を変えればいい
-
デメリット
- ドメインモデルが頻繁に変更されないことを前提としているため、もしドメインモデルにクソデカ変更が入るようものなら脳は破壊される
- ドメインモデル以外にも引数のための型が必要になったりと、定義すべきオブジェクトが普通に書くよりもかなり多くなる
- 一人で管理する場合は相応の根気がいる
おわりに
今回は茨香祭で作成したWebアプリケーション「OZ」のバックエンドにおいて、技術的に工夫したところをまとめてみました。
この記事を読んでくださった方に「はえー、参考にしてみるかな」とか思っていただけたりしたら、それほど嬉しいことはありません。
次回は「OZ」のフロントエンド担当者がフロントエンドにまつわるお話を書いてくれるかもしれません。
ぜひともお楽しみに...。
またね。
Discussion