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

はじめに

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

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

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


開発背景・経緯

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

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

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

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

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


frourioとは

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


frourioの特徴

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

上記の図の通り、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にもあるので環境構築は簡単にできます。

frourio環境構築時の設定1
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側で生成されるファイルになります。

frourioの自動生成機能1

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

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)、また、今後上記の機能を実装していくなかで得られた知見などをまた別の機会に共有できたらなと思います。

参考記事

実践! Typescript で DDD - マイクロサービス設計のすすめ

対象読者

  • マイクロサービス化を検討しており、実際に作る場合の構成を参考にしたい。
  • ドメイン駆動設計について、基本的な用語の知識がある。
  • TypeScript を多少触ったことがある。理解がある。

はじめに

こんにちは。エンジニアの吉村です。 現在、弊社が運営する teratail というサービスに携わっており、CakePHP で動作しているモノリシックな既存サービスをマイクロサービスに移行するというプロジェクトを進行中です。

この記事では、実務を通して得た知見として、マイクロサービス化によりどんな恩恵があるのか、具体的にどのような構成で実装をしているのかについてご紹介します。

TL;DR

マイクロサービスのバックエンドサービスの実装に焦点を絞って、ドメイン駆動設計 + オニオンアーキテクチャをベースに設計をしました。 本記事では、具体的に「ユーザ新規登録処理」の実装をする場合を例にとり、実装手順をなぞりながら詳細を解説しています。 結論としては、従来の MVC ベースのアーキテクチャと比較して、メンテナンス性が高く技術的負債が生じづらい、スケーリングが容易な構成にできたのではないかと思います。その反面、簡単な処理の実装であっても記述量が多くなってしまうため、初期コストは割と高めな構成であるように思いました。

なぜマイクロサービス化を導入したのか

モノリシックサービスとマイクロサービスとで、どちらの構成が優れているかについては、実装するサービスの規模や様々な背景などによって左右される部分ではありますが、 今回のケースでは

  • 比較的規模の大きいシステムであること
  • サービスの改善を更に速度を上げて実施すべく、スケールしやすい構成にしておく必要があること
  • 弊社の他マイクロサービスと相互連携をしやすい構成にする必要があったこと

などの理由から、マイクロサービスとしてリプレースをしていく方針となりました。

マイクロサービスでシステムを実装をしていくにあたって、初期設計の段階で失敗してしまうとメンテナンスが困難なシステムができあがってしまう恐れがあるため、アーキテクチャの検討は注意深く行っていく必要がありました。 様々な文献で構成を調べる上で、特に気をつけたポイントは、なるべく技術的負債を回避でき、処理の把握が容易でメンテナンス性が高く、ミドルウェア等の環境の変更に強いシステムとなることでした。

それら検討の上で現在どのような構成で実装をしているか、簡単な処理での例を基にご紹介します。

サービスの構成

サービス全体の構成としては、フロントエンドサーバ、BFFサーバ、バックエンドサーバ と分けて実装をしていますが、 今回はバックエンドサーバのサービス構成に焦点を絞ってご紹介していきます。

構成図

サービスの構成は、オニオンアーキテクチャ[1]を参考に、以下の図の構成としています。

サービスの根幹となるドメイン層を中心に置いて、アプリケーション層、アダプタ層、インフラストラクチャ層と順に囲っていき、 外側の層から内側の層への単方向の依存のみ許可しています。※図の右側の各モジュールの詳細については後述します。

マイクロサービス化をするにあたり、ドメイン駆動設計を参考に、一つのアプリケーションを、例えばユーザ のアカウントについて管理する user サービスや、投稿された内容を管理する post サービスなど、それぞれ独立して成立するサービスとして分割していきます。このときに分割されたサービスの粒度を1単位としてサブドメインと呼ぶことにし、サブドメインごとに上図の構成のサービスを実装していきます。

ディレクトリ構成

ディレクトリの基本的な構成は以下の通りです。 以下の例ではサブドメインが user のみの構成となっていますが、サブドメインの数だけここに同様の構成のものが並びます。 src/shared には共通のコアロジックや、ベースクラスなどを配置しています。

src
├── server.ts
├── subdomains
│   └── user
│       ├── Entities
│       │   └── User.ts
│       ├── Events
│       │   └── UserCreated.ts
│       ├── Infrastructures
│       │   └── typeorm
│       │       └── User.ts
│       ├── Mappers
│       │   └── UserMap.ts
│       ├── Repositories
│       │   ├── UserRepository.ts
│       │   └── implements
│       │       └── TypeOrmUserRepository.ts
│       ├── Subscriptions
│       │   └── AfterUserCreated.ts
│       ├── UseCases
│       │   └── CreateUser
│       │       ├── CreateUserUseCase.ts
│       │       ├── CreateUserDTO.ts
│       │       ├── CreateUserErrors.ts
│       │       └── CreateUserController.ts
│       └── ValueObjects
│           └── UserName.ts
└── shared
    ├── core
    │   ├── AppError.ts
    │   ├── Guard.ts
    │   ├── Result.ts
    │   ├── UseCase.ts
    │   └── UseCaseError.ts
    └── domain
        ├── events
        │   ├── DomainEvents.ts
        │   ├── IDomainEvent.ts
        │   └── IHandle.ts
        ├── AggregateRoot.ts
        ├── Entity.ts
        ├── Identifier.ts
        ├── UniqueEntityID.ts
        ├── ValueObject.ts
        └── WatchedList.ts

実際に「ユーザ登録処理」を作ってみる

ドメイン層の定義

まず、ドメイン層として ValueObject と Entity の定義をしていきます。 ドメイン層はサービスの根幹となる部分であり、すべての処理はこのドメイン層で定義されたものを操作する形で実装していきます。

ValueObject

ValueObject は、Entityを構成するプロパティとして使用したり、レイヤ間のやり取りをする箇所などで用います。 ValueObject を用いるメリットとしては、対象の値が正しい形であるということが担保できるという点と、その値に対してのビジネスルールを豊かに表現できるという点があります。 オブジェクトを生成する際に必ずバリデーションロジックを通すように作ることができるため、ValueObject としてデータが存在している時点で、形式的に正しい値を持っているということが保証され、様々な箇所で形式チェックをする必要がありません。 また、例えば Email という ValueObject を作成したときに、対象の文字列からドメイン部分のみ抽出するというメソッドを用意したりなど、対象の値に対して何らかの加工をした形で取得をするといったことをまとめて定義しておくことができます。

