社内向けのWeb APIをTypeScriptとExpressで作り直している話

こんにちは。エージェンシー事業部でアプリケーションエンジニアをしている梅津です。

私が所属しているチームでは、日々機能追加や改善を行っているメインプロダクトの他に、社内の別サービスに向けてデータを提供するWeb API(以下、社内向けAPI)が存在しています。
この社内向けAPIはKotlin + Spring Bootで動いているのですが、それをTypeScript + Expressで作り直すというプロジェクトが進行しています。
今回はそのプロジェクトについてお話したいと思います。

社内向けAPIの概要

社内向けAPIの概要を簡単に説明します。

プログラミング言語 Kotlin
フレームワーク Spring Boot
エンドポイント数 4つ
リクエスト数 約9~10万/日
扱うデータ 何かしらのファイル情報
作成時期 2018年頃

作り直す背景

現在の社内向けAPIには、次のような問題があります。

  1. 全くメンテナンスされていない
    • 機能追加がないだけでなく、フレームワークやライブラリのアップデートといった改善作業も行われていない
    • Dockerなどを使用しておらずローカル環境の構築に時間がかかるため、改善に取り掛かるハードルが高い
  2. チーム内のスキルと利用技術が合わない
    • メインプロダクトではバックエンドにRuby on Rails, フロントエンドにReact, TypeScriptを利用している
    • そのため日々の業務でKotlinやSpring Bootにふれる機会がなく、いざというときにコードの修正をするのが困難
  3. 脆弱性への対応ができない
    • 問題1, 2から社内向けAPIの更新ができないため、脆弱性が見つかったとしても対応ができない

このような問題に対して、今よりはチーム内のスキルとフィットしていてメンテナンスしやすい状況に変えるべく動き出したのでした。

何故KotlinやSpring Bootを使っていたのか

そもそも何故そんなつらい状況になるような技術選定をしたのか?という疑問も出てくるかと思います。

実はメインプロダクトは過去にシステムのフルリニューアルに向けて動いている時期がありました。
リニューアル時に利用しようと考えていたのがKotlinとSpring Bootだったのです。
これは当時のチームメンバーとしても触れたことのない技術でした。

時期を同じくして、社内向けAPIの需要が高まっていました。
社内向けAPIを作ることが決まった時、新しい技術に慣れるための実験場としてKotlinとSpring Bootで実装することになりました。

しばらくして社内向けAPIの実装を終えた頃、なんとメインプロダクトのリニューアルが中止となってしまいました!

結果的にメインプロダクトはRuby on Railsのまま、実験的な場となった社内向けAPIはKotlin + Spring Bootという状況が生まれてしまったのです。

ちなみに、リニューアルの技術選定などは当時のチームメンバーが記事にまとめてくれています。興味のある方は読んでみてください。

技術選定

前述の問題点を解決するため、どういった技術にするか決めていきました。

最終的に決まった内容は次のとおりです。

選定項目 決定した内容
プログラミング言語 TypeScript
実行環境 Node.js
フレームワーク Express
ORM Prisma
アプリケーションのデプロイ先 AWS Lambda
(Lambda Web Adapterを利用)
環境構築 Docker

それぞれの項目について細かく説明します。

プログラミング言語

プログラミング言語についてですが、まずは利用できそうなものをチーム内でブレストしながら挙げていきました。
そこで挙がったのはTypeScript, Ruby, Go, Kotlin, Rust, C#などなど。

続いて、この候補の中から条件に合うものを絞り込んでいきます。
今回のプロジェクトでプログラミング言語に求める条件は次のとおりです。

  • できれば静的型付け言語であること
    • 実行時エラーを減らして、安心して開発したい
    • IDEの恩恵を受けやすくしたい
  • チーム内のスキルとのフィット感が高いこと
    • 今現在のスキルセットと比べた時にフィットした状態にしたい
    • 将来的に入ってくれる人のコンテキストスイッチをなるべく少なくしたい
  • 言語自体やパッケージ/モジュールなどの後方互換性がある程度あること
    • アップデートにかかるメンテナンスコストを小さくしたい

これらの条件と照らし合わせた結果、すべての条件を満たしてくれるTypeScriptに決定しました。

実行環境

プログラミング言語をTypeScriptとしたため、実行環境についても考える必要があります。
ここでは広く利用されており、デプロイ先での利用時に追加改修などが必要なさそうなNode.jsとしました。

とはいえ、これまでNode.jsの運用経験がチーム内でもないため、相変わらず実験的な取り組みとなっています。

フレームワーク

技術選定をしていた当時、Node.jsのフレームワークはExpressとNestJSの2強だったため、これらから選ぶことにしました。

