UPSIDER Tech Blog

ZodとOpenAPI Generatorで日付文字列をBrand型で扱う

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事ではZodとOpenAPI Generatorを使用し、日付文字列をBrand型で扱う方法について紹介します。

TypeScriptを使用したフロントエンドのアプリケーション開発で日付をstring型で扱う際に困ることがあります。
日付をstring型として扱う際、APIから返却された未フォーマットの文字列と、UI用にフォーマットされた文字列を区別することが難しく、誤った表示や変換処理が発生しがちです。
またAPIリクエストでは正しいフォーマットで送信されているかどうかを保証できないため、不具合の原因になることがあります。

たとえばUIで表示する日付文字列が yyyy年MM月dd日 であった場合、この文字列をさらにフォーマットしようとすると想定外のエラーとなります。

// date-fnsのformatを想定
// APIレスポンスの日付文字列を想定
format(new Date('2024-01-20'), 'yyyy年MM月dd日') // Success

// 変換後の文字列を誤って指定
format(new Date('2024年01月20日'), 'yyyy年MM月dd日') // Error: Invalid Date

私たちが開発している 支払い.com でもこれらの課題を抱えており、Brand型を導入しました。
このプロダクトではOpenAPI Generatorを使用してコードを生成しています。そのため自動生成されるコードにBrand型をマッピングする必要がありました。
またFormやURLパラメータの検証にはZodを使用しています。Brand型をZodと組み合わせることでより扱いやすくなりました。

今回の記事では 支払い.com でBrand型を導入した知見を共有します。
大きくライブラリに依存した内容になりますが参考になれば幸いです。

設計

APIレスポンスの日付文字列を表示

// 表示コンポーネントではDate型で受け取ることで、フォーマットが必須であることを明確にする
const DateField = ({ value }: { value: Date }) => {
  return <p>{format(value, 'yyyy年MM月dd日')}</p> // formatはdate-fns想定
}

type ApiDateString = string & { __brand: 'ApiDateString' }

type Order = {
  // APIレスポンスの日付はBrand型で定義する
  shipmentDate: ApiDateString
}

const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  return <DateField value={new Date(shipmentDate)} />
}

ユーザーによる日付入力の値をAPIリクエストパラメータとして使用する

※ 以下の例ではuseStateを使用したサンプルですが、支払い.comではReact Hook Formを使用しています

const InputDate = (props: {
  value: string,
  onChange: (v: string) => void
}) => { /* 実装は省略 */ }

type ApiDateString = string & { __brand: 'ApiDateString' }

// APIリクエストパラメータの日付はBrand型で定義する
type PostParams = { shipmentDate: ApiDateString }

const postDate = (args: PostParams) => { /* 実装は省略 */}

const Form = () => {
  const [formData, setFormData] = useState<{ shipmentDate: string }>({ shipmentDate: '' })

  const handleSubmit = () => {
    // バリデーション
    if (!formData.shipmentDate) {
      return
    }
    postDate({
      ...formData,
      date: format(formData.shipmentDate, 'yyyy-MM-dd') as ApiDateString
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputDate
        value={formData.shipmentDate}
        onChange={
          (v) => setFormData(
            prev => ({ ...prev, shipmentDate: v })
          )
        }
      />
    </form>
  )
}

これらの設計のとおりBrand型を使用することで安全に日付文字列を扱えるようになりました。
実際にプロジェクトの導入する例を紹介します。

Zodを使用する

Brand型のZodスキーマを定義し、スキーマから型を得る

const API_DATE_STRING = z.string().brand('ApiDateString')
type ApiDateString = z.infer<typeof API_DATE_STRING> // string & z.BRAND<"ApiDateString">

// あとはZodを使用しない場合と同様に使用できる
type Order = {
  shipmentDate: ApiDateString
}

type PostParams = { date: ApiDateString }

Zodスキーマから得た型を使用しBrand型が定義できることはわかりましたが、まだ以下の問題があります。

  • ユーザー入力値の検証の処理を曖昧にしている
  • APIリクエストのパラメータを渡す際に、日付文字列の項目の詰替えが必要になっている
  • asを使用しBrand型にキャストしている

これらについてZodを使用し解決していきます。

ユーザー入力値の検証、型と値の変換

const FORM_SCHEMA = z.object({
  date:
    z
      .string() // 未入力状態など考慮し、入力はstringで受け付ける必要がある
      .refine(v => /* 文字列が日付として正しいか検証 */)
      .transform(v => format(v, 'yyyy-MM-dd')) // APIで扱う日付フォーマットに変換
      .pipe<API_DATE_SCHEMA> // 検証が完了し、フォーマットされた日付文字列はApiDateStringとして扱う
})

定義したスキーマを使用して検証し、後続処理で検証後の値を使用する。

  const handleSubmit = () => {
    // バリデーション
    const res = FORM_SCHEMA.safeParse(formData)
    if (!res.success) {
      return
    }
    postDate(res.data) // 検証後の日付文字列はApiDateString型となっているため、詰め替えが不要になった
  }

asの代わりにpipeを使うようにしただけではないかと言われるとそのとおりだと思いますが、検証、型と値の変換の定義を一箇所で行うことができ、シンプルで扱いやすくなったと感じていますがどうでしょうか?

transformでApiDateStringのフォーマットに変換すべきか

むずかしいところ
多くのユースケースでsubmitした値を使用し、APIリクエスト、その結果を使用してなんらかの後続処理を行うことを考えると有りだと思う。
しかしsubmitした値をQuery文字列へpushし、Query文字列から取得した値を使用しAPIリクエストを行うなど必ずしもApiDateStringへ変換すべきではないと思う。

FORM_SCHEMAから得られる型

検証前の値はstring型、検証後の値はApiDateString型として扱うことができる。
検証前は任意の値 (デフォルト値は空文字) を扱う必要があるのでstring型であることが望ましい。

  type Input = z.input<typeof FORM_SCHEMA> // string
  type Output = z.output<typeof FORM_SCHEMA> // string & z.BRAND<"ApiDateString">

OpenAPI Generatorで日付文字列をBrand型にマッピングする

APIスキーマからAPIのリクエストとレスポンスの型を自動生成することがあります。
OpenAPI Generatorを使用し、日付文字列をBrand型にマッピングする方法について説明します。

設定ファイル

typeMappings:
  date: ApiDateString

APIスキーマファイル

Order:
  type: object
  required:
    - shipmentDate
  properties:
    shipmentDate:
      type: string
      format: date

これで以下の型を生成できるようになります。

type Order = {
  shipmentDate: ApiDateString
}

ただし生成したファイルからApiDateStringをimportしていないため、エラーとなってしまいます。 生成したコードに追記するなどなんらかの方法でApiDateStringをimportするか、globalに定義することで解決できます。

今回はglobalに定義する方法を紹介します。

global.d.ts

// @/lib/zod/brandにApiDateStringを定義したものとする
declare type ApiDateString = import('@/lib/zod/brand').ApiDateString

また日時は以下の設定でコード生成できます。

設定ファイル

typeMappings:
  DateTime: UnixtimeNumber

APIスキーマファイル

Order:
  type: object
  required:
    - createdAt
  properties:
    createdAt:
      type: string
      format: date-time

生成されるコード

type Order = {
  createdAt: UnixtimeNumber
}

これでOpenAPI Generatorを使用したプロジェクトで日付文字列をBrand型で扱うことができるようになりました。

あとがき

日付の扱いは一見単純に思えても、プロジェクトが大きくなるにつれて複雑さが増していく部分です。
特にAPIとのやり取りやUIでのフォーマットなど、少しのミスで意図しない不具合を引き起こすことも少なくありません。
今回紹介したBrand型やZodやOpenAPI Generatorのような素晴らしいライブラリに助けられました。

もしこの記事が少しでも参考になれば幸いです。
フィードバックがあればお待ちしております。

Reactで絞り込みFormの作り方

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事では、Reactを使用した絞り込みフォームの作り方をご紹介します。

Formはアプリケーションのコア機能の1つとなりえる一方、検証やQuery Stringとの連携など複雑な要素が多く、実装者によって大きな差が生じやすい部分であると感じています。

今回は絞り込みFormの作り方を例に、FormとQuery Stringの連携方法や、ユーザーが自由に入力するFormとQuery Stringの値の扱い方について1つの実装方法を紹介します。

前提

記事中で紹介するコードは以下のツールの使用を想定していますが、SPAであれば他のツールでも活用できると考えています。

以下のライブラリを想定したコードも一部使用しますが、コードを簡潔に記述するものであり主旨とは関係ありません。

伝えたいこと

  • Form、Query Stringでユーザーによって指定される値の扱い方
  • FormとQuery Stringを併用する際の実装方法

作るもの

以下の画像のような絞り込みFormと一覧部分で構成される画面を作成します。
絞り込み条件やページネーションはQuery Stringと連動します。
取引日はFrom、Toともに必須項目とします。

ユースケース

以下の2つのシナリオを想定します。

Formに絞り込み条件を入力して絞り込みボタンを押す

  • Formの値を検証する
    • 不正な場合、エラーを表示する
  • 検証が成功した場合、Query Stringへ反映する
  • Query Stringに反映されたForm入力値をパラメータとして、一覧取得APIを呼び出して一覧を表示する

URLを直接開いた場合

  • Query Stringを検証する
    • 不正な場合、Query Stringをあらかじめ決めたデフォルト値で置き換える
  • 検証が成功した場合
    • Query Stringの内容をFormに反映する
  • Query Stringをパラメータとして一覧取得APIを呼び出して一覧を表示する

今回は上記仕様としますがURLを直接開いた場合、APIを呼ばずにFormへセットするだけにするなど要件に合わせて変更できます。

処理の流れ

Form

以下を考慮する必要があります

  • 入力コンポーネントの型に依存した入力値を扱えること
  • デフォルト値を扱えること
  • 入力値の検証が行えること
  • 検証後、後続処理で扱いやすい任意の型で扱うことができること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormInput = {
  transfer_date_from: Date | null
  transfer_date_to: Date | null
  amount_from: string
  amount_to: string
}

日付入力コンポーネントは制御コンポーネントDate | null を扱い、数値入力コンポーネントは非制御コンポーネントstring を扱うことを想定します。

必須項目である取引日は最終的にはnullは許容しませんが、デフォルト値やユーザーによる未入力状態への変更を考慮する必要があるため nullable を指定します。

任意項目である金額はoptionalを付与する必要がありますが、必ずdefaultValuesを指定する設計にすることで、未設定 = 空文字となるためoptionalを付与する必要はありません。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const FORM_SCHEMA = z.object({
  transfer_date_from: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  transfer_date_to: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  amount_from: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
  amount_to: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
})
import { z } from 'zod'

z.input<typeof FORM_SCHEMA>
// {
//   transfer_date_from: Date | null;
//   transfer_date_to: Date | null;
//   amount_from: string;
//   amount_to: string;
// }

z.output<typeof FORM_SCHEMA>
// {
//   transfer_date_from: string;
//   transfer_date_to: string;
//   amount_from?: number;
//   amount_to?: number;
// }

先述した入力を想定した型、出力を想定した型を考慮してスキーマを定義します。 ZodEffects (transformなど) が発生するスキーマは検証前の入力値と検証後の出力値で得られる型が異なります。 z.inputを使用することで入力値の型、z.outputを使用することで出力値の型を得られます。 これによりstring型で入力された値を、検証後はnumber型で扱うようなことが可能になります。

またpipeを使用することで stringで受け付けた値をnumberとして検証することが可能になります。

z.string().positive() // Property 'positive' does not exist on type 'ZodString'
z.string().pipe(z.number().positive()) // OK

defaultValues

上記スキーマの説明においても、必ずdefaultValuesを指定する設計について書きましたが、React Hook FormのuseFormを使用してFormコンポーネントを作成する場合、defaultValuesを必ず設定することをおすすめします。

非制御コンポーネントを使用した場合に、UI上差が無いにもかかわらず、undefinedと空文字をそれぞれ扱わないといけなくなるなど、予想が難しいふるまいをスキーマで表現しないといけなくなります。

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
}

また、defaultValuesとvaluesを併用することは避けることをおすすめします。

今回のサンプルからは外れますが、既存のデータを更新するFormについて考えます。

更新Form

defaultValuesに対し、valuesはリアクティブにFormへ反映されるため、既存データを取得してFormへ反映させる場合に扱いやすいです。

同一項目をもった新規作成Formと更新Formを共通化することも容易にできます。

しかし、以下の理由からvaluesを使用せずにdefaultValuesのみを使用することをおすすめします。

  • defaultValuesを使用してFormが描画される → ユーザーの入力を受け付ける → APIから既存データの取得が完了しFormに反映される → ユーザーが入力した情報が失われる

APIから必要なデータの取得が完了したあと(defaultValuesが確定したあと)、Formコンポーネントをマウントするとよいでしょう。

取得が完了するまでスケルトンを表示するなど、取得中のUIの工夫が必要です。

データ取得完了前にどうしてもFormを表示しておきたい場合は、ローディングなどを表示しユーザー操作を受け付けないようにした方が良いかもしれません。

export const BaseForm = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA>
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
  return <form>{...}</form>
}