以下の例では、ユーザの名前を表現する UserName ValueObject を定義しています。 入力された値が 3 文字以上 32 文字以下の場合はエラーを返す、簡単なバリデーションロジックを組み込んでいます。

// src/subdomains/user/ValueObjects/UserName.ts
export interface UserNameProps {
  value: string
}

export class UserName extends ValueObject<UserNameProps> {
  get value(): string {
    return this.props.value
  }

  private constructor(props: UserNameProps) {
    super(props)
  }

  private static isValidName(name: string) {
    const re = /^.{3,32}$/
    return re.test(name)
  }

  public static create(name: string): Result<UserName> {
    if (!this.isValidName(name)) {
      return Result.fail<UserName>(`Invalid Argument - userName:[${name}]`)
    }
    return Result.ok<UserName>(
      new UserName({ value: name })
    )
  }
}

※ Result

Result クラスは、成功形か失敗形かの状態を持たせた結果データを表すものとして実装しています。 成功形の場合は成功した値を取得することができ、失敗形の場合はエラーレスポンスを取得できます。どちらの状態であるかは isSucceedisFailure のプロパティの値で判別可能です。 上記 ValueObject の生成メソッドでこの戻り値を利用していますが、状態ごとの処理を記述する可能性のあるあらゆる箇所で利用しています。

Entity

用意した ValueObject をプロパティとして設定し、Entity を定義します。 Entity は固有の識別子として id を持っており、 id が一致している Entity は、プロパティが異なっている場合でも同一のものとして扱います。

// src/subdomains/user/Entities/User.ts
interface UserProps {
  name: UserName
  createdAt: DateTime
  updatedAt: DateTime
  deletedAt?: DateTime
}

export class User extends Entity<UserProps> {
  get name(): UserName {
    return this.props.name
  }
  
  get createdAt(): DateTime {
    return this.props.createdAt
  }

  get updatedAt(): DateTime {
    return this.props.updatedAt
  }

  get deletedAt(): DateTime | undefined {
    return this.props.deletedAt
  }

  private constructor(props: UserProps, id?: UniqueEntityID) {
    super(props, id)
  }

  public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
    const user = new User({...props}, id)
    return Result.ok<User>(user)
  }
}

アプリケーション層の実装

次に、定義されたドメイン層の部品を操作して、ユーザ作成処理を記述するアプリケーション層の UseCase、 DTO、 RepositoryInterface を実装していきます。

DTO

まず、 DTO(Data Transfer Object)を定義します。 DTO は、UseCase の入出力パラメータを表します。 UseCase がリクエストとしてどういう形式を求めているか、レスポンスとしてどんなデータが返るのかを明確に定義しておくことで UseCase の仕様を把握しやすくするなる他、UseCase を実行する際にはこの定義に従って呼ぶようにすればよいため、 通信プロトコルが何なのか、または DomainEvent (後述します)の後続処理として実行されるのかなど、実行元が何であるかなどを意識することなく UseCase を定義することができるようになります。

// src/subdomains/user/UseCases/CreateUser/CreateUserDTO.ts
export interface CreateUserRequestDTO {
  name: string
}

export interface CreateUserResponseDTO {
  success: boolean
}

UseCase

UseCase は、ドメイン層を操作してデータの加工をし、Repository にデータを渡す処理や、Repository から取得したデータを返却する処理を記述していきます。 UseCase は、できるだけ単一の操作にのみ責任を持つように作るほうが良いです。例えば、エンティティを新規作成する UseCase を作成する場合は、データ作成の成否のみを(必要であれば作成したエンティティのIDのみあわせて)返却するよう実装し、エンティティ全体のデータを取得する UseCase は別途作成するようにしておきます。 このように、データの操作のみに責任をもつ Command 処理と、データの返却にのみ責任を持つ(データを操作しない) Query 処理に明確に分けて実装しておくと、それぞれの処理の最適化に注力しやすくなり、データの読み出しに関してはデータに副作用を与えないことが担保された Query 処理を使うことで、安全にシステムを利用することができるようになります。※CQRS(コマンドクエリ責務分離)[2]

以下の例では、ユーザデータを作成を実行して、成功か失敗かの bool 値のみを返す Command UseCase を実装しています。

// src/subdomains/user/UesCase/CreateUser/CreateUserUseCase.ts
type Response = Either<
  | CreateUserAlreadyExistsError
  | CreateUserFailedToCreateUserError
  | UnexpectedError
  | BadRequestError,
  Result<CreateUserResponseDTO>
>

export class CreateUserUseCase
  implements UseCase<CreateUserRequestDTO, Promise<Response>> {
  constructor(
    private userRepository: UserRepository
  ) {}

  async execute(req: CreateUserRequestDTO): Promise<Response> {
    const nameOrError = UserName.create(req.name)
    if (nameOrError.isFailure) {
      return failed(new BadRequestError(nameOrError.errorValue().value))
    }

    const name = nameOrError.getValue()
    const now = DateTime.utc()
    try {
      if (await this.userRepository.isExists(name)) {
        return failed(new CreateUserAlreadyExistsError(name))
      }

      const userOrError = User.create({
        name: name,
        createdAt: now,
        updatedAt: now
      })
      if (userOrError.isFailure) {
        return failed(new CreateUserFailedToCreateUserError())
      }

      const user = userOrError.getValue()
      await this.userRepository.save(user)

      return succeed(
        Result.ok<UserCreateResponse>({
          success: true,
        })
      )
    } catch (err) {
      return failed(new UnexpectedError(err.message))
    }
  }
}

※ Either

UseCase のレスポンスは、Either クラスで表現しています。 UseCase の結果値として、複数の Result オブジェクトが返る場合がありますが、それぞれが正常系か異常系かを振り分けて定義しています。 これにより、 UseCase の利用側はレスポンスを判別が成功系か異常系か判別して処理を記述しやすくなるほか、UseCase の定義書のように機能し、どんなパターンのレスポンスが存在するか一目で分かるようになる利点があります。

