frourioを使って1ヶ月で管理画面をリリースした話

f:id:y-kawamura-lvgs:20210830110809p:plain

はじめに

こんにちは、レバテック開発部の河村です。 私はレバテック各種メディアのリプレイスを担当しており、バックエンドを中心にフルスタック開発を行っています。

今回は管理画面のリリースで採用した、フルスタックフレームワークであるfrourioについて、frourioを採用した理由や使ってみて良かったこと、困ったことを紹介します。 この記事を通して、frourioのメリット、デメリットだけでなく、レバテック開発部ではどのような背景のもと、技術・アーキテクチャの選定を行っているのか、どれくらいのスピード感で開発を行っているのかをお伝えできればと思います。

なお、この記事ではfrourioにおける環境構築や使い方等の説明は割愛させていただきます。


開発背景・経緯

今回、開発する対象となった管理画面は、レバテックの各メディアで運用する記事やセミナー情報、エントリー情報を管理するものになります。

すでに管理画面として、PHP(CakePHP)をベースとした技術で作られているものを利用している状態でしたが、 PHP自体のバージョンも古く、利用しているライブラリなどを含めレガシーな状態になっており、開発速度、品質、運用、保守性が低下している状態でした。 また、レバテック組織全体でTypeScriptの標準化を進めており、管理画面のリニューアルが必須の状態でした。

管理画面のため、特別凝ったデザイン等は必要ありませんが、利用するロールやメンバーが多く、複雑な機能が必要となることもあり、管理画面のフレームワーク(ReactAdminやAdminBroなど)を利用してしまうと、機能の実装が困難になる可能性もあり、基本的には拡張性が高い状態、フルスクラッチで開発できる環境を構築する必要がありました。

さらに、事業側の方々の要望もあり、約1か月程度での本番リリース、稼働開始が必要だったため、 上記の背景、経緯を踏まえた上で開発速度が出せるようなフレームワークを選定する必要がありました。

このような背景、経緯があり、frourioを採用することにしました。 frourioを採用した理由については後ほど詳細を説明させていただきます。


frourioとは

f:id:y-kawamura-lvgs:20210830123005p:plain

frourioは一言で言うと、TypeScript製のフルスタックWebフレームワークで、詳細については、公式ドキュメントまたはGithubを参照してください。


frourioの特徴

frourioの1番の特徴は、フロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発できるところです。 以下の図が、frourioが内包しているフレームワークやORMになります。

f:id:y-kawamura-lvgs:20210830124223p:plain

上記の図の通り、frourioはTypeScriptをベースに利用できるフレームワークを内包しています。 フロントエンドだとNext.jsやNuxt.jsを選択できたり、ORMだとTypeORMやPrismaを利用することができます。 また、Node.js系のフレームワークでハイパフォーマンスと言われているfastifyも利用することができ、安定したパフォーマンスを出すことも期待できます。

その他にも以下のような特徴がありますが、本記事では割愛させていただきますので、気になる方はぜひ調べてみてください。

  • コマンド1発でフロントエンドからバックエンドの環境構築
  • Vercel社が開発しているReact Hooksライブラリ「SWR」に対応
  • 関数ベースでControllerを実装可能
  • 関数ベースでDI(Dependency Injection)の実現

frourioを採用した理由

frourioを採用した理由として、大きく以下の3つになります。

  • TypeScriptを利用して型安全に開発できる
  • チームとして利用したいフレームワークが内包されている
  • 自動生成により開発速度を大きく向上させることができる

上記の理由について、以下、詳細に説明させていただきます。

TypeScriptを利用して型安全に開発できる

frourioの特徴でもお伝えしましたが、frourioはフロントエンドからバックエンドまですべてTypeScriptでかつ型安全に開発することができます。型安全に開発することで、不正な動作を事前に防げたり、フロントエンドとバックエンドをつなぐ部分のInterfaceを明示的に宣言でき、フロントエンドからバックエンドのAPIを呼び出す際も安全にかつスムーズに呼び出すことができます。

例えば、以下のように、バックエンドのAPIのInterfaceを定義し、

export type Task = {
  id: number
  label: string
  done: boolean
}

export type Methods = {
  post: {
    reqBody: Pick<Task, 'label'>
    resBody: Task
  }
}

フロントエンドからInterfaceを参照しながら、安全にかつスムーズにAPIを呼び出すことができます。

await apiClient.tasks.post({ body: { label } })

ここで、バックエンドのAPIのInterfaceを定義しましたが、 frourioのルールにしたがって定義することで、ApiClient(APIのクライアント)部分を自動生成してくれます。 こちらの自動生成については、自動生成により開発速度を大きく向上させることができるの箇所で説明したいと思います。