const UpdateForm = () => {
  const { data } = useFetchData()
  
  // データ取得が完了してからマウントする
  return data ? <BaseForm defaultValues={{
    transfer_date_from: new Date(data.transfer_date_from),
    transfer_date_to: new Date(data.transfer_date_to)
    amount_from: `${data.amount_from}`,
    amount_to: `${data.amount_to}`,
  }} /> : <p>{'loading...'}</p>
}

話が脱線しましたので元に戻します。

Formの内容をQuery Stringへ反映する

Form Submitをトリガーに入力値を検証し、不正な場合はエラーメッセージを表示するなど適切な処理をします。

正常な場合は、Query Stringへ反映します。

処理の流れに記載した通り、この値を直接APIのパラメータとしては使用しません。

import { useRouter } from 'next/router'

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  const router = useRouter()
  const { handleSubmit } = useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    resolver: zodResolver(formSchema),
    defaultValues,
  })
  
  const onSubmit = (d: z.output<typeof FORM_SCHEMA>) => {
      router.push({ query: { ...d } })
  }
  
  return <form onSubmit={handleSubmit(onSubmit)}>{...}</form>
}

Query String

Formの値をQuery Stringに反映できたので、次はQuery Stringについて見ていきます。

以下を考慮する必要があります

  • Query Stringを扱うライブラリから取得できるQuery Stringの型 (next/router の場合、 useRouter().query の型 string | string[] | undefined ) に依存した入力値を扱えること
  • 値の検証を行えること
  • 検証後は後続処理で扱いやすい任意の型で扱うことができること
  • Query Stringの値をFormに反映できること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringInput = {
  transfer_date_from: string | string[]
  transfer_date_to: string | string[]
  amount_from?: string | string[]
  amount_to?: string | string[]
}

すべての項目が string | string[] | undefined となりますが、要件が許す場合は必須項目のundefinedを許可しない方が扱いやすいと思います。

今回は必須項目の undefined を許可しない形とします。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const isString = (v: unknown): v is string => {
  return typeof v === 'string'
}

const QUERY_STRING_SCHEMA = z.object({
  transfer_date_from: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      // string型以外の場合、z.NEVERを返すことで後続処理ではstring型に絞り込まれる (z.NEVERを返す代わりにthrowしても同様の結果が得られる)
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  transfer_date_to: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  amount_from: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
  amount_to: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
})
z.input<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string | string[]
//   transfer_date_to: string | string[]
//   amount_from?: string | string[]
//   amount_to?: string | string[]
// }

z.output<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string
//   transfer_date_to: string
//   amount_from?: number
//   amount_to?: number
// }

Query Stringの変更

Query Stringが変更された場合(もしくは直接URLを開いた場合)、上記スキーマを使用して検証します。

検証

検証結果が不正な場合は、デフォルト値をセットすることで常に正しい値パラメータを使用できる状態にします。(この例ではQuery Stringの一部でも不正な場合、すべての項目に対してデフォルト値が設定されるため要件に合わせて変更してください。)

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
}, [
  router,query, router.push
]

Query StringをFormに反映

Query StringをFormに反映します。

URLを直接開いた場合、Query StringをFormへ反映する必要があります。

Form Submitした場合も同じ処理が実行されますが、Formの検証を先に行っているためUI上変化はないはずです。(途中のformat等の処理によりForm内容が置換される可能性があるが、大きな問題はないと思っています。ただし、Form Submitせずに入力値をリアルタイムでQuery Stringに反映させていく場合は、この設計をそのまま使うことはできないかもしれません。)

const { reset } = useForm({})

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
  
  reset({
    transfer_date_from: new Date(res.data.transfer_date_from),
    transfer_date_to: new Date(res.data.transfer_date_to),
    amount_from: res.data.amount_from === undefined ? '' : `${res.data.amount_from}`,
    amount_to: res.data.amount_from === undefined ? '' : `${res.data.amount_to}`,
  })
}, [
  router,query, router.push
]