UseCaseError

UseCase の異常系レスポンスとして、UseCaseError を定義します。

// src/subdomains/user/UesCase/CreateUser/CreateUserError.ts
export class CreateUserAlreadyExistsError extends Result<UseCaseError> {
  constructor(name: UserName) {
    super(false, {
      message: `User name already exists. - name: ${name.value}`
    } as UseCaseError)
  }
}

export class CreateUserFailedToCreateUserError extends Result<UseCaseError> {
  constructor() {
    super(false, {
      message: "Failed to create user."
    } as UseCaseError)
  }
}

RepositoryInterface

UseCase 実装時点で、Repository にアクセスする必要がある箇所を記述する場合は、Repository の実装を抽象化した RepositoryInterface を作成し、抽象化された Repository へアクセスする形で実装していきます。※依存性逆転の原則[3]

これをする理由は、UseCase は本来、データを永続化する仕組みが何であるか、ORMとして何を使っているのかなどは意識する必要がなく、永続化データへの読み書きさえできれば Repository の具体的な実装を気にしなくても良いためです。 また、Repository を抽象化せずに具体実装に対してアクセスするようにしてしまうと、アプリケーション層がそれより外側のアダプタ層に依存する形での実装となってしまい、内側の層が外側の層に依存してはならないというルールに反してしまうため、これを逆転させる目的があります。

// src/subdomains/user/Repositories/UserRepository.ts
export interface UserRepository {
  isExists(name: UserName): Promise<boolean>
  save(user: User): Promise<void>
}

データ永続化処理実装

UseCase 実装の際に定義した RepositoryInterface の定義に基づき、DB 定義とデータ永続化の具体実装をしていきます。 従来型の MVC による実装などに慣れていると、まず DB の定義をしてから UseCase 処理の実装をしていきたくなるかもしれませんが、今回のアーキテクチャではそれとは逆で、ドメイン層とアプリケーション層を実装したあとに DB 定義を行います。 Repository は アプリケーション層・ドメイン層の処理の中で生成・更新された Entity を永続化し、永続化されたデータを Entity として返すことにのみ責任を持つように実装していきます。 こうすることでサービスのコアとなるロジックが DB の定義や ORM などに密接に依存してしまうような作りを回避することができます。

DBスキーマ定義

TypeORM を使用する場合を例に以下のようにモデルを定義します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn
} from "typeorm";

@Entity()
export class User {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @CreateDateColumn({
    type: "datetime",
  })
  createdAt!: Date

  @UpdateDateColumn({
    type: "datetime",
  })
  updatedAt!: Date

  @DeleteDateColumn({
    type: "datetime",
    nullable: true,
  })
  deletedAt?: Date
}

Repository 実装

// src/subdomains/user/Repositories/implements/TypeOrmUserRepository.ts
import { createConnection, getRepository } from 'typeorm'
import { User } from '../../Infrastructures/typeorm/User'
import { UserMap } from '../../Mappers/UserMap'

export class TypeOrmUserRepository implements UserRepository {
  async isExists(name: UserName): Promise<boolean> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    const user = await userRepository.findOne({ name })

    await connection.close()

    return !!user
  }

  async save(user: User): Promise<void> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    await userRepository.save(UserMap.toPersistent(user))

    await connection.close()
  }
}

Mapper 実装

Entity <=> 永続化データ間の変換処理を Mapper として実装します。

// src/subdomains/user/Mappers/UserMap.ts
export interface UserRawProps {
  id: string
  name: string
  createdAt: Date
  updatedAt: Date
  deletedAt?: Date
}

export class UserMap implements Mapper<User> {
  public static toDomain(raw: UserRawProps): User {
    const userNameOrError = UserName.create(new UniqueEntityID(raw.name))
    if (userNameOrError.isFailure) {
      throw new Error(userNameOrError.error)
    }

    const userOrError = User.create(
      {
        name: userNameOrError.getValue(),
        createdAt: DateTime.fromJSDate(raw.createdAt),
        updatedAt: DateTime.fromJSDate(raw.updatedAt),
        deletedAt: raw.deletedAt
          ? DateTime.fromJSDate(raw.deletedAt)
          : undefined
      },
      new UniqueEntityID(raw.id)
    )

    if (userOrError.isFailure) {
      throw new Error(userOrError.error)
    }

    return userOrError.getValue()
  }

  public static toPersistent(userEntity: User): UserCreationParam {
    return {
      id: userEntity.id.toString(),
      name: userEntity.name.value,
      createdAt: userEntity.createdAt.toJSDate(),
      updatedAt: userEntity.updatedAt.toJSDate(),
      deletedAt: userEntity.deletedAt?.toJSDate()
    }
  }
}

通信インターフェース実装

ここまででユーザ登録処理の UseCase の実装が完了しました。次にこれを呼び出すインターフェースの実装をしていきます。 ここでは gRPC で実装する場合を例に実装していきます。

protocol buffer 定義

syntax = "proto3";
package sample.user.command_user;