Expressは利用者の多さや、そのシンプルさによる自由度の高さが魅力です。
一方でその自由度の高さは、自分たちで構成を考え、その構成を守る強い意志がないといずれ破綻するということの裏返しです。

NestJSはTypeScriptで書かれていることや、NestJSの定めた構成に乗っかることで考えることが少なくなるのが魅力です。
個人的なフレームワークを選ぶときの基準として、「考えることが少ない」というのはとても重要なことだと感じています。

こういった比較をしたものの、後述するデプロイ先の制約によりExpressに決定しました。

ORM

ORMに関してはPrismaやTypeORMが候補に挙がりましたが、これまでの経験の無さからなにを選んでも一定の苦労はしそうだな、というのが正直なところでした。
そんな中でも、型の効き具合やマイグレーションのしやすさ、開発体験の良さが噂されているPrismaを選ぶことにしました。

アプリケーションのデプロイ先

まず、クラウドサービスについてはAWSを選びました。
これは現在の社内向けAPIがAWSで動いているため、新しく作り直したシステムに切り替えるときの作業も少なくなるだろうと考えたためです。

次に利用するAWSのサービスを決めます。
メンテナンスのしやすやなどを考え、AWS LambdaとApp Runnerが候補に挙がりました。
その中でも金銭的な安さ、アクセス制限のしやすさからAWS Lambdaを選びました。

加えてLambda Web Adapterを利用することで、フレームワークを使ったコードをそのままAWS Lambdaで動かすことができます。
導入はDockerfileにコードを1行追加するだけというお手軽さ。

ただし、利用できるフレームワークには制限があります。
技術選定時に対応していたNode.jsのフレームワークはExpressとNext.jsのみだったため、フレームワークはExpressを利用することになりました。

※ 詳しくは調べていませんが、NestJSはExpress上で動かせるため、やり方によってはLambda Web AdapterとNestJSを組み合わせることができるかもしれません。

環境構築

環境構築にはDockerを利用することにしました。

Lambda Web Adapterを利用するためにDockerが必要だったということもありますが、結果的にDockerによる環境構築は次のようなメリットもあったので良かったです。

  • Dockerを利用することで、ローカルの環境構築が簡単になる
  • ローカル環境と本番環境の差異を小さくできる

設計思想とディレクトリ構成

現在の社内向けAPIはCQRSを意識して、参照系と更新系の処理を分けています。
実装はプレゼンテーション層、ドメイン層、インフラ層という3層に別れており、それぞれのディレクトリ内に参照系と更新系のファイルが混在しています。

├── domains
│   ├── AwesomeFile.kt <- 更新系
│   ├── AwesomeFileDto.kt <- 参照系
│   ├── AwesomeFileQueryService.kt <- 参照系
│   └── AwesomeFileRepository.kt <- 更新系
├── infrastractures
│   └── ...略
├── presentations
│   └── ...略

現在の実装をふりかえってみると、実際にはドメインロジックのようなものはほとんどなく、それぞれの機能のアプリケーションロジックとデータモデルがあれば十分といった状況です。
また、参照系と更新系のファイルが混在していることで、パッと見たときにどれが参照系でどれが更新系なのかがわかりにくいです。

ただ、参照系と更新系の処理を分けて責務を明確にすることで保守性を上げる、というやり方はとてもいいなと感じました。

これらを踏まえ、作り直すときは次のような形で進めることにしました。

  • CQRSを意識して、参照系と更新系の処理を分けることは引き続き行っていく
  • ディレクトリは queriescommands に分け、その中に機能用のディレクトリを作成する
    • こうすることで、どのような機能があるのかがわかりやすくなる
    • また必要なファイルがまとまっているので修正範囲もわかりやすくなる
  • 機能用のディレクトリ内には、最低でも controller.ts, logic.ts, index.ts ファイルを作成する
    • controller.ts
      • ルーティング、リクエストハンドラを記述する
      • ルーティングとリクエストハンドラを別のファイルに分けることもできるが、お互いが強く関連しているため、ファイル移動の少なさ・テストのしやすさなどを考えてまとめておく
    • logic.ts
      • その機能のアプリケーションロジックを記述する
      • ロジックに対して個別にテストを書けるようにコントローラからは切り出しておく
    • index.ts
      • 機能を利用する側(主に app.ts)に公開するものを記述する
      • 今のところルーターを公開するだけ
    • その他
      • コントローラーやロジック以外に、関連するものが必要になったら機能用のディレクトリ内に配置する
        • 例: データモデル、DTO、バリデーション
  • 共通で利用するようなものが出てきた時は適宜配置していく

最終的な src ディレクトリ以下の構成は次のようになりました。