検証後のパラメータを使用してAPIを呼び出し

import { useQuery, skipToken } from '@tanstack/react-query'
import { stringify } from 'qs'

const params = useMemo(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...router.query,
  })

  if (!res.success) {
      return null
    }
    return res.data
}, [router.query])

const { data } = useQuery({
  queryKey: ['transactions', params],
  queryFn: params
    ? () => fetch(`https://api.example.com/transactions?${stringify(params)}`)
    : skipToken,
})

まとめ

以上の設計により、FormからSubmitされた場合もURLを直接開いた場合も、常に「URL → API呼び出し」の流れが確立され、処理の流れがわかりやすくなります。

指摘や他に良い設計があれば是非教えて下さい。

Go Conference 2024 にBronze スポンサーとして協賛 & slogについて登壇しました! #gocon

UPSIDERでエンジニアリングマネージャーをしているMiki (@m_miki0108) です!

2024年6月8日(土)に開催されたGo Conference 2024に登壇し、Bronzeスポンサーで協賛いたしました!

Go Conferenceとは?

Go Conferenceとは、年に一度開催されるGo言語に関するカンファレンスです。

今年は数年ぶりのオフライン開催で、キャパシティ400名のところキャンセル待ちが数百人という大人気かつ大規模なイベントになりました。 当日はセッションはもちろん、オフラインならではの交流やイベントブースなど盛り沢山な内容でした!

登壇した内容

「Custom logging with slog Making Logging Fun Again!」という内容で20分の英語セッションをいたしました。こちらがスライドです。

内容は、「Goの1.21から標準ライブラリとして導入されたslogのCustom Handlerを作ってログ出力をカスタムしてみよう!」というAll Levelのセッションです。 slogを触ったことのない人も楽しめるように以下のような内容でお話ししました。

  • slogの基本
  • slogのアーキテクチャ
  • シンプルなslogのCustom Handlerを作るデモ
  • Performance関連の考慮事項

デモに関して、Custom Handlerを作るライブコーディングをやりたかったのですが今回は20分のセッションということで時間的になかなか厳しそうでした。

なので実装をステップに分けてGitの差分を見てもらいながら理解してもらう方針でやってみました。結果として「初見の人にもわかりやすい内容になったかな?」と思っています。

また、今回のGo Conferenceは株式会社サイバーエージェントさんのAbema Towersを会場に使わせていただいたということで、サンプルコードにこそっとAbema.tvを忍び込ませてみました。

こういう遊び心を忘れないように日々仕事しています!

UPSIDERでの取り組み

当社ではGoで書かれたマイクロサービスがたくさん存在しています。

会社全体のGoのプロジェクトで共通的に使っているものも多いですが、各チームで持っているサービスの特性や非機能要件、作られた時期や開発者の方針によってはアーキテクチャや利用しているライブラリが違うこともよくあります。 ロギングについても同様で、現状slogを使っているプロジェクトもあれば、standard libraryのlogをカスタムして使っていたり、サードパーティのzapを使っているところもあります。

個人的にはGoのプロジェクト全体で統一・標準化して開発スピードを落とさずに、より調査しやすく必要十分なロギングが実現できるようにしたいと思っています。

slogの良いところは、標準ライブラリでありlogとの後方互換性も保たれていること、サードパーティのロギングpackageでも共通の処理(バックエンド)を利用することができることです。(セッションでお話ししたように、APIをログメッセージの必要な情報を収集するフロントエンドとそれらを処理するバックエンドに分けるような構成になっています。)

これらのメリットを踏まえつつ、全体でどういう形にするのが最適なのかはこれから模索していきたいところです!

We are hiring!

UPSIDERではGoのエンジニアの採用中です! より具体的な業務事例をNoteで発信しています。ぜひご覧ください! note.com

ご興味をもっていただいた方はぜひカジュアルにお話ししましょう! pitta.me herp.careers

UPSIDERのこれからを担うFlutterアプリのアーキテクチャ

こんにちは、UPSIDERで日々モバイルアプリ開発をしているふっくです。

UPSIDERでは今後、よりアプリ開発に注力し決済プラットフォームの中核的な役割を果たすことを目指しています。

今回は、今後の開発・運用を目指して考えたFlutterアプリ向けのアーキテクチャを紹介します。

ネイティブアプリの世界で触れてきた色々なアーキテクチャフレームワークを参考に、開発の後半でも順調にスケールさせることができるように、工夫を凝らしました。

アーキテクチャで作ったサンプルアプリもあるので、ぜひ以下のリンクから見てみてください。

https://github.com/upsidr/flutter_architecture_blueprint

デモはこちら

https://upsidr.github.io/flutter_architecture_blueprint/

対象読者

この記事は、Flutterアプリの設計に興味がある方を対象としています。また、以下の知識を持っている前提となっています。

目指すところ

サービスの成長に応じてアプリを破綻なくスケールさせるためには、適切なレイヤー分割とその疎結合化が必要になります。

アーキテクチャは、

  • Presentation / Domain / Data の3層レイヤーを採用する
  • View と ViewModel (このブログでは以降Notifierと呼びます) の責務を明確に分離する
  • Notifierの内部では、StateとActionなどを用いて整理し、アプリの振る舞いを予測可能なものにする

の3つを前提に設計を行いました。

持続可能な形で安全にアプリ開発を進めることができるよう、テストコードフレンドリーになるよう設計しました。とくに、Presentation層のテストがしやすい形を目指しました。

参考にしたもの

全体的な作りは、モバイルアプリで流行りの実装手法を参考にしました。

Now in Android

Now in Androidは、Google Androidチームによる開発者向けの技術記事のシリーズ名です。同タイトルで、Android開発におけるベストプラクティスを示すリファレンスアプリが公開されています。

https://developer.android.com/series/now-in-android?hl=ja

関心事で分離するモジュール

関心事で分離するモジュール構成は広く知られています。Flutterにおいてもディレクトリの切り方として扱いやすいため、参考としました。

https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md

モックライブラリに深く依存しないテストダブル

DIを活用してテストダブルにFakeを使用することで、モックライブラリに深い依存をせずにテストコードを記述することができます。これにより、テストコードがシンプルになり、忠実度の制御が容易になります。

https://github.com/android/nowinandroid?tab=readme-ov-file#testing

    class TestUserDataRepository : UserDataRepository {
        /**
         * The backing hot flow for the list of followed topic ids for testing.
         */
        private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)
     ...
         /**
         * A test-only API to allow setting of user data directly.
         */
        fun setUserData(userData: UserData) {
            _userData.tryEmit(userData)
        }
    }

The Composable Architecture

The Composable Architecture (TCA)は、Point-Freeチームが開発しているiOS向けのアーキテクチャフレームワークです。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/

UIの抽象化

TCA はUIを State / Action で抽象化しています。他の宣言型UIの世界でも見られる手法であり、単方向データフローを構築しやすい・テスト容易性が向上するなどのメリットがあります。

@Reducer
struct Feature {
  struct State { /* ... */ }
  enum Action { /* ... */ }
  var body: some ReducerOf<Self> {
    // ...
  }
}

swift-dependencies

TCA では swift-dependencies というDIライブラリを使っています。このライブラリでは、メソッドをクロージャープロパティで定義し、本番用・テスト用などと実装を差し替え可能にする手法が紹介されています。

https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/designingdependencies#Struct-based-dependencies

struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

ライブラリ選定

アーキテクチャに関わるパッケージを以下のように選定しました。

目的 パッケージ
状態管理 hooks_riverpod, rxdart
モデル freezed
ルーティング auto_route

状態管理

3層レイヤーを採用するため、レイヤー間の繋ぎ込み(DI)に riverpod を使いました。

rxdartは、主にBehaviorSubjectを使ってData層から Presentation層 までのデータの伝搬に使用します。

モデル

Dart3では、代数的データ型(直和型・直積型)を言語レベルで扱えるようになりました。

https://dart.dev/language/patterns#algebraic-data-types

これに freezed を組み合わせると、より簡単に記述することができるようになるので採用しました。

https://pub.dev/packages/freezed#legacy-union-types-and-sealed-classes

ルーティング

go_router / auto_route から、書き心地の好みで auto_route を採用しました。

アーキテクチャ解説

アーキテクチャにおいて特徴的なPresentation層に焦点を絞って解説をします。

Domain・Data層の詳細はサンプルコードをご参照ください。

https://github.com/upsidr/flutter_architecture_blueprint

Presentation層