service CommandUser {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message CreateUserRequest {
  string name = 1;
}

message CreateUserResponse{
  bool success = 1;
}

Controller 実装

// src/subdomains/user/UesCase/CreateUser/CreateUserController.ts
export class CreateUserController {
  constructor(
    private createUserUseCase: CreateUserUseCase
  )

  async createUser(
    req: CreateUserRequest,
    metadata?: Metadata
  ): Promise<CreateUserResponse> {
    const reqDTO = req as CreateUserRequestDTO
    const res = await this.createUserUseCase.execute(reqDTO)

    if (res.isFailed()) {
      const error = res.value
      switch (error.constructor) {
        case CreateUserAlreadyExistsError:
        case CreateUserFailedToCreateUserError:
        case UnexpectedError:
        case BadRequestError:
        default:
          return {
            success: false
          }
      }
    }

    return {
      success: true
    }
  }
}

これでユーザ登録処理の実装が完了しました。 gRPC で対象のインターフェースを呼び出すことで、DB に対象ユーザのデータが登録ができていることを確認することができます。

ユーザ作成直後に処理を実行したい場合

ユーザ登録処理が完了した直後、メールなどの通知を送信したりレポートデータを書き込みたいなどの後続処理を実装する必要が出てくるケースがあります。その場合は、既存の UseCase (今回でいうと CreateUserUseCase )に手を加えて実装はせず、別の UseCase として各処理を実装し、 DomainEvent を利用して処理を実行するのが望ましいです。

① CreateUserUseCase を実行 ② User エンティティを生成したときに UserCreated イベントを発行。データの永続化が完了した時点でイベントを発火させる。 ③ Subscription がイベントの発火を検知して、後続処理を実行する。

DomainEvent

User エンティティが生成される際に、対象の Entity 情報を EntityID でタグ付けした DomainEvent を発行します。 この時点では、 DomainEvent を発行しているだけで、まだ発火はしていません。

// src/subdomains/user/Entities/User.ts
- export class User extends Entity<UserProps> {
+ export class User extends AggregateRoot<UserProps> { 

    // ...

    public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
      const user = new User({...props}, id)

+     // 新規作成時のみイベントを発行するため、 id が付与されていない場合に実行
+     if (!id) {
+       this.addDomainEvent(new UserCreated(user))
+     }

      return Result.ok<User>(user)
    }
  }

イベントの発火は、データの永続化の完了を検知したタイミングで実行します。 TypeORM の場合を例にとると、 Entity Listener を使って以下のように実装します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
@Entity()
export class User {

  // ...

+ @AfterInsert()
+ dispatchAggregateEvents() {
+   const aggregateId = new UniqueEntityID(this.id);
+   DomainEvents.dispatchEventsForAggregate(aggregateId);
+ }
}

Subscription は、 DomainEvent の発火を検知して、DomainEvent 内の Entity 情報を使って後続処理を実行するように実装しておきます。その上で、 Subscription をサーバ起動時に常時監視状態にしておくことで、ユーザ登録完了後に後続処理を実行する実装の完了です。

おわりに

マイクロサービスのバックエンドサービスの実装に焦点を絞って、オニオンアーキテクチャをベースに設計をしました。 実際にこの構成を採用してシステムを実装した所感として、従来の MVC アーキテクチャなどの実装と比較して良かった点、気になる点をまとめると以下のとおりです。

良かった点

  • サービスのコアロジックを独立して実装できるため、ミドルウェアの変更など環境の変化に柔軟に対応可能。
  • 各実装レイヤーごとに明確に責務を割り振ることで、処理の流れを把握しやすい。
  • 一貫したルールでの実装を半ば強制することで、システムの規模が大きくなるにつれて処理が複雑になりすぎるということが起きづらい。
  • 機能の追加・削除が容易で、スケールしやすい。

気になる点

  • 簡単な機能であってもコードの記述量が多くなってしまうため、初期コストが高い。

冒頭で述べたように、運用面でのメリットは多くありますが、初期の実装にかかるコストは高いように思いました。 リリースまでのリソースに余裕があり、長期的に運用をしていく可能性のあるシステムには導入する価値があるように思いますが、スタートアップのような限られたリソースでとにかく早くリリースをしたいという場合は、慎重に採用の判断をしたほうが良い構成であると思います。

各項目の解説については詳細を割愛している部分も多々ありますが、全体像の大まかなイメージだけでも伝われば幸いです。

参考記事

能動的に価値を追求!レバレジーズのシステム開発

f:id:s-nagasawa-lvgs:20210707183141j:plain

こんにちは、レバテック開発部の長澤です。 タイトルの通り、今回は私の所属部署でのシステム開発について一部をご紹介します!

執筆の背景

私は現在4ヶ月目の中途入社の社員です。 まだわずかな期間ではありますが、すでにレバレジーズのシステム開発は前職までの経験と大きく違うことを実感しています。

転職前は、作成された仕様書にのっとり「機械的」に「工場」の様に開発することを求められていました。 どちらかといえばトップダウンでシステム仕様を決めることが多く、開発者の意見が採用されることは多くありません。 そのためか 「使われない新機能」 「報告するためだけのドキュメント」 「固定化した開発プロセス」 「負債を抱えたレガシー技術」 「形骸化した会議」 などなど、本質的ではない事象をよく見てきました。

そのような環境から一転、私は今とても充実して開発をしています。 なぜ充実しているのか?それは開発プロセスに秘密があります。 今回は「4つのレバレジーズ開発現場の特徴」を皆様にご紹介します!

1、本質の追求

  • 予定していなかった新技術もプロダクトの価値が向上するなら、開発途中に導入することも珍しくありません。
  • 開発時に発生した課題は対話をベースに議論を交わします。無駄なドキュメントは作りません。

2、フィードバック

  • 開発したサービスは利用者が多く、リリース当日の新機能でも利用者がその日に反応し、開発した達成感が得られます。
  • マーケティングチームから数値ベースでフィードバックがあり、利用状況の良し悪しから、開発観点からも改善施策の提案も行います。

3、高い専門性

  • リードエンジニアやDBスペシャリストも在籍しており、高度な技術について議論する事もあります。
  • DDD/クリーンアーキテクチャ、マイクロサービス、gRPC等、社内標準技術スタックに、モダンでより専門性を必要とする技術を採用しています。

4、戦略共有

  • 開発メンバー会議以外にも、マーケター・セールス・デザイナーを含めた部署横断でのプロジェクト進捗共有を毎週開催し、開発内容や優先度に対する認識をすり合わせています。
  • 仮説と根拠が伴った中長期戦略から、開発の方向性の納得感を得ることができ、自信を持った開発をしています。

最後に

いかがでしたでしょうか。 ご紹介した内容は取り組みのほんの一部ですが、プログラミングだけでは無く、様々な部署を通じてプロダクトを成長させていくために、広い視点で開発をしていることを感じていただけましたでしょうか。 システム以外の知識も必要とされる場合もあり、課題にぶつかることもありますが、乗り越えた際は、顧客志向やマーケティング等のシステム以外の観点についても自身の成長を実感しています。

レバテック開発部では、一緒にサービスを作り上げてくれる仲間を募集中です! ご興味のある方は、以下のリンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/

新卒エンジニアが1ヶ月かけてマーケティングを学んだ話

はじめに

 こんにちは。21卒エンジニアの田中、五十嵐、益子です。

 エンジニアの新入社員向け研修といえば、開発に関わる研修を中心に受けるのが一般的だと思います。レバレジーズでは、エンジニアもマーケティング職と同じプログラムでマーケティング研修を受けます。約1ヶ月間、マーケティングの基礎の学習から始まり、最終的には顧客理解に基づいた「重視すべきサービスが提供する価値の定義と改善施策」の提案を行いました。

 本記事では、実際に研修を受けた体験談を通じて、なぜレバレジーズのエンジニアはマーケティング研修を受けるのか、どのようなことを学ぶのか、配属後の業務にどのように活きているのかを紹介します。

顧客理解×エンジニアリング="いいサービス"

 レバレジーズにとってマーケティングとは、「顧客のニーズを満たすこと」であり、顧客に最適な解決策を提供することです。

 なぜレバレジーズのエンジニアは、マーケティングを学ぶのでしょうか。

 レバレジーズは、セールス・マーケター・デザイナー・エンジニアなど、さまざまな職種が社内にいるオールインハウスの組織です。職種の枠を超えたスピード感のあるコミュニケーションや連携を通じて、様々な事業を展開しています。その中で、顧客理解に基づいた"いいサービス"を作り上げるために、エンジニアはマーケターやデザイナーの考え方を理解した上で、密にコミュニケーションをとり、実践する必要があります。

 そのためにエンジニアもマーケティング研修を受けることで、マーケターが業務でどんなことをしているのか、どういった思考のプロセスが求められるのかを学びます。

こんなメンバーがマーケティング研修を受けました

田中

「新規の事業作りをしたい思いが強く、オールインハウス組織で、若いうちから職域を広げた働き方がしたいと考え、レバレジーズへの入社を決めました。マーケティング研修はすごく楽しみでしたが、正直どんなことをやるのか想像つきませんでした。」

五十嵐

「会社として急激に成長しており新規事業に携われる可能性が高く、他職種との関係性が密なため、求められるスキルの幅が広いと考えレバレジーズへの入社を決めました。マーケティング研修を受ける前は、研修が楽しみな気持ちが半分と、内定者インターンをしていたチームから離脱することに対する不安が半分ありました。」

益子

「エンジニアとして技術を大切にしながらも、マーケティングや事業開発まで職能を広げたいと思い、レバレジーズへの入社を決めました。学生時代は受託開発企業で働きつつ、マーケティングのゼミで事業計画書を作り、役員への事業提案もしていました。3人の中でも特にマーケティング研修を楽しみにしていたと思います。」

どんなことをしたのか

 次に、マーケティング研修の内容について紹介します。この研修では、レバレジーズのマーケターがどんな考え方や方法で業務に取り組んでいるのかを学びました。実務レベルで実際のレバレジーズの事業部のデータ分析を行い、データを元にペルソナ設計からUX(顧客体験)改善施策立案まで行いました。

 具体的には以下のプログラムです。

  • ロジカルシンキング研修
  • マーケティング概論
  • ビジネスモデル研修
  • UX研修
  • プロモーション研修
  • オウンドメディア研修
  • CRM研修
  • データ活用研修
  • プロセス研修

 これらの研修を受けた後に、最終アウトプットとして、レバレジーズの既存サービス改善施策提案を行いました。

 改善施策提案では、顧客が求めていることや提供している価値から、サービスとしての理想状態を自分たちで定義しました。そして、顧客インタビューの記録や実際のデータ分析を通じて、最適な顧客体験を提供するために何が足りないか、どのターゲットに対して施策を打つべきかを特定しました。

 顧客体験を考える際には、研修で学んだペルソナ設計やカスタマージャーニーマップ設計などのアプローチを活用。定義した顧客体験を実現するための施策を立案し、営業現場のヒアリングや期待できる効果検証・工数の見積もりなども行い、顧客の理想や現場の実情に即したアウトプットにこだわりました。

研修を受けて感じたこと

田中

 元々、エンジニアでも事業課題の解決やサービス改善施策立案をやりたいと思っていたので、マーケティングの基礎を学ぶ時間があることはすごく貴重な時間でした。研修では、より良いサービスやプロダクトを作るために顧客理解が大事なことを学びました。    現在は、「ハタラクティブ」というメディアの開発を担当していて、チームで顧客インタビューを実施し、顧客理解に基づいた改善施策を実行しています。

エンジニアの立場でも顧客インタビューに積極的に関わらせていただき、職種を問わずチーム全体で顧客のことを考えた改善施策を進めていくことに、レバレジーズの良さが表れていて、僕が目指していた職域を広げた働き方ができています。配属されたばかりですが、さらにマーケティングの思考を生かした施策提案などにも挑戦していきたいです。

五十嵐

 研修でマーケターの実務に近い経験をさせていただいたのは、とても貴重な経験で、たくさんのことを勉強させていただきました。僕は現在、新規開発の事業部に所属しています。新しいプロダクトを作る上で、まず顧客に対してどんな体験や価値を提供するかを考え、それを実現するためにどんな機能が必要かを定義する必要があります。

 最初にUXをどれだけ深く考えられるかがその後のプロダクトの価値を左右すると考えているので、新規開発でもUXを意識して業務に取り組んでいます。エンジニアリングだけでなく、幅広い知識を身につけて業務に望んでいきたいという、選考時に抱いていたことを実際に経験できています。今後、サービスがリリースされたら、マーケティング戦略が本格的に動き出すため、その際に、研修で学んだことを更に活かし、開発業務の枠を超えたエンジニアになれるように挑戦を続けていきたいです。

益子

 自分はエンジニアの枠を超えて、課題定義から戦略・戦術の策定、さらには事業開発まで関わりたいと考えていました。そんな自分にとって、社内のマーケターから社内で取り組むマーケティングを網羅的に学べる機会は、非常に貴重なものでした。

マーティング研修で得た知見は、既に業務にも活きていると感じています。開発業務において、各事業課題が設定された背景に意識が向くようになり、「仮説に対しての検証施策に対し、細かな変更に対応できる記述になっているのか」などの新しい視点を持つようになりました。    開発業務以外では、エンジニアリング以外の職域にも挑戦するために、顧客ニーズ・顧客行動の調査などの積極的な情報収集を始めました。まずは何を目的に、どのようなタスクが動いているのか、業務の現状を理解することから取り組んでいます。顧客ニーズの調査方法や顧客行動の調査方法は企業によって異なりますが、マーケティング研修で実務のプロセスで調査手法を学んだことで、スムーズに必要な情報を理解することができています。エンジニアとしての職域にとらわれず、マーケティングを含め、幅広い面から事業に貢献できるプレーヤーになるため、今後も積極的な取り組みを続けたいと思っています。

最後に

 3人とも配属先での業務も異なるため、研修で学んだことの活かし方が異なりますが、顧客体験や施策背景といった様々な視点を持って開発業務に取り組むことで、確実にそれぞれにとってプラスになっています。

今後は、サービスやプロダクトを利用する顧客について理解した上で、「いいサービス」の開発に取り組み、社会に影響を与えられる人材となるために、切磋琢磨して日々努力していきます。

 「マーケティングも学びたい、若いうちから職域を広げて将来的に、事業をリードするエンジニアを目指したい」と考えている方は是非レバレジーズで一緒に働きませんか。お待ちしています! https://leverages.jp/recruit/

GraphQL + Apollo Client + TypeScript + React で型安全なフロントエンド開発を実現した話

はじめに

こんにちは。レバレジーズ株式会社の大滝です。

私は、レバレジーズのHRテック事業部に所属し、新規SaaSサービスのフロントエンド開発を行っております。

今回は雑然としがちな新規開発、とりわけフロントエンド開発で避けたかった4つの課題を、技術的な観点から回避していった点を紹介したいと思います。

新規開発で回避したかった問題

私たちの開発は新規開発でしたので、できるだけ技術負債を作らないように、かつスピード感を持って開発を行う必要がありました。 そこでフロントエンド開発を行う上で回避したかったポイントがいくつかあります。

  1. バックエンドとフロントエンド間でAPI仕様確認と管理に時間がかかる
  2. 型安全ではない
  3. 画面によってコンポーネントのデザインがバラバラ
  4. 入力動作が遅い

また前提として、開発中のサービス全体がマイクロサービスアーキテクチャを採用しており複数のサービス間がGraphQLで通信されていると言う特徴がありました。

図1 マイクロサービスアーキテクチャ構成図

フロントエンドはBFF(Backends For Frontends)に接続し、BFFではバックエンドのマイクロサービスのAPIの集約を行っています。

問題の回避方法と技術選定

上記した問題をクリアするには適切な技術選定を行う必要がありました。

しかし技術選定の難易度が高かったため、弊社のテックリードや開発メンバーと協力し調査を行いました。

結果的に下記の他のマイクロサービスで使用している技術と近く、かつ社内ナレッジがある程度蓄積されていると言う観点から、Apollo Client(graphql-codegen)/TypeScript/Reactを採用し、フォームライブラリとして、React Hook Formを利用しました。

これらの技術により、1の課題に対して、フロントエンドはBFFのエンドポイントからschema(APIの型定義)を取り込みそこからコードを生成することで回避しました。 また、schemaから生成したコードをもとに静的型付き言語であるTypeScriptを用いて実装を行うことで2の課題を回避しました。

3の課題に対しては、デザインの再利用性を高められるようにAtomic designを採用し、それに相性の良いReactを用いました。 さらに、動作速度向上のためにReact Hook Formという依存関係が少なく、軽量なライブラリを用いることで動作速度を向上させることで4の課題を回避しました。

画面実装までのフロントエンド開発フロー

上記の課題をクリアした実際の開発の様子を紹介します。 実際の開発では下記のようなフローで開発を行っております。

図2 実際の開発の手順

この開発フローに沿って、下記の画像のような簡単なユーザーの住所を変更する画面を実際に作ってみます。

図3 ユーザーの住所を変更する画面

GraphQL schemaの実装

サンプルのGraphQLスキーマを用意しました。 今回取り込むschemaはこちらです

type User {
  firstName: String 
  lastName: String 
  address: String 
}

type Query {
  user:  User
}

氏名、住所を持っているUser情報を取得するQuery型に入れます。 今回はサンプルなので1名分のUserを取得する形にします。

フロントエンドでのschemaの取り込み

次に、このスキーマをフロントから取り込みます。 まずはQuery情報を記載するgraphqlファイルを作成します

query userSearch {
  user {
    firstName
    lastName
    address
  }
}

これをGraphQL Code Generatorという機能を使用して、上記のgraphqlファイルのスキーマ情報を取り込みます。 GraphQL Code Generatorはcodegen.yamlにエンドポイントやgraphqlファイルのディレクトリ等を記載してスキーマ情報を読み込みます。

React Hook Formを用いたFormの実装

取り込んだスキーマを使用できるFormを実装します。 React Hook Formを用いてテキストボックスを実装してみます。

今回はMaterial-UIのMuiTextFieldを使います。

textFields.tsxに下記のようにMuiTextFieldをReact Hook Formでラップします。

仕様としてはFormのデフォルト値、ヘルパーテキスト、エラーメッセージが表示でき、nameをキー、入力値をバリューとしてsubmitできるものとしておきます

export type FormTextProps = TextFieldProps & {
  name: string;
  defaultValue?: string;
  showError?: boolean;
  rules?: Exclude<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
};

const TextFields: React.FC<FormTextProps> = ({
  name,
  rules,
  defaultValue = '',
  error,
  showError = true,
  ...textFieldProps
}) => {
  const { control, errors } = useFormContext();

  return (
    <>
      <Controller
        control={control}
        name={name}
        defaultValue={defaultValue}
        rules={rules}
        error={!!(get(errors, name) || error)}
        render={({ onChange, onBlur, value }) => (
          <MuiTextField onChange={onChange} onBlur={onBlur} value={value} {...textFieldProps} />
        )}
      />
      {showError && (
        <ErrorMessage
          errors={errors}
          name={name}
          render={({ message }) => (
            <FormHelperText error={true}>
              {message}
            </FormHelperText>
          )}
        />
      )}
    </>
  );
};
export default memo<FormTextProps>(TextFields);

コンポーネントを実装する際のポイントですが、下記の5点を意識しています。

  • Material-UIのTextFieldPropsの型定義を拡張してReact Hook Formで扱いやすくする。
  • nameタグはReact Hook Formでsubmitした際のkeyにあたるので必ずpropsとして注入するように必須にする。
  • rulesはReact Hook FormのRegisterOptionsの型定義から必要なものを集めてくる。
  • defaultValueは指定していないとwarningになるので空文字を初期値として設定する。
  • メモ化して無駄なレンダリングを減らす。

Form値に入力した値の表示テスト

最後に新住所を入力して入力値をconsoleで確認できるところまで作ってみます。

ユーザーの氏名を表示して、住所を新しく登録する画面を作成していきます。

見栄えをよくするためにスタイルも当てていきます。

const SamplePage: React.FC<{}> = () => {
  const methods = useForm<{ testTextFields: string }>({
    mode: 'onBlur',
  });
  const { handleSubmit, getValues } = methods;

  const onSubmit = () => {
     console.log('submit:', getValues());
  };

  const { loading, data } = useUserSearchQuery();

  useEffect(() => {
     console.log(data);
  }, [data]);

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {!!loading && <>loading...</>}
        {!!data && (
          <>
            <Box display="flex" justifyContent="center" mb={2} mt={2}>
              ユーザーの住所情報を変更してください
            </Box>
            <Grid container alignItems="center" justify="center">
              <Grid item xs={8} style={{ backgroundColor: '#668bcd0f' }}>
                <Box m={2}>
                  <Grid container justify="center">
                    <Grid item xs={4}>
                      姓: {data?.user?.firstName}
                    </Grid>
                    <Grid item xs={4}>
                      名: {data?.user?.lastName}
                    </Grid>
                    <Grid item xs={8}>
                      現在の住所: {data?.user?.address}
                    </Grid>
                  </Grid>
                </Box>
              </Grid>
              <Grid item xs={6}>
                <Box mt={4}>
                  <TextFields
                    name={'newAddress'}
                    label={'新住所'}
                    rules={{
                      required: {
                        message: 'この項目は必須です',
                        value: true,
                      },
                    }}
                    defaultValue={data?.user?.address || ''}
                    helperText={'新しい住所を入力してください'}
                    variant={'outlined'}
                    fullWidth={true}
                  />
                </Box>
              </Grid>
            </Grid>
            <Box mt={3} display="flex" justifyContent="center">
              <Button type="submit" variant="contained" color="primary">
                更新
              </Button>
            </Box>
          </>
        )}
      </form>
    </FormProvider>
  );
};