src
├── app.ts
├── index.ts
├── queries
│     └── get-awesome-file
│         ├── controller.test.ts
│         ├── controller.ts
│         ├── dto.ts
│         ├── index.ts
│         ├── logic.test.ts
│         └── logic.ts
├── commands
│     └── create-awesome-file
│           ├── controller.test.ts
│           ├── controller.ts
│           ├── index.ts
│           ├── logic.test.ts
│           └── logic.ts
├── errors
│     ├── application-error.ts
│     └── http-error.ts
├── lib
│     └── ...略
├── middlewares
│     └── ...略
├── test
│     └── ...略
└── utils.ts

queriescommands 以外にも追加されたディレクトリがあるので、それらについても軽く説明します。

  • errors
    • カスタムエラーのファイルを置くところ
  • lib
    • 外部ライブラリのラッパーを置くところ
  • middlewares
    • Expressのミドルウェアを置くところ
  • test
    • 自動テストのセットアップファイルやテストのユーティリティなどを置くところ

実装例

クエリとコマンドが具体的にどのような実装になっているかも見ていきましょう。

先程のディレクトリ構成の例に、queries/get-awesome-filecommands/create-awesome-file ディレクトリがありました。
これらの中にある controller.tslogic.ts の実装を紹介します。

それぞれの機能を簡単に説明すると次のようになります。

  • queries/get-awesome-file: ファイル情報を取得するクエリ
  • commands/create-awesome-file: ファイル情報を作成するコマンド

queries/get-awesome-file クエリの実装例

このクエリは、データベースに保存されたファイル情報を参照し、API利用者にとって使いやすい形に変換して返します。

見ていくファイルは次のとおりです。(長くなるため、テストファイルや index.ts は省略)

queries
  └── get-awesome-file
      ├── controller.ts
      ├── dto.ts
      └── logic.ts

コントローラー

まずはコントローラーです。

コントローラーでは次のようなことを行います。

  1. ルーターの定義
  2. リクエストパラメータの型定義
  3. レスポンスボディの型定義
  4. リクエストハンドラの定義
    1. リクエストパラメータのパース
    2. アプリケーションロジックの呼び出し
    3. 想定しているエラーのハンドリング
    4. レスポンスの返却
// queries/get-awesome-file/controller.ts

import { Router } from "express"
import { AwesomeFileNotFoundError } from "../../errors/application-error"
import { notFoundError } from "../../errors/http-error"
import { prisma } from "../../lib/prisma"
import { errorHandler } from "../../middlewares"
import { wrap } from "../../utils"
import { AwesomeFileDto } from "./dto"
import { findAwesomeFileByFilename } from "./logic"

export const router = Router()
router.get(
  "/path/to/awesome-file/:filename",
  wrap(getAwesomeFileHandler),
)
router.use(errorHandler)

interface RequestParams {
  filename: string
}

interface ResponseBody extends AwesomeFileDto {
}

async function getAwesomeFileHandler(
  req: Request<RequestParams, ResponseBody>,
  res: Response<ResponseBody>,
) {
  const {filename} = req.params

  const result = await findAwesomeFileByFilename(
    prisma,
    filename,
  )

  if (result instanceof AwesomeFileNotFoundError) {
    throw notFoundError(`${filename}が見つかりませんでした`, req.path)
  }

  res.status(200).json(result)
}

アプリケーションロジックとDTO

続いてアプリケーションロジックとDTOを見ていきます。

アプリケーションロジックでは次のようなことを行います。

  1. ファイル名を受け取り、そのファイル名に一致するファイル情報が存在するかをデータベースから探す
  2. ファイル情報が存在しない場合はエラーを返す
  3. ファイル情報が存在する場合はDTOに変換して返す
// queries/get-awesome-file/logic.ts

import { PrismaClient } from "@prisma/client"
import { AwesomeFileNotFoundError } from "../../errors/application-error"
import { AwesomeFileDto } from "./dto"

export async function findAwesomeFileByFilename(
  prismaClient: PrismaClient,
  filename: string,
): Promise<AwesomeFileDto | AwesomeFileNotFoundError> {
  const awesomeFile = await prismaClient.awesomeFile.findFirst({
    where: {filename},
  })

  if (!awesomeFile) {
    return new AwesomeFileNotFoundError(
      `${filename}が見つかりませんでした`,
    )
  }

  return {
    name: awesomeFile.name,
    size: awesomeFile.size,
    // createDownloadUrl関数はどこかに定義されているものとする
    downloadUrl: createDownloadUrl(awesomeFile.name, awesomeFile.size),
  }
}

DTOも見ていきます。

// queries/get-awesome-file/dto.ts

export interface AwesomeFileDto {
  name: string
  size: number
  downloadUrl: string
}