アーキテクチャのデータフローは以下のようになります。

アーキテクチャーのデータフロー

図のように、UIState / Action / Effect を使って View・Notifier間の単方向データフローを実現します。

■ Contract

ここでは、TCAのUI抽象化 を参考に画面が取りうる全ての状態を抽象化して記述し、クラスとして定義します。

  • UIState - 画面の表示要素
  • Action - UIの操作イベント
  • Effect - 画面遷移などの画面イベント

この3要素をまとめて Contract という名前で扱います。

TCAは State と Action の2つを定義しますが、本アーキテクチャでは State を UIState と Effect に分割しています。

これは、SwiftUIがダイアログ表示や画面遷移を1つの State として扱えるのに対し、FlutterではUIの宣言とは別に手続き的に表示・遷移処理が必要であり、Effect が独立している方が扱いやすいためです。

サンプルのタスク一覧画面の Contract がこちら

abstract class BaseContract<UiState, Action, Effect> {
  void consume();
  Future<void> send(Action action);
}

typedef TaskListContract
    = BaseContract<TaskListUiState, TaskListAction, TaskListEffect>;

// 画面の表示要素
@freezed
class TaskListUiState with _$TaskListUiState {
  const factory TaskListUiState({
    @Default([]) List<EditableUserTask> taskList,
  }) = _TaskListUiState;
}
//  UIの操作を定義
@freezed
sealed class TaskListAction with _$TaskListAction {
  const factory TaskListAction.onAppear() = OnAppear;
  const factory TaskListAction.newTaskButtonTapped() = NewTaskButtonTapped;
  const factory TaskListAction.taskTapped(EditableUserTask task) = TaskTapped;
  const factory TaskListAction.toggleIsCompleted(EditableUserTask task) =
      ToggleIsCompleted;
  const factory TaskListAction.onTaskSwiped(EditableUserTask task) =
      OnTaskSwiped;
}
// 画面遷移などの画面イベントを定義
@freezed
sealed class TaskListEffect with _$TaskListEffect {
  const factory TaskListEffect.none() = None;
  const factory TaskListEffect.goDetail({
    required EditableUserTask? task,
  }) = GoDetail;
  const factory TaskListEffect.showAlert({
    required AlertState state,
  }) = ShowAlert;
}

■ Notifier

ここでは

  • UIState・Effect の管理
  • Action のハンドリング

を行います。

Notifier は AutoDisposeNotifierProvider で実装し、UIState を state として扱います。

Effect は別途 StateProvider を作って管理します。

サンプルのタスク一覧画面の Notifier はこちら

(riverpod_generator による自動生成)

final taskListEffectProvider =
    StateProvider((ref) => const TaskListEffect.none());

@riverpod
class TaskListNotifier extends _$TaskListNotifier implements TaskListContract {
  late final StreamSubscription _taskListSubscription;
...
  @override
  TaskListUiState build() {
    _taskListSubscription = _taskListUseCase()
        .listen((list) => state = state.copyWith(taskList: list));
    ref.onDispose(_taskListSubscription.cancel);
    return const TaskListUiState();
  }

  @override
  void consume() {
    _updateEffect(const TaskListEffect.none());
  }

  @override
  Future<void> send(TaskListAction action) async {
    switch (action) {
        case NewTaskButtonTapped():
        _updateEffect(const TaskListEffect.goDetail(task: null));
            case ...:
              state = state.copyWith(...)
    }
  }

  _updateEffect(TaskListEffect effect) =>
      ref.read(taskListEffectProvider.notifier).update((state) => effect);
}

■ View

ここでは

  • UIState の表示
  • Effect のハンドリング
  • Action の送信

を行います。

Extensionプロパティを定義して、 ref.notifier.send() の形で Action を送信できるようにしています。

Effect は ref.listen で購読し、画面イベントを副作用として処理します。

サンプルのタスク一覧画面の View はこちら

extension _TaskListEx on WidgetRef {
  TaskListNotifier get notifier => read(taskListNotifierProvider.notifier);
}

@RoutePage()
class TaskListPage extends HookConsumerWidget with AlertStateCompatible {
  const TaskListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(taskListEffectProvider,
        (_, effect) => handleEffect(context, ref, effect: effect));
    ...
    return Scaffold(
      appBar: AppBar(
        title: const Text('ToDo'),
        centerTitle: true,
      ),
      body: const _TaskListBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: () =>
            ref.notifier.send(const TaskListAction.newTaskButtonTapped()),
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> handleEffect(
    BuildContext context,
    WidgetRef ref, {
    required TaskListEffect effect,
  }) async {
    switch (effect) {
      case None():
        break;
      case GoDetail():
        ref.notifier.consume();
        context.router.push(EditTaskRoute(task: effect.task));
      case ShowAlert():
        ref.notifier.consume();
        handleAlertState(context, effect.state);
    }
  }
}

class _TaskListBody extends HookConsumerWidget {
  const _TaskListBody();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final taskListCompleted = ref.watch(
      taskListNotifierProvider.select(
          (value) => value.taskList.where((t) => t.isCompleted)),
    );
    ...

    return CustomScrollView(
      slivers: [
        ...
        SliverPadding(
          padding: const EdgeInsets.symmetric(vertical: 16),
          sliver: SliverList(
            delegate: SliverChildListDelegate(
                buildList(ref, taskList: taskListCompleted)),
          ),
        ),
      ],
    );
  }
...
}

ディレクトリ構成

Now in Andoirdをベースとしています。

ディレクトリ構成

├── app_router.dart
├── app_router.gr.dart
├── core
│   ├── data
│   │   ├── network
│   │   │   └── fake_todo_api_client.dart 👈 サンプルでは実際の通信はナシ
│   │   └── repository
│   │       └── todo
│   │           ├── fake_todo_repository.dart
│   │           ├── fake_todo_repository.freezed.dart
│   │           ├── todo_repository.dart
│   │           └── todo_repository.freezed.dart
│   ├── domain
│   │   ├── model
│   │   │   ├── editable_user_task.dart
│   │   │   └── editable_user_task.freezed.dart
│   │   └── todo
│   │       ├── edit_task_usecase.dart
│   │       ├── edit_task_usecase.freezed.dart
│   │       ├── task_list_usecase.dart
│   │       └── task_list_usecase.freezed.dart
│   ├── model
│   │   ├── user_task.dart
│   │   ├── user_task.freezed.dart
│   │   └── user_task.g.dart
│   └── util
│       ├── alert_state.dart
│       ├── alert_state.freezed.dart
│       ├── base_contract.dart
│       ├── datetime_formatted.dart
│       └── stream_extensions.dart
├── feature
│   └── todo
│       ├── edit_task
│       │   ├── edit_task_contract.dart
│       │   ├── edit_task_contract.freezed.dart
│       │   ├── edit_task_notifier.dart
│       │   ├── edit_task_notifier.g.dart
│       │   ├── edit_task_page.dart
│       │   └── ui_components
│       │       ├── task_text_field.dart
│       │       └── toggle_complete_button.dart
│       └── task_list
│           ├── task_list_contract.dart
│           ├── task_list_contract.freezed.dart
│           ├── task_list_notifier.dart
│           ├── task_list_notifier.g.dart
│           ├── task_list_page.dart
│           └── ui_components
│               └── task_list_item.dart
│               └── task_list_placeholder.dart
├── main.dart
└── main_device_preview.dart

feature 直下のディレクトリは関心事の単位で切ります。タスク一覧画面のファイルの場合は以下の箇所になります。

👉 lib/feature/todo/task_list

── task_list
   ├── task_list_contract.dart
   ├── task_list_contract.freezed.dart
   ├── task_list_notifier.dart
   ├── task_list_notifier.g.dart
   ├── task_list_page.dart
   └── ui_components
       ├── task_list_item.dart
       └── task_list_placeholder.dart

テストについて

アーキテクチャは、Presentation層のテストに重きを置いています。

画面の実装では、Contract のモデリングを真っ先に行うことで Notifier をテスト駆動開発(TDD)で実装することが可能になります。

基本的に Notifier のユニットテストは以下の流れで実装します。

  1. notifier.sendメソッドで Action を送信
  2. Notifierが処理した結果の UIState / Effect を評価する

サンプルのタスク一覧画面の Notifier 「タスク作成ボタンをタップしてタスク編集画面に遷移する」のテストがこちら