export default SamplePage;

APIで取得したデータを表示する際は、codegenでgenerateしたファイルからAPIをfetchするuseUserSearchQueryをインポートして使用します。

ここで使用しているqueryのhookはGraphQL Code Generatorにtypescript-react-apolloのpluginを入れて生成されるもので、手間のかかるAPIのエラーのハンドリング部分の実装をせずにhookをimportするだけですぐにAPIを使用することができます。

useUserSearchQueryの実態をgenerateされたファイルで確認してみます

export function useUserSearchQuery(baseOptions?: Apollo.QueryHookOptions<UserSearchQuery, UserSearchQueryVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useQuery<UserSearchQuery, UserSearchQueryVariables>(UserSearchDocument, options);
      }

UserSearchQuery型がGenericsで渡されているので戻り値は型安全になっています、また使用する際はスニペットが効くのでかなり開発しやすいです。

空の状態でsubmitした際にはバリデーションがかかり、onBlurでもバリデーションがかかるように実装しています。この時にuseFormにGenericsで渡したFormの型がReact Hook Formに登録されます。

今回はMaterial-UIのBoxとGridを用いて画面を実装しましたが、これによりレスポンシブにも対応できる作りになっています。

まとめ

簡単ではありますが、新規開発等でも型安全にかつスピード感を持って開発できるような開発手法を紹介いたしました。