チームとして利用したいフレームワークが内包されている

チームとして、以下のフレームワークを利用して開発工数をできる限り抑えることを目指していました。

  • フロントエンド
    • Nuxt.js
    • Next.js
  • バックエンド
    • TypeORM

まず、フロントエンドですが、frourioではNuxt.js、Next.jsともに利用することができます。 チーム内ではVue.js、Nuxt.jsの方が経験のあるメンバーが多いため、どちらを選定するかで迷いましたが、TypeScriptとの相性、チームメンバーのスキル向上、モチベーションアップを考慮した上で、Next.jsを選定しました。型安全に開発できることをメインに置いていることもあり、TypeScriptとの相性の良いReact.jsをベースとしたNext.jsを選定することが当然であるかもしれませんが、それ以上に、チーム内でReact.js、Next.jsの技術を身につけていきたいというモチベーションが選定において大きな判断材料になったかと思います。

ちらっと、TypeScriptとの相性について述べましたが、こちらの記事が参考になりますので、気になる方は参考にしてみてください。

また、バックエンド、主にORMになりますが、TypeORMを採用しました。 現在、レバテック全体で各種機能のマイクロサービス化が進んでおり、TypeORMを利用しているチームやメンバーが多いことから選定しました。個人的にはPrismaを使ったみたいという気持ちもありましたが、開発工数をできる限り抑えるということで、すでに知見が溜まっているTypeORMを選定しました。 これにより、バックエンドの開発は、今までとほぼ変わらず、frourioのお作法を覚えるだけで開発できる環境を整えることができました。

余談:CSSフレームワーク

フルスクラッチで開発する以上、UIを作成するために、管理画面のフレームワーク(ReactAdminやAdminBro等)と比べて、UIの実装にある程度の工数がかかってしまいます。なので、そこで、CSSフレームワークでReactと相性の良いMaterial-UIを採用しています。 さらに、管理画面においては、同じような画面、Componentの使い回しが多くなることから、Atomic Designの概念を採用して、再利用される可能性のあるComponentはできる限り共通化(atoms, moleculed, organismsを適用)し、UIの実装にかかる工数をできる限り削減しました。

自動生成により開発速度を大きく向上させることができる

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれて、開発工数を大きく削減でき、開発速度を向上させることができます。個人的に、特にフロントエンドとバックエンドをつなぐ部分、ApiClient(APIのクライアント)部分の自動生成がとても便利だったので、紹介させていただきます。

  • ディレクトリを作成するだけで、Controllerを自動作成してくれる
  • APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

ディレクトリを作成するだけで、Controllerを自動作成してくれる

frourioの機能を利用することで、バックエンドのController部分に該当する./server/apiディレクトリに対象のエンドポイント用のディレクトリを作成するだけで、必要なファイルを生成してくれます。

上記の機能を説明する前に、まず、frourioを使って環境構築した後のディレクトリ構成を紹介します。

frourioのディレクトリ構成

frourioのコマンドを使って環境構築を行った際の設定が以下になります。 Next.jsのcreate-next-appのようなコマンドがfrourioにもあるので環境構築は簡単にできます。

f:id:y-kawamura-lvgs:20210830105352p:plain
frourio環境構築時の設定1
f:id:y-kawamura-lvgs:20210830105531p:plain
frourio環境構築時の設定2

上記を行った結果、以下のようなディレクトリ構成が出来上がります。

.
├── README.md
├── aspida.config.js
├── components # React用のComponent
├── ecosystem.config.js
├── jest.config.ts
├── next-env.d.ts
├── package.json
├── pages # Next.jsのpagesに該当
├── public # フロントエンド用の静的ファイル
├── server # バックエンド
│   ├── $orm.ts
│   ├── $server.ts
│   ├── api # バックエンドのController部分に該当
│   ├── entity # TypeORMのEntity
│   ├── entrypoints # バックエンドのサーバーを立ち上げるための設定等
│   ├── index.js
│   ├── migration # TypeORMによって生成されたMigrationファイル
│   ├── ormconfig.ts
│   ├── package.json
│   ├── service # レイヤードアーキテクチャ的に言うと、アプリケーションサービスに該当
│   ├── static # バックエンド用の静的ファイル
│   ├── subscriber # TypeORMのSubscriber
│   ├── test # バックエンド用のテスト
│   ├── tsconfig.json
│   ├── types # バックエンド用の型定義ファイル
│   ├── validators # Service等で利用したいValidation用のDTOに該当
│   ├── webpack.config.js
│   └── yarn.lock
├── styles # CSS
├── test # フロントエンド用のテスト
├── tsconfig.json
├── utils # フロントエンド用のUtil
│   └── apiClient.ts
└── yarn.lock