group('Navigation', () {
  test('Tap NewTaskButton, navigate detail', () async {
    final (notifier, uiState, effect) = buildAccessors();
    
    // 👇 Data層のFakeRepositoryの挙動を変更
    todoRepository.handler.fetchTaskList = () async =>
        fakeTodoState.update((value) => value.copyWith(taskList: []));

    await notifier.send(const TaskListAction.onAppear());
    expect(uiState().taskList.isEmpty, true);

    await notifier.send(const TaskListAction.newTaskButtonTapped());
    expect(
      effect(),
      const TaskListEffect.goDetail(task: null),
    );
  });
  ...

setUp にてFakeをDIしています。

void main() {
  ...
  buildAccessors() {
    final subscription = container.listen(taskListNotifierProvider, (_, __) {});
    addTearDown(subscription.close);
    return (
      container.read(taskListNotifierProvider.notifier),
      () => container.read(taskListNotifierProvider),
      () => container.read(taskListEffectProvider),
    );
  }
  ...
  setUp(() {
    fakeTodoState = BehaviorSubject.seeded(const FakeTodoRepositoryState());
    todoRepository = FakeTodoRepository.from(fakeTodoState);
    container = ProviderContainer(overrides: [
      todoRepositoryProvider.overrideWithValue(todoRepository), // 👈 ココでDI
    ]);
    ...
  });

  tearDown(() => container.dispose());
...

FakeTodoRepositoryの実装

swift-dependencies DIのアイデアを使っています 👍

  class FakeTodoRepository implements TodoRepository {
    const FakeTodoRepository._(this.fakeState, this.handler);

    factory FakeTodoRepository.from(
        BehaviorSubject<FakeTodoRepositoryState> fakeState) {
      return FakeTodoRepository._(
          fakeState, FakeTodoRepositoryHandler(fakeState));
    }

    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    final FakeTodoRepositoryHandler handler;

    @override
    Stream<List<UserTask>> get taskList =>
        fakeState.map((state) => state.taskList);

    @override
    Future<void> fetchTaskList() async {
      await handler.fetchTaskList();
    }

    @override
    Future<void> addTask({required UserTask task}) async {
      await handler.addTask(task);
    }

    @override
    Future<void> updateTask({required UserTask task}) async {
      await handler.updateTask(task);
    }

    @override
    Future<void> removeTask({required String id}) async {
      await handler.removeTask(id);
    }
  }
    
  class FakeTodoRepositoryHandler {
    FakeTodoRepositoryHandler(this.fakeState);
    
    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    
    late AsyncCallback fetchTaskList = () async {};
    late AsyncValueSetter<UserTask> addTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.appending(task)));
    };
    late AsyncValueSetter<UserTask> updateTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.replaced(task)));
    };
    late AsyncValueSetter<String> removeTask = (taskId) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.deletedBy(taskId)));
    };
  }
    
  @freezed
  class FakeTodoRepositoryState with _$FakeTodoRepositoryState {
    const factory FakeTodoRepositoryState({
      @Default([]) List<UserTask> taskList,
    }) = _FakeTodoRepositoryState;
  }

今後に向けて

Flutter Web + device_preview の体験

今回のサンプルは device_preview を使って Flutter Web で公開してみました。

👉 https://upsidr.github.io/flutter_architecture_blueprint/

https://pub.dev/packages/device_preview

device_preview を Flutter Web と組み合わせることで、

  • アプリ外観の確認が容易になる
  • 誰でもすぐに成果物を閲覧できる

などの利点を強く感じました。

公開範囲の制限など考えないといけないこともありますが、開発フローの要所要所で活かしていきたいと思いました 👍

実装方針を決めてみた感想

プロダクトで実際に運用することになれば、色々な課題が出てくるでしょう。

ボイラープレートの自動生成をしてみる、状態の変更に対する網羅的なテストを実装できるようにするなど、チームのみんなと一緒に改良を続けていければと思います。

最後に

今回は運用検討中のFlutterアーキテクチャの紹介でした。

UPSIDERでは、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに世界中の挑戦者を支えるチャレンジをしています。UPSIDERが提供する法人カード「UPSIDER」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?

ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!

herp.careers

余談

サンプルアプリを公開するにあたって、リポジトリ名をどうするかで少し議論になりました。

flutter_architecture_blueprintflutter-architecture-blurprint

結論、チームによっていろんな判断がありそうでした。

UPSIDERの決済領域における技術選定と、運用後の感想

はじめに

弊社は社名と同じ、法人カード「UPSIDER」と請求書カード払いサービス「支払い.com」等のサービスを提供し、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに掲げる会社です。 今回は、その根幹である決済領域のテックスタックを紹介しようと思います。

決済領域とはなんやねんという方はこちらの資料にわかりやすい説明が載っていますのでこちらもぜひチェケラしてください

https://www.slideshare.net/MikiMasumoto/go-upsider

ただ紹介しても面白味がないので、立ち上げフェーズにメインで進めていたRyoyaが導入時の経緯を、途中参画したTakuyaが参画後に使用してみた感想を、それぞれの視点で言い合う形式で進めていきます。

決済システムを取り巻くテックスタックとアーキテクチャ

各テックスタックの話に入る前に、決済領域のどこにどの技術やアーキテクチャを利用しているかを図とともに簡単に説明していこうと思います。 まず、決済システムを含めたUPSIDERのアプリケーションはほぼすべてKubernetesの上で動いています。決済システムは、そのインフラを活用して、多数のGoのマイクロサービスで構成されています。

そして、その一つ一つのGoのサービスのビルドとテストにはBazelを利用してきました。 また、メインのデータベースはSpannerとなっております。 それでは、各テックスタックを紹介していきます!

レッツ&ゴー

マイクロサービス

概要

僕らはProcessing領域のサービス群をマイクロサービスを使ってk8s上に構築しています。

また、それらサービス間の通信方法としてgRPCを採用しています。

導入時の経緯

決済のドメインにマイクロサービスを採用した理由として以下のようなものがありました。

  • 決済という複雑かつ多様なドメインの責務を分離しつつもメンテしやすくするため
    • 決済処理、カード発行、WebやアプリなどのUI向け機能などにわけることで、変更を加える際の影響範囲を限定的にする
    • すべてのメンバーが決済領域の関わる全機能や領域に精通していなくてもある程度の開発ができるようにする
  • 障害が起きた際の影響範囲を限定できる
    • 例えば、Webでの画面表示向けの機能で障害が起きても、決済処理には影響が出ないようにする
    • また、障害時の復旧作業の際に、作業が影響しうる他の機能を限定的にする

その一方で、マイクロサービスはドラスティックな変更には弱いというデメリットがあり初期フェーズでの採用はアンチパターンとされる場合も多いかと思います。

しかし、ユーザーが直接触る機能と違い、決済領域はカードの仕様や制約に縛られる部分が多いため、新規追加や中小規模の改変はあれど、既存のコアの機能にドラスティックな変更は入る頻度はあまり多くないことが予想されました。そこで、決済に関してはデメリットが大きくならないと判断して、導入を決断しました。

参画して使用してみた感想

参画当初は細かくサービスが分かれているので全体像の把握が困難でした。ただ、一つ一つはそこまで複雑なことはやっていないので割とすぐに慣れることができて、慣れたあとはメンテナンスが容易だと感じています。

また、gRPCを採用することによっていわゆるスキーマ駆動開発が強制されるため、開発順序の秩序が保たれるのもナイスです。

ただ、これはgRPC特有だと思いますが、Sliceのnilとemptyを区別することができないのは初心者に対する罠ポイントでした。(区別できる前提で設計をして実際に動かしてみたらどちらもnil扱いとなり、設計からやり直すというまぁまぁ大きな手戻りが発生してしまいました😭)

Go

概要

UPSIDERでは、大半のバックエンドの領域にGoを採用しています。決済領域もほぼすべてGoで書かれており、決済領域がUPSIDERのシステムの中で一番最初にGoで書かれた領域です。

導入時の経緯

初期のUPSIDERで言語を選定する中で、以下の基準で言語を選定した結果、決済領域にはGoを採用することになりました。

  • 型があること
  • 可読性が高いこと
  • テストが書きやすいこと
  • 並行処理が書きやすいこと

決済領域は、高い安定性とパフォーマンスが求められる一方で、複雑度・理不尽度の高いビジネスロジックを取り扱う上でメンテナンスしやすさも必要となります。そこで、上記のような強みを合わせ持つGoが最適な言語であると判断して導入を決めました。

参画して使用してみた感想

まずなんと言ってもマイクロサービス、gRPCとの圧倒的相性の良さが挙げられると思います。もともとGoは簡潔さを重んじるカルチャーの元作られている言語だと思っており、サービス単体の振る舞いがシンプルなこれらの技術と組み合わせることでGoならではのデメリットが打ち消されて、メリットが存分に発揮されていると感じます。