このように、GraphQLのスキーマから型情報を取得しTypeScriptとReactを用いて型安全な実装ができる上に、React Hook Formを用いることで簡単にFormの値の制御が行うことができるので非常に使い勝手が良いです。

HRテック事業部では一緒にサービスを作ってくれる仲間を募集中です!ご興味を持たれた方は、下記リンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/

Slack APIとLambdaの仕様による板挟みを回避した話

はじめに

こんにちは。レバレジーズ株式会社エンジニアの原田です。

私は、レバレジーズのシステムマネジメントチームに所属し、社内の業務改善のため、さまざまなWebサービスの導入や社内ツールの開発を行っています。

例えば、SlackとDocBaseのWebサービス同士のグループを同期させるツールを開発しました。いくつか問題が起きたことがあったので、どうやって対策したのかを紹介させていただきます。

DocBaseとは

DocBaseは気軽に書き込めるナレッジ共有サービスで、弊社では毎日数百件ナレッジが作られ共有されています。 このナレッジの閲覧権限はグループで管理することができ、ユーザーをグループに参加させることで簡単にアクセス権を管理することができます。

同期ツールとは

同期ツールは、AWSのLambda上で動作し、下記のイベントでグループの作成やリネーム、グループ参加者の管理を自動で行うツールです。

  • Slackチャンネルが作成された
  • Slackチャンネルがリネームされた
  • Slackチャンネルに誰かが参加した
  • Slackチャンネルから誰かが退出した