正直、利用しないディレクトリ等も生成されてしまいますが、不要なものは後から取り除く形で問題ないかと思います。 (実際に、./styles./server/service./server/subscriberは不要だったので取り除いています)

また、1つのディレクトリ内でフロントエンド、バックエンドを管理しているために、一見モノリシックに見えますが、Interface(型定義)で繋がっていること以外は、フロントエンド、バックエンド、それぞれに別のpackage.jsonがあり、別のプロジェクトとして扱うことができます。ライブラリ等が依存し合ったりすることもなく、仮にバックエンドを別のものに差し替えたとしても、フロントエンドのAPIの呼び出し部分を変更するだけで可能になります。

./server/apiディレクトリにディレクトリを作成してみると

上記のディレクトリ構成の通り、./server/apiディレクトリがバックエンドのController部分に該当します。 補足ですが、frourioのコマンドで環境構築をすると、デフォルトでサンプルのコードが作成されます。 また、$がつくファイルはfrourio側で生成されるファイルになります。

f:id:y-kawamura-lvgs:20210830105537p:plain
frourioの自動生成機能1

こちらのディレクトリに、hogehogeディレクトリを作成して、バックエンド側をビルドすると以下のようになります。 ホットリロードでも生成されるので、実際にはサーバーを起動したまま作成していくことになるかと思います。

f:id:y-kawamura-lvgs:20210830105545p:plain
frourioの自動生成機能2

上記のようにファイルが生成されており、実際のファイルの中身は以下のようになります。 メソッド自体はサンプル的なものになっていますが、これだけでも自動生成してくれるだけで恩恵を得られるかと思います。

index.ts

export type Methods = {
  get: {
    resBody: string
  }
}

controller.ts

import { defineController } from './$relay'

export default defineController(() => ({
  get: () => ({ status: 200, body: 'Hello' })
}))

$relay.ts

/* eslint-disable */
// prettier-ignore
import { Injectable, depend } from 'velona'
// prettier-ignore
import type { FastifyInstance, onRequestHookHandler, preParsingHookHandler, preValidationHookHandler, preHandlerHookHandler } from 'fastify'
// prettier-ignore
import type { Schema } from 'fast-json-stringify'
// prettier-ignore
import type { HttpStatusOk } from 'aspida'
// prettier-ignore
import type { ServerMethods } from '../../$server'
// prettier-ignore
import type { Methods } from './'

// prettier-ignore
type Hooks = {
  onRequest?: onRequestHookHandler | onRequestHookHandler[]
  preParsing?: preParsingHookHandler | preParsingHookHandler[]
  preValidation?: preValidationHookHandler | preValidationHookHandler[]
  preHandler?: preHandlerHookHandler | preHandlerHookHandler[]
}
// prettier-ignore
type ControllerMethods = ServerMethods<Methods>

// prettier-ignore
export function defineResponseSchema<T extends { [U in keyof ControllerMethods]?: { [V in HttpStatusOk]?: Schema }}>(methods: () => T) {
  return methods
}

// prettier-ignore
export function defineHooks<T extends Hooks>(hooks: (fastify: FastifyInstance) => T): (fastify: FastifyInstance) => T
// prettier-ignore
export function defineHooks<T extends Record<string, any>, U extends Hooks>(deps: T, cb: (d: T, fastify: FastifyInstance) => U): Injectable<T, [FastifyInstance], U>
// prettier-ignore
export function defineHooks<T extends Record<string, any>>(hooks: (fastify: FastifyInstance) => Hooks | T, cb?: (deps: T, fastify: FastifyInstance) => Hooks) {
  return cb && typeof hooks !== 'function' ? depend(hooks, cb) : hooks
}

// prettier-ignore
export function defineController(methods: (fastify: FastifyInstance) => ControllerMethods): (fastify: FastifyInstance) => ControllerMethods
// prettier-ignore
export function defineController<T extends Record<string, any>>(deps: T, cb: (d: T, fastify: FastifyInstance) => ControllerMethods): Injectable<T, [FastifyInstance], ControllerMethods>
// prettier-ignore
export function defineController<T extends Record<string, any>>(methods: (fastify: FastifyInstance) => ControllerMethods | T, cb?: (deps: T, fastify: FastifyInstance) => ControllerMethods) {
  return cb && typeof methods !== 'function' ? depend(methods, cb) : methods
}