AwesomeFileDtodownloadUrl プロパティはデータベースの情報を使って生成しています。
このようにデータベースの情報をそのまま返すのではなく、API利用者にとって使いやすい形に変換する際には別途DTOを定義します。

commands/create-awesome-file コマンドの実装例

このコマンドは、受け取ったファイル情報をデータベースに保存し、保存時に生成されたデータベースのIDを返します。
クエリの例ではDTOが出てきましたが、コマンドの例ではPrismaが生成したデータモデルなどをそのまま利用するだけで十分なので、DTOは必要ありません。

見ていくファイルは次のとおりです。(こちらもテストファイルや index.ts は省略)

commands
  └── create-awesome-file
        ├── controller.ts
        └── logic.ts

コントローラー

それではコントローラーから見ていきましょう。

やることはクエリの例とほとんど変わりません。
変わったところでいうと、リクエストボディのパースや型生成にZodを使っていることくらいでしょうか。

import { Router, urlencoded } from "express"
import * as JSONBig from "json-bigint-native"
import { z } from "zod"
import { badRequestError } from "../../errors/http-error"
import { prisma } from "../../lib/prisma"
import { errorHandler } from "../../middlewares"
import { wrap } from "../../utils"
import { createAwesomeFile } from "./logic"
import type { ParamsDictionary } from "express-serve-static-core"

export const router = Router()
router.use(urlencoded({extended: true}))
router.post(
  "/path/to/awesome-file",
  wrap(createAwesomeFileHandler),
)
router.use(errorHandler)

const requestBodySchema = z.object({
  filename: z.string(),
  file_size: z.coerce.number(),
})
type RequestBody = z.infer<typeof requestBodySchema>

interface ResponseBody {
  id: bigint
}

async function createAwesomeFileHandler(
  req: Request<ParamsDictionary, string, RequestBody>,
  res: Response<string>,
) {
  const result = requestBodySchema.safeParse(req.body)
  if (!result.success) {
    throw badRequestError(result.error.message, req.path)
  }

  const params = result.data
  const awesomeFile = await createAwesomeFile(prisma, {
    name: params.filename,
    size: params.file_size,
  })

  const responseBody: ResponseBody = {
    id: awesomeFile.id,
  }

  // BigIntを含んだオブジェクトをそのまま返すことができないので文字列で返す
  res.status(200).type("json").send(JSONBig.stringify(responseBody))
}

リクエストハンドラ内でレスポンスボディの型が string になっていたり、JSONBig という名前で外部ライブラリをimportしている部分があります。
これはBigIntを含んだオブジェクトをそのままJSONでは返せないという問題に対応するためのものです。
この問題については後述します。

アプリケーションロジック

最後にアプリケーションロジックを見ていきます。

ここでは受け取った情報を単純にデータベースに保存するだけです。
データベースへの保存に必要な data の型もPrismaが生成したものを利用します。

import { AwesomeFile } from ".prisma/client"
import { Prisma, PrismaClient } from "@prisma/client"

export async function createAwesomeFile(
  prismaClient: PrismaClient,
  data: Prisma.AwesomeFileCreateInput,
): Promise<AwesomeFile> {
  return prismaClient.awesomeFile.create({
    data,
  })
}

BigIntを含んだオブジェクトをJSONで返す

ExpressではレスポンスをJSONで返す時に res.json([body])res.type("json").send([body]) を利用します。
これらは内部で JSON.stringify を利用しているため、BigIntを含んだオブジェクトを渡すと例外が発生します。

  • TypeError ("BigInt value can't be serialized in JSON") 例外は、 BigInt 値を文字列化しようとしたときに発生します。

<出典: JSON.stringify() - 例外>

この問題に対応するには次のような方法があります。

  1. MDNで書かれているように replacer を使って文字列などに変換する
  2. 外部ライブラリを利用する

今回は2の方法を選択し、外部ライブラリとして json-bigint-native を利用することにしました。

Expressで利用するときは、json-bigint-native を使ってBigIntを含んだオブジェクトを文字列にしたものを res.send([body]) に渡します。
const responseBody: ResponseBody という変数を定義して、文字列にする前になるべく型安全なオブジェクトを作るようにしました。

const responseBody: ResponseBody = {
  id: awesomefile.id, // BigIntな値
}

res.type("json")
  .send(JSONBig.stringify(responseBody))

APIのレスポンスは次のようなJSONで返ってきます。

{
  "id": 9223372036854775807
}

さいごに

いかがだったでしょうか。
今回は社内向けAPIを作り直すときに行った技術選定や設計思想などについてお話しました。

システムは一度作ってしまうとメンテナンスが必要なもの。
作らなくてもいいのなら作らないのが一番ですが、
作ってしまったからには、そのときに取れる一番ベターな方法で改善していきたいですね!

それでは、またどこかで。

参考リンク