このイベントをもとに、Slackチャンネルと同名のグループをDocBase上に用意します。その後、Slackチャンネルに参加しているメンバーをグループ参加者として追加する動作を行います。

ただし、稀にSlackからのイベントを取得できないことがあり、「ナレッジを閲覧することができない」お問い合わせが発生することがありました。そのため、定期的にSlackチャンネルの情報をDocbaseに一括して同期するバッチ処理を追加で作ることにしました。

バッチ処理の内部動作

当初、バッチ処理は以下の図のように動作させることを考えていました。

早速バッチ処理用のLambdaを作成し、Slack APIを使って実装を行いました。 動作確認のためテストを行ったところ、次のような問題が発生しました。

  • 一定期間内におけるSlack APIの実行回数上限を上回ってしまう
  • Slack APIの実行回数上限を超えないようウエイト処理を挟むと、Lambdaの実行時間上限を超えてしまい処理が中断される

この時上限に達することを想定していなかったため、どのように問題を解決すれば良いかとても困った記憶があります。

なぜ上限に達したのか

Slack APIには毎分実行できるAPIの実行回数が設定されており、それを超えると429エラーが返ってくるよう設計されています。 なのでAPIを実行した後に2 ~ 3秒のウエイト処理を実行することでこの実行回数上限は回避できる、という仕様が存在します。