一例として、Goではいちいちエラーハンドリングを考慮しなければならないので、巨大なモジュールを作る場合などはerrorの扱いが複雑になりがちですが、マイクロサービスのような設計と併用することでデメリットが打ち消されているように思います。決済領域は例外ハンドリングを適切に行なった上で、異常系であってもその旨を伝えるレスポンスを返さないといけないのですが、Goのように常に例外を意識しながらコードを書いていくと、予想外のことが起こりづらいのです。

また、テストの実行がシンプルで素早いのはGoの明らかなメリットです。標準で提供されたテストの実行環境は他言語でテストフレームワークを使用した場合と比較して開発者体験が圧倒的に良いと感じています。決済領域のような、極端に品質を求められるシステムにおいて、テストコードの実装ハードルの低さはとても重要な要素です。

Google Cloud Spanner(Google Cloud)

概要

Google Cloud が提供するSpannerは、Google Cloud が提供するスケーラブルなフルマネージドデータベースです。大規模なトランザクション処理とSQLクエリのサポートが特徴です。

UPSIDERでは、どのデータストアを使うかサービス群の特徴によってそれぞれ判断おり、決済領域においてSpannerを採用しています。

導入時の経緯

決済領域のデータベースを選定する上で、まずドメインの性質から以下の基準を満たすデータベースを探しました。

  • メンテナンスコストや整合性を大きく犠牲にせず、スケーラビリティを担保できる
    • 決済領域はデータの取り扱い量が多いため
  • 高い可用性・安定性

上記二点だけであれば、Amazon DynamoDBなども選択肢に入りましたが、最終的に下記の二点から、Spannerの採用に至りました。

  • キャッチアップコストが低いこと
    • ANSI SQLをサポートしていて見かけはRDBに類似しているため、RDBの開発経験者にとってはNoSQL DBよりはるかに馴染みやすい。
  • インフラをGCP中心で構築していたこと

参画して使用してみた感想

私個人の話になりますが、まず参画時点ではSpannerの利用経験はなく、RDBは一通り使っていたものの、NoSQLはプロダクトで使ったことがないといったレベルでした。そのため、まずは概念の理解からスタートし、RDBとの考え方の差異を埋めていくところから始まりました。

とはいえ、そこまでハードルが高いものでもなく、また、SpannerはSQLによる問い合わせが可能なのも相まって、あまり苦戦はしなかった記憶があります。

ちゃんと運用し始めてからの感想としては、テーブル構成をしっかり考えておけば、ユーザー数がどんなに増えても安心なのは大変心強いと感じています。BtoBのサービスをホストしていると、しばしば障害が複数企業に影響してしまうなどのシチュエーションに遭遇しますが、ことデータストア周りにおいてはSpannerを利用することでそういったインシデントを未然に防ぐことができているように感じます。

一点苦言があるとすればGoogle Cloudのコンソールが少し使いづらいところです。クエリ結果をエクスポートする時とかなどはやむを得ずCLIを使っています。

Bazel

概要

Bazelは、Googleが開発したビルドおよびテストツールです。多言語サポートと実行環境の差異を排除したビルドを提供し、ビルドの再現性に優れているのが特徴です。

UPSIDERでは、決済領域のCI/CDにBazelを組み込み、ビルド・テストに利用しています。

導入時の経緯

Goの導入を決めた際には、ビルドの手段の準備が必要になってくるわけですが、その中でBazelを採用することになりました。

主な採用基準としては、以下のような点です。

  • 実行環境への依存が少なく、再現性を持ったビルドができる
  • 文法で比較的シンプルで書きやすい
  • 当時 Google が推しているプロジェクト(だと思っていた)ので、今後のメンテナンスや普及についても期待が持てた。

ただし、Bazelは最初のセットアップが非常に大変で、悪戦苦闘しながらなんとかセットアップして運用してきました。

参画して使用してみた感想

そもそもbazelファイルの書き方だとか、コンフィグレーションの方法を意識しなくてはならず、Go最大の特徴でありメリットであるシンプルさから逸脱してしまっているように感じてしまいました。

また、これは最適化を怠っているからだとは思うのですが、ビルドにめちゃくちゃ時間がかかるようになってしまったのは残念ポイントです。そもそも純正のGoのビルドにそこまで時間がかかっている印象はなく、逐次最適化していかないと本来のビルドよりも遅くなってしまう状態はナンセンスだと感じています。

そのため、途中からBazelによるビルドはやめ、今はGO_PRIVATEを設定して依存関係を成り立たせつつ、go buildを使ってデプロイ用のイメージを作成しています。

現在もBazelでビルドすることが前提になっているコードは残っていますが、それらもリプレイスしていく予定です。

Goのパッケージ: Testing(gomock & cmp)

概要

これまで紹介したセクションの中でも度々テストについては言及があったかと思いますが、僕らはテストに心臓を捧げた民族です。そのため、僕らのテストを支えてくれる技術に関して最後に紹介させてください。

導入時の経緯

  • gomock

    最初は自前でinterfaceを実装したモックのコードを一つ一つ書いてきました。ただ、さすがにそのコードを書いてメンテすることに疲弊していきました 😥その中で、途中からgomockを利用することになりました。

  • cmp

    Goの標準のテストパッケージでは、テスト結果と期待値を比較して差分をわかりやすく表示してくれるものはありません。最初はreflectパッケージを利用して、比較を行っていましたが、差分が生じたときの確認が一々手間になっていました。そこで、go-cmpパッケージを利用することになりました。

使用参画して運用してみた感想

  • gomock

    参入した当初から使っていたので当たり前になっていましたが、これがない世界線には行きたくないなぁと感じています。高性能なテストダブルがコマンド一発で作られるのはめちゃくちゃ体験がよく、癖はありつつも慣れたらその利便性の虜になりました。

    ただ、呼び出し前に期待値を設定することを強制されるので、そこは柔軟性にかけるように感じています。他言語のテストダブル系のツールだと呼び出した後にそれを検証するというのが一般的なように思っていて、気づかぬうちにテストケースが複雑化していることはしばしばあります。

  • cmp

    参入した当初から使っていたので当たり前になっていましたが、これがない世界線には行きたくないなぁと感じています(n回目)

    差分の見やすさに加え、値の比較時に利用される便利なオプションがたくさん提供されているのも満足度が高いですし、なんなら自分で作ることも容易にできるので、普段使いの利便性と拡張性どちらの特性も併せ持ったヒソカみたいなパッケージだなと思ってます。

まとめ

今回の記事を書くにあたって、導入・運用の二つの観点でそれぞれの技術についてのメリデメだったり、各フェーズでの課題や成功体験をシェアすることができました。意外とこういう機会は少なく、業務の合間合間にさらっと雑談として話されることが多い印象ですが、理解の整理や今後同様のシチュエーションにおける道標となり得る感触があったので、また開催しようと思っています。

同様のテクノロジースタックを検討しているエンジニアの皆さんにとって、この記事が有用な情報源となれたら嬉しいです!

今後も我々の技術選定は続く ——

herp.careers

支払い.comのブランチ戦略見直しとテスト環境を複数作成できるようにした

はじめに

UPSIDER の 支払い.com でバックエンドエンジニアを担当している水村です。今回は支払い.comのブランチ戦略とテスト環境を見直して改善している件をまとめました。

最近のチーム構成

技術境界でチーム分けをしています。フロントエンド、バックエンドをそれぞれ専任で開発しており、OpenAPI によるスキーマ駆動開発を行っています。

現在(2023年11月22日時点)は以下のチーム構成です。

  • フロントエンド4名(フルタイム3名)
  • バックエンド4名(フルタイム2名)

3ヶ月前までは以下のチーム構成で開発をしていました。

  • フロントエンド2名(フルタイム1名)
  • バックエンド3名(フルタイム1名)

小規模なチームのため、ブランチ戦略やテスト環境による悩みはそれほどなかったのですが、幸いなことに正社員でフルタイムのメンバーが増えてきたことにより、今までそれほど問題ではなかったことが徐々に問題となってきました。

開発の流れ

以下のような流れでブランチを作成し、本番リリースを行なっています。Git-Flow に則った開発フローです。

  1. develop ブランチから feature ブランチを作成する
  2. feature ブランチから Pull Request を作成する
  3. Pull Request をレビューし問題なければ develop ブランチにマージする
  4. develop ブランチにマージされたタイミングで develop ブランチがテスト環境に自動でデプロイされる
  5. テスト環境でテストする
  6. リリースしたいタイミングで develop ブランチを main ブランチにマージする
  7. GitHub の画面上からタグを作成して main ブランチをリリースする(フロントエンド、バックエンドは個別にデプロイ可能)