追加したControllerの場合でも、開発側は特別な設定を追加することなく、frourio側が自動に設定してくれます。 実際には、./server/$server.tsに自動で設定されますが、ファイルがけっこうな行数になってしまうので、ここでは割愛させていただきます。

APIのInterfaceを定義するだけで、ApiClientを自動生成してくれる

frourioでは、標準でTypeScript製のREST APIクライアントであるaspidaを利用して、バックエンドのAPIにアクセスすることができます。そのため、フロントエンド側に./utils/apiClient.tsが生成され、aspidaの設定が追加されています。

├── utils # フロントエンド用のUtil
│   └── apiClient.ts

apiClient.ts

import aspida from '@aspida/axios'
import api from '~/server/api/$api'

export const apiClient = api(aspida())

上記の通り、~/server/api/$api.tsを読み込んでいるのですが、 こちらがバックエンド側で定義したAPIのInterfaceを元に、自動生成されたClientの接続周りをラップしたものになります。 例えば、上記の例(hogehoge)の場合だと、~/server/api/$api.tsは以下のように生成されます。

/* eslint-disable */
// prettier-ignore
import { AspidaClient, BasicHeaders, dataToURLString } from 'aspida'
// prettier-ignore
import { Methods as Methods0 } from '.'
// prettier-ignore
import { Methods as Methods1 } from './hogehoge'

// prettier-ignore
const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? 'http://localhost:39809/api' : baseURL).replace(/\/$/, '')
  const PATH0 = '/hogehoge'
  const GET = 'GET'
  const POST = 'POST'
  const DELETE = 'DELETE'
  const PATCH = 'PATCH'

  return {
    hogehoge: {
      get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text(),
      $get: (option?: { config?: T }) =>
        fetch<Methods1['get']['resBody']>(prefix, PATH0, GET, option).text().then(r => r.body),
      $path: () => `${prefix}${PATH0}`
    }
}

// prettier-ignore
export type ApiInstance = ReturnType<typeof api>
// prettier-ignore
export default api

これらのfrourioの機能を利用することで、フロントエンドは特段、APIを呼び出すためにAPI用のClientを実装する必要がなくなります。バックエンドのAPIを実装した時点で、フロントエンドはAPIを呼び出して開発を進めることができます。


frourioを使って困ったこと

現段階でfrourioを使って困ったことをいくつか紹介させていただきます。

  • ディレクトリ構成が変更しづらい
  • 不要な設定が思ったより追加されてしまう

ディレクトリ構成が変更しづらい

frourioのお作法にしたがって開発することで、様々な部分を自動生成をしてくれる分、ある程度、frourioが用意したディレクトリ構成に従わないと動作しない機能がいくつかあります。ただし、ORMに関する設定はよしなに変更できたりします(例えば、TypeORMであれば、ormconfig.tsの設定を変更したりなど)

バックエンドを簡単なレイヤードアーキテクチャの構成(UI層→Application層→Domain層→Presentation層)にしようと思っても、上記で紹介した./server/apiディレクトリを変更するとControllerを追加できなかったり、./server/validatorsディレクトリをApplication層のDTOとして利用しようと思っても、./server/validatorsディレクトリ内に作成しないとclass-validatorが機能しなかったりします。

これらの原因は明確で、frourioが生成される$が付いたファイルをよしなに書き換えたりすることができないためです。 例えば、validatorsであればserver/$server.tsに自動で設定が作成され、以下のように設定されています。

$server.ts(一部抜粋)

...
// prettier-ignore
import * as Validators from './validators'
...

// prettier-ignore
const createValidateHandler = (validators: (req: FastifyRequest) => (Promise<void> | null)[]): preValidationHookHandler =>
  (req, reply) => Promise.all(validators(req)).catch(err => reply.code(400).send(err))

// prettier-ignore
const formatMultipartData = (arrayTypeKeys: [string, boolean][]): preValidationHookHandler => (req, _, done) => {
  const body: any = req.body

  for (const [key] of arrayTypeKeys) {
    if (body[key] === undefined) body[key] = []
    else if (!Array.isArray(body[key])) {
      body[key] = [body[key]]
    }
  }

  Object.entries(body).forEach(([key, val]) => {
    if (Array.isArray(val)) {
      body[key] = (val as Multipart[]).map(v => v.file ? v : (v as any).value)
    } else {
      body[key] = (val as Multipart).file ? val : (val as any).value
    }
  })

  for (const [key, isOptional] of arrayTypeKeys) {
    if (!body[key].length && isOptional) delete body[key]
  }

  done()
}