また、Lambdaは15分以上実行させようとするとタイムアウトしてしまい、処理が中断してしまうという仕様が存在します。

今回の追加開発では全Slackチャンネル情報が必要になるため、Slackチャンネル数分APIを実行する必要がありました。 この時、APIの実行が必要な回数は3,000回を上回っており、ウエイト処理を実行させると15分以上処理に時間がかかるため、Lambdaが途中で処理を中断させてしまうのです。

どのように解決したか

Slack APIとLambdaの仕様をチームメンバーに伝え、どのようにこの問題を解決するか相談したところ「1度にまとめてやろうとせず、処理を分割して行う」方針で解決する話になりました。

処理を分割すれば、Lambdaの実行時間上限を超えないようSlack APIを実行できるのでSlack APIとLambdaの仕様どちらも解決可能です。

こうして、同期ツールのバッチ処理開発を行うことができ「記事が閲覧できない」というお問い合わせを大きく減少させることができました。

もし、同じように困っている方がいましたら、参考にしていただけますと幸いです。

まとめ

今回の問題に遭遇したことで、予め上限や制約などがないか調べる癖を付けると良いなと実感しました。

レバレジーズでは、業務上の問題や課題は、一人ひとりの問題ではなく、チームメンバー全員の問題や課題として扱うことで自然と知見を共有できるため、すぐに問題解決が行えます。

システムマネージメントチームでは一緒にレバレジーズを支えてくれる仲間を募集しています!ご興味を持たれた方は、下記リンクから是非ご応募ください。
https://recruit.jobcan.jp/leverages/

チームブレストから8言語検索のコスト削減とUX最適化を両立させた話

はじめに

レバレジーズ株式会社エンジニアのカラバージョ(Caraballo)です。今回は、8言語(*1)で求人情報を提供しているメディアであるWeXpats Jobsで実装した多言語検索のコスト最適化についてご紹介します。

(*1) 2021年2月現在。

なぜコストの最適化が必要だったのか?

チームの目標として、ユーザーエクスペリエンス(UX)を向上させるために日本語で書いてある求人情報を複数の言語で検索できるようにする必要がありました。

私たちのチームでの最初のアプローチは、Google translate APIを使用して各求人情報を翻訳し、Elasticsearchにインデックスを付ける予定でした。これは簡単なアプローチのようにみえますが、APIの費用が100万文字あたり$20USDであり費用対効果が低いことに気付きました。 月額だと約 $4,000USDの費用がかかる計算です。

この問題にどのように取り組んだのか?

まずはじめに、ブレインストーミングを行い問題を根本的なものに集約しました。つまり、「日本語のテキストデータを元にして他の言語での検索を効率的に行う方法は何か」ということです。

たとえば、次のテキストのように 「東京でReactを使用したフロントエンドエンジニアとしての職務」の中から、仕事を探すという文脈で意味を伝えたい重要な部分は [東京、React、使用、フロントエンドエンジニア、職務 ] の名詞であり、[で,を,した,としての]を省略しても他の言語に翻訳したときに元のテキストの意味をほとんど反映できます。

したがって、各求人情報の名詞を抽出して翻訳することで翻訳の必要があるAPI呼び出しの数が減る、さらに記事の間で何度も使われている名詞の翻訳結果をキャッシュすることでさらに翻訳の数を減らすことができました。 その結果、翻訳されるデータが多くなるほど、辞書が増え必要なAPI呼び出しが少なくなっていきます。

計画は次のとおりでした。

  1. 日本語のテキストをtokenizeし、名詞のみを抽出する。
  2. 抽出した名詞を共通の辞書に保存し、必要に応じて各言語に翻訳を追加する。
  3. 翻訳された名詞を準備して、Elasticsearchの各言語のインデックスを作成する。

実装

上記の要件を実装するために、今回はGolangを利用しました。

  1. 日本語の原文を取り込む
  2. トークナイザーを使用して名詞のみを抽出する
  3. 新しい単語がある場合、辞書に保存する
  4. 翻訳されていない単語を翻訳する
  5. 複数の言語でのテキストBLOBを作成する
  6. Logstashを使ってElasticsearchにデータを取り入れてインデックスを作成する

日本語をトークン化するために、次のgolangライブラリを使用しました。

github.com/ikawaha/kagome/tokenizer
Kagome Japanese Morphological Analyzer(https://github.com/ikawaha/kagome/tree/master)

例:
package main

import (
    "fmt"
    "github.com/ikawaha/kagome/tokenizer"
)

func main() {
    nouns := GetTokens("東京でReactを使用したフロントエンドエンジニアとしての職務")
    fmt.Printf("%v", nouns)

}

func GetTokens(content string) []string {
    t := tokenizer.New()
    tokens := t.Analyze(content, tokenizer.Normal)
    var nouns []string
    for _, token := range tokens {
        if token.Class == tokenizer.DUMMY {
            continue
        }
        feature := token.Features()[0]
        switch feature {
        case "名詞":
            nouns = append(nouns, token.Surface)
        }
    }
    return nouns
}

output

[東京 React 使用 フロントエンドエンジニア 職務]

まとめ

今回は、多言語検索のためのコスト最適化の例をご紹介しました。 翻訳された名詞の辞書を作成することで、API呼び出しの数を大幅に減らすことができ、将来的にはWeXpats Jobs以外のサービスでも多言語検索サポート機能を使用できるように拡張していく予定です。

レバレジーズ株式会社では現在、サービスを開発し、優れたユーザーエクスペリエンスを作成するための新しい仲間を探しています。興味のある方は、こちらのリンクに応募してください。
https://recruit.jobcan.jp/leverages/