ブランチ戦略とテスト環境の課題

支払い. comは初期から git-flow で開発を行なっています。テスト環境は1つで、develop ブランチにマージされると自動でテスト環境にデプロイされます。ブランチ戦略については何度かチーム内で話し合いを行なっており、将来的には GitHub-Flow に変わるかもしれません。現在は以下の課題を認識しているので改善を行なっている最中です。

  1. 大きめの機能を develop にマージした場合、テストが終わるまでの間は develop にマージできない場合がある
  2. テスト環境が1つしかなく、並行してテストがやりづらい

リリース初期から開発メンバーがあまり増えていなかったことと、大きめの機能リリースが少なかったことから先ほどの問題があまり重要視されていませんでした。ですが、8月ごろからエンジニアが増え、開発する機能数も増えてきたため、ブランチ戦略などの見直しを行った方が良さそうとなりました。

ブランチ戦略見直しと複数のテスト環境をデプロイする

先ほどの課題を同時に解決するため、最近は次の対応をおこなっています。

※前提として、支払い. comのフロントエンドは Next.js を Firebase Hosting にデプロイしています。バックエンドは Server-Side Kotlin を Cloud Run にデプロイしています。

  1. 大きめな機能は feature ブランチとは別に scope ブランチを作成し、feature ブランチを scope ブランチにマージする
  2. Firebase Hosting の preview 機能を使って Pull Request ごとにフロントエンドのテスト環境を作成する
  3. Cloud Run の deploy preview 機能を使って Pull Request ごとにバックエンドのテスト環境を作成する(こちらについてはまだ完全にできているわけではないので対応中)

これらの対応によって、develop ブランチが長期間占有されてしまうという課題を解消し、複数の機能を並行してテスト可能になると考えています。まだ全ての機能が対応できているわけではないですが、一部の機能については develop ブランチとは別のテスト環境を用意して並行でテストができるようになりました。

develop ブランチから main ブランチに対して 特定の機能のみを cherry-pick してリリースするという案もありましたが、cherry-pick でのリリースは失敗するリスクが高いと判断したためやめました。

scope ブランチを作成するデメリットは develop ブランチへのマージが遅くなることです。develop へのマージが遅くなることによりソースコードのコンフリクトが発生する確率が上がります。よって、scope ブランチは定期的に develop ブランチをマージし、コンフリクトが発生しないようにした方が良いかもしれません。また、scope ブランチが長期間存在しないようにする工夫が必要になるかもしれません。

バックエンドのテスト環境を複数デプロイできるようになりましたが、テスト環境が接続するデータベースは1つにしています。理由は、複数データベースをセットアップするのが結構大変であること、銀行振込などに使用している外部サービスが指定した IP アドレスのみの接続を許可しているため、全部の機能をテストできるのは develop ブランチがデプロイしたテスト環境のみという制約があるためです。

バックエンドの Cloud Run コンテナは Cloud NAT を使用して外部 IP アドレスを固定しています。複数デプロイした Cloud Run の外部 IP アドレスを同じものにするにはもう少し工夫が必要そうです。

本番リリースの方針

今の所あまりルールはないですが、基本的に以下の方針でリリースしています。

  • 週末、深夜、土日は避ける
    • ただしシステム停止が必要な機能は利用者の少ない週末などにリリースすることを検討する

Cloud Run は割といい感じにシャットダウンしてくれるので、日中にリリースすることが多いです。データベースのマイグレーションは手動で行なっています。データベースのマイグレーションを自動にした場合、思わぬ不具合が発生することがあるので念の為手動で行うようにしています。データベースのマイグレーションが伴う本番リリースについても、本番環境の影響を考慮しなくても良いと判断できるものについてはシステムを停止せずにリリースするようにしています。

フロントエンドに関しては、画面のみの軽微な修正などに関してはバックエンドのことを気にせずにリリースしています。APIスキーマ定義が変更になる場合などは先にバックエンドからリリースし、その後フロントエンドをリリースするようにしています。

GitHub の画面上でリリースタグを作成すると本番リリースされるようになっていて、リリースが完了すると Slack に通知が飛びます。フロントエンド、バックエンドをそれぞれ個別にリリース可能です。

リリースが完了すると Slack に通知されます

もっと簡単に、かつ、より安全にリリースしたいと考えているので、現在の monorepo 構成からフロントエンドとバックエンドで repository を分割した multi-repo にした方がいいのではないかという話を過去に行なっています。

monorepo で行くのか multi-repo にするのか

私たちのチームは、現在の一元化されたリポジトリ(monorepo)構成を維持するか、複数のリポジトリ(multi-repo)に分割するかを議論しています。どちらがベストかは明確ではないため、まずは新しいプロジェクトでmulti-repoを試すことにしました。

monorepo の利点には、チームの一体感、バックエンド開発者がフロントエンドの Pull Request を確認しやすいこと(もちろん、逆のパターンもあります)、そしてフロントエンドとバックエンドの両方が同じコミットで機能することを保証しやすいことがあります。

これらの点から、monorepo の継続も良い選択だと考えています。一方で、multi-repoにすると、フロントエンドチームはその領域に集中でき、バックエンドの変更がなければ気にせずリリースできるため、どちらが私たちに適しているかを検証したいと思います。

支払い.comのような技術的境界でチームが分かれている場合、フロントエンドとバックエンドを別々のリポジトリにするとリリースが容易になると考えています。しかし、実際に運用してみないと分からないこともあるため、様々な方法を試しながら、私たちに合った開発フローを見つけたいと思います。

トランクベース開発など、すでに確立された開発フローに基づいて進めつつ、細部を微調整して自分たちにとって最適な方法にする予定です。

開発フローやブランチ戦略などを考え直して分かったこと

まず、自分1人では思い付かないような改善提案がチームメンバーからたくさん出てくることがとてもいいなと思いました。2週間に1回やっている KPT でブランチ戦略の見直しやテスト環境を複数デプロイする案などが出てきており、実際に Try して改善できているので、今後も継続していきたいです。

ちなみに Firebase Hosting と Cloud Run の preview 環境デプロイはチームのエンジニアが積極的に動いて全部やってくれました!圧倒的感謝。

現在採用している Git-Flow を GitHub-Flow に変更するかどうかも検討しており、GitHub-Flow を採用するのであれば feature flag を使ってリリースできた方が良さそうなので、今後も開発体験を損なわず、かつ、リリース速度を落とすことなく継続的に改善していけたらいいなと思います。

こちらもモザイクだらけですが、KPT で上がってくる Problem の画像になります。Problem の中から厳選して Try を選ぶことが多いです。

毎回大量の Problem が出てくる KPT, Try は1-2個に絞ります

できていないこと

計測ができていないです。リリースしたことによりビジネス的にどのようなインパクトがあったのかをちゃんと計測する必要があると考えています。具体的にはリリースした機能によって売り上げがどれぐらい上がったのかや、顧客の利便性がどのぐらい上がったのかなどを定量的に計測したいと考えています。計測できていることも一部ありますが、できていないことの方が多いです。

また、システムのメトリクス収集については Google Cloud の Cloud Monitoring を使っているだけなので、システムの規模が大きくなってより詳細なメトリクスなどが必要になった場合は Datadog などの導入が必要かもしれません。

Cloud Run の場合は Open Telemetry などを導入しなくても Cloud Trace で API の処理単位で何にどれぐらい時間がかかっているのかを計測できるので、現状はそれで十分かなという状態です。Cloud Trace とは別に Cloud Monitoring のダッシュボードで CPU 使用率やメモリの使用量、コンテナインスタンス数などを確認しています。

支払い.comはマイクロサービスではないため、 Cloud Run の出力するログを Cloud Trace が解析し、それぞれの処理時間を出力することができています。今後新たにマイクロサービスを構築し、サービス間でトレースを行いたいなどのニーズが出てきた場合は Open Telemetry などのライブラリの導入が必要になりそうです。

それ以外にも、ガベージコレクションの回数など、 JVM の詳細なメトリクスを収集して計測したくなった際は Open Telemetry の導入を検討しようと思います。

プロジェクトの運営についても慣れが必要かなと感じています。エンジニアが増えたことにより調整と管理のコストが増えたので、管理や調整のコストを最小限に抑えつつ最速でプロダクトをリリースできるようにしていきたいと考えています。具体的な改善案などは振り返りの時に考えようと思います。