上記の通り、frourioの機能を存分に活用するには、frourioのルールにある程度従う必要があります。 ただ、frourioの機能、特に自動生成周りの機能は、開発スピード、コストにおいてとても効果を発揮しますが、バックエンドをきれいなアーキテクチャで作成したいようなニーズとはマッチしないかと思います。特に、今回はある程度簡単な機能が多いということもありfrourioを採用しているので、無理してアーキテクチャを整える必要はなく、特に大きな問題とはなっていません。

必要のない設定が思ったより追加されてしまう

frourioの公式に従って環境構築を進めると、たしかにコマンド1発でフロントエンドからバックエンドの環境構築ができますが、その分、様々な設定が追加されている状態になります。不必要な設定があると開発者内で認識齟齬につながる可能性があるので、開発において不必要な設定は取り除いておいたほうがいいかと思います。

例えば、バックエンドのサーバーを立ち上げるための設定をしているserver/entrypoints/index.tsでは、デフォルトの状態だと以下のようになっています。

index.ts

import 'reflect-metadata'
import path from 'path'
import Fastify from 'fastify'
import helmet from 'fastify-helmet'
import cors from 'fastify-cors'
import fastifyStatic from 'fastify-static'
import fastifyJwt from 'fastify-jwt'
import { createConnection } from 'typeorm'
import server from '$/$server'
import ormOptions from '$/$orm'
import {
  API_JWT_SECRET,
  API_SERVER_PORT,
  API_BASE_PATH,
  API_UPLOAD_DIR,
  TYPEORM_HOST,
  TYPEORM_USERNAME,
  TYPEORM_PASSWORD,
  TYPEORM_DATABASE,
  TYPEORM_PORT
} from '$/service/envValues'

const fastify = Fastify()

fastify.register(helmet)
fastify.register(cors)
fastify.register(fastifyStatic, {
  root: path.join(__dirname, 'static'),
  prefix: '/static'
})
if (API_UPLOAD_DIR) {
  fastify.register(fastifyStatic, {
    root: path.resolve(__dirname, API_UPLOAD_DIR),
    prefix: '/upload',
    decorateReply: false
  })
}
fastify.register(fastifyJwt, { secret: API_JWT_SECRET })
server(fastify, { basePath: API_BASE_PATH })

createConnection({
  type: 'mysql',
  host: TYPEORM_HOST,
  username: TYPEORM_USERNAME,
  password: TYPEORM_PASSWORD,
  database: TYPEORM_DATABASE,
  port: Number(TYPEORM_PORT),
  migrationsRun: true,
  synchronize: false,
  logging: false,
  ...ormOptions
}).then(() => fastify.listen(API_SERVER_PORT))

また、デフォルトの状態の環境変数は以下のようになります。

.env

API_SERVER_PORT=39809
API_BASE_PATH=/api
API_ORIGIN=http://localhost:39809
API_JWT_SECRET=supersecret
API_USER_ID=id
API_USER_PASS=pass
API_UPLOAD_DIR=upload
TYPEORM_HOST=localhost
TYPEORM_USERNAME=root
TYPEORM_PASSWORD=
TYPEORM_DATABASE=hogehoge
TYPEORM_PORT=3306

おそらく、初期段階ではファイルのアップロードの設定や認証・認可の設定は不要になるかと思います。逆に、必要になるCORSやTypeORMの設定がされているので、この部分は工数削減になります。ただ、このように様々な箇所にデフォルトで設定がされているので、不要な設定がないかは確認しておいたほうが良いかと思います(他にも、TypeORMの場合、デフォルトでSubscriberの設定が追加されています)


最後に

管理画面の開発にfrourioを採用し、必要最低限のみの機能にはなりますが、想像以上のスピードでリリースすることができました。 ですが、まだまだfrourioの機能を活かせていない部分があり、特にaspidaSWR(stale-while-revalidate)、validationの機能を拡充したり、機能、非機能要件として以下の機能を実装していく必要があります。

  • NextAuthを利用した認証・認可
  • レバテック内部の各マイクロサービスとの連携
  • メール配信、予約配信
  • 記事投稿のためのリッチテキストエディター
  • ...etc

今回の記事で説明しきれていないfrourioの特徴やfrourio以外で困ったこと(Next.js、TypeORM固有の課題など)、frourioを利用したシステムアーキテクチャ(AWS)、また、今後上記の機能を実装していくなかで得られた知見などをまた別の機会に共有できたらなと思います。

参考記事