Cloud Trace で処理ごとの時間を表示できる

おわりに

人が増えて開発する機能が増えると今までそれほど問題ではなかったことが本格的な問題になるという話でした。scope ブランチから preview 環境へデプロイすることで複数環境でテストできるようになる見通しです。さらに人が増えたら別の問題が発生すると思いますが、継続的に改善していけたらいいなと思います。

このテックブログは、私一人の功績ではなく、チーム全体で継続的に改善してきた成果を紹介しています。チームや事業が成長する過程で直面した様々な課題について、リリース後の約2年間を振り返り、ここにまとめました。これからもチームとして成果を上げられるように頑張ります!

これは個人的な感想でもあるのですが、他の会社のテックブログなどを読むと強いエンジニアがいい感じの環境を整えて理想的な開発をできていそう・・というふうに見えることが多いです。このブログもそのような印象を持っていただけると嬉しいのですが、正直なところ、支払い. comの開発チームはできていないことや不十分なことはまだまだあります。このブログだけで全てを伝えることは非常に困難なので、もし気になることなどがありましたらお気軽にご連絡いただければと思っています。

UPSIDERはエンジニアを募集していますので、ご興味がありましたらカジュアル面談などのご応募をお待ちしております!

herp.careers

支払い.com のアジャイル開発を基本としたスクラムではない開発手法について

はじめに

UPSIDER の支払い.comでバックエンドエンジニアを担当している水村です。

リリース当初はビジネスサイドとエンジニアを合わせて5、6名だった支払い.comのチームですが、リリースしてから2年弱でビジネスサイドを含めると数十名規模になりました。

今回は、アジャイル開発を基本としていますが、スクラム開発ではない支払い.comの開発手法についてまとめました。

支払い.comのリリースフロー

2022年5月に正式スタートした支払い. comですが、2023年11月22日現在、本番環境へのリリース回数は394回に達しました。過去1年半の営業日を約370日と考えると、ほぼ毎日リリースしていることになります。毎日リリースが行われるわけではありませんが、時には1日に複数回リリースすることもあります。開発チームの人数が多くないにも関わらず、この頻度でリリースができていることは良い成果だと思います。

さらに、これまでのリリースは特に大きなバグも少なく、安定して行われています。たまに私が間違えてリリースすることもありましたが、そうしたやらかしは2回程度で、幸い本番環境への影響はありませんでした。翌日にリリースしようと思っていた軽微な修正を早めにリリースしてしまった、という感じです。

支払い.comはスクラム開発を採用していないので、スプリントがありません。ですが、アジャイル開発やスクラム開発の考え方を基本としています。機能が出来上がってテスト環境でテストして問題なければリリースという流れで本番リリースを行なっています。本番リリースまでのリードタイムを短くした方がメリットがあると考えているため、リリース準備が整った機能はどんどんリリースしています。これは和田卓人さんの「質とスピード」や「Lean と DevOps の科学」などから影響を受けています。

リリース頻度が高いことのメリット

良い意味で本番リリースの緊張がなくなることが最大のメリットだと感じています。リリース頻度が2週間に1回だったり、月に1回だった頃と比べても安心してリリースできている実感があります。小さな変更や機能追加を頻繁にリリースできるのでテストする範囲も狭いです。テストもすぐ終わります。いいことづくめなので頻繁にリリースしたくてたまらなくなってきます。

リリース頻度を高めるためにやっていること

当たり前のことすぎるので書く必要なさそうですが、GitHub Actions 上の CI でテストを自動化して CD で簡単にデプロイできるようにしてます。CI は早く終わったほうが嬉しいので定期的に見直して遅くなったら改善します。

Pull Request を作ると CI が自動で走る。テストは並列化されている。

本番リリースも GitHub Actions 上で実行されています。バックエンドの場合は Docker Image を作成して Artifact Registry に push して Cloud Run へのデプロイが実行されます。フロントエンドの場合は GitHub Actions 上で Next.js のビルドを実行し Firebase Hosting にデプロイします。

リリースについての基本的な考え方ですが、最小限の機能や変更を細かくリリースするようにしています。「GitLabに学ぶ 世界最先端のリモート組織のつくりかた」に書いてある考え方と近いかもしれません。この本は最近読みましたが、参考になることが多かったです。

スプリントをやらない理由

スプリント方式を採用しない理由は、主にリードタイムが長くなることにあります。スクラム開発では、1-2週間のスプリントを実施し、その期間内にユーザー受け入れテスト(UAT)まで完了しリリースできる状態にすることが目標です。しかし、実際には1つのスプリント内で UAT を完了させるのは難しく、多くの場合次のスプリントに持ち越されます。これにより、機能が本番環境にリリースされるまでには最短で1〜2週間の遅延が発生します。

さらに、スプリント内でリリース対象の機能が多い場合、テストと本番リリースが困難になることがあります。テストで多くのバグが見つかった場合、機能を元に戻したり(revert)、特定の機能を選んでリリースしたり(cherry-pick)する必要が出てきます。また、スプリントの期限に合わせて品質を犠牲にしたコードがマージされることもあり、これは開発の負債となり得ます。

確かにスプリント方式が有効なケースもありますが、現在の私たちの開発体制ではその必要性が低いと感じています。スプリントによって生じる遅延や複雑さを考慮すると、私たちにとって最適な方法ではないと判断しました。

よって、支払い.comではカンバンのような開発方式を採用してます。エンジニアの手が空いたら優先度の高いタスクを順番にやっていくようにしています。

そもそもなぜスプリントが必要なのかを考えると、ステークホルダに対して進捗を伝えやすいことが大きな理由だったりします。UPSIDERのように自社でサービスを運営している会社の場合、進捗に対して説明責任が必要な外部のステークホルダがあまりいないです。

開発の細かい進捗は支払い.comのチーム内で共有できていればよく、リリースタイミングは自分たちでほぼ自由に決めることができます。そのため、スプリントを採用する必要性は、一般的な環境とは異なるかもしれません。スプリントプランニングとは別に、顧客に何を提供したいのかを機能単位で判断して優先順位づけを行なっています。

プロダクトバックログ

以前はプロダクトバックログGitHub Projects で管理していたのですが、エンジニア以外のメンバーが見づらかったりしたので notion に移行しました。notion に移行するにあたってLayerX さんのテックブログが参考になりました。ありがとうございます!

モザイクだらけですが、要件と仕様とタスクを notion で管理しています

プロダクトバックログ上で優先度の高いものから要件と仕様を詰めていきます。ビジネスサイドの希望するリリース日が実現可能かどうかの見積もりをエンジニアが行います。見積もり精度はあくまで目安であり、70%の確率で2-3日で完了、90%の確度で4-5日といったブレがあるので、見積もり段階で決定したリリース日からずれることが想定されます。

個人的には見積もり段階でいつ頃リリースできそうかを判断し、実装途中でさらに精度が上がってくると思うので、途中経過を報告していつ頃リリースできそうかをビジネスサイドと調整するのがいいのかなと考えています。また、見積もりはあくまで見積もりであり、コミットメントではないという前提で考えています。

リリース日をどうやって決定するのかは現在改善を行なっている最中です。ビジネスサイドの要望としてはなるべく早い段階でいつリリースできるのかを知りたいのですが、エンジニアとしては見積もりや実装途中までやってみないとなんとも言えない部分があるので、ビジネスサイドとエンジニアのお互いにとってどのあたりで合意を取るのが最適なのかを模索しています。

おわりに

支払い.comは元気な20代前半の若者が PjM, PdM, PMM を担当しています。エンジニアチームも20代中盤から40代、元 SIer や受託開発経験者など、多様性があります。最近の若者は非常に優秀で、めちゃくちゃ優秀ですごいな・・というのを感じながら若者の成長を見守りつつ、顧客にとって価値のある機能を最速でリリースできるように努めてまいりますので、よろしくお願いいたします。

UPSIDERはエンジニアを募集していますので、ご興味がありましたらカジュアル面談などのご応募をお待ちしております!

herp.careers

参考文献

アジャイル開発やスクラム開発についての考え方は以下の本などを参考にしています。

アートオブアジャイルデベロップメント

エッセンシャルスクラム

エクストリームプログラミング

アジャイルな見積もりと計画作り

アジャイルサムライ

アジャイルレトロスペクティブズ 強いチームを育てる「ふりかえり」の手引き

(冊子版)ふりかえり読本 実践編~型からはじめるふりかえりの守破離~

Lean と DevOps の科学

アートオブプロジェクトマネジメント