every Tech Blog

株式会社エブリーのTech Blogです。

Android で性別に応じて文法を変更する方法について

この記事は every Tech Blog Advent Calendar 2024 13 日目の記事です。

はじめに

こんにちは、DELISH KITCHEN でクライアントエンジニアを担当している kikuchi です。

普段会話をする際に、話す相手は誰か、言及する対象は人であるか物であるか、性別はどうか、といった様々な情報から微妙にニュアンスを変えて話すことがありますが、
もしアプリでユーザの特性によって文言を出し分ける、というような機能を実装する場合は、条件分岐が複雑化するなど多くの手間がかかってしまいます。
今回はそのユーザの特性の中でもユーザの性別 (文法上の性別) によって、簡単にアプリ上で表示する文言を切り替えることができる Grammatical Inflection API という機能を紹介したいと思います。

Grammatical Inflection API を使用することで、性別による複雑な条件分岐を実装する手間を省くことができます。

文法上の性別とは

一言で「文法上の性別」と記載しても少し分かりづらいと思いますので、具体例を記載したいと思います。

Android Developer サイト で本件について「フランス語でサービスに登録されていることをユーザに知らせるメッセージの例」があるため引用します。

  • Masculine-inflected form: 「Vous êtes abonné à...」 (English: 「You are subscribed to...」)
  • Feminine-inflected form: 「Vous êtes abonnée à...」 (English: 「You are subscribed to...」)
  • Neutral phrasing that avoids inflection: 「Abonnement à...activé」 (English: 「Subscription to ... enabled」)

この様に英語では文法が変わりませんが、フランス語では微妙に文法が変わっている (「abonné」と「abonnée」で差異がある) ことが分かります。
日本語でも「あなたは〇〇に登録しています」といった統一の文法になると思いますが、上記の様に文法上の性別への対応が必要な言語も存在するため、
多言語対応をする場合は言語に合わせて適切な文法を設定することがユーザに対しての適切なアプローチとなります。

Grammatical Inflection API の概要

こちらの API で提供される機能は大きく分けて 2 つあります。

  1. 文法上の性別を選択する
  2. 性別によって文字列のリソースを分ける

まず 1 についてですが、こちらで選択できる性別の修飾子は

  • 女性的 (feminine)
  • 男性的 (masculine)
  • 中性的 (neuter)

の 3 つが存在します。

そして 2 についてですが、Android は以前から言語で文字列のリソースを分けることができますが、そこに更に性別でもリソースを分けることができる様になります。
先程のフランス語を例にすると

  • フランス語、かつ女性的 : res/values-fr-feminine/strings.xml
  • フランス語、かつ男性的 : res/values-fr-masculine/strings.xml
  • フランス語、かつ中性的 : res/values-fr-neuter/strings.xml

といった分け方ができ、例えば 1 の機能で女性的 (feminine) を選択していると、res/values-fr-feminine/strings.xml のリソースから文字列が読み込まれる様になります。

なお、本 API ですが、

  • Android 14 以降の端末のみサポートされる
  • Android Studio Giraffe Canary 7 以降の環境のみ、性別のリソースの修飾子 (values-fr-masculine の masculine の部分) がサポートされる

といった制約があるため、事前に対象の OS を絞る、開発環境を新しくするといった対応が必要となります。

実装方法

本項目では具体的な実装方法について説明したいと思います。

文法上の性別を選択する

文法上の性別を選択する API の実装方法について説明します。

まずは AndroidManifest.xml で API を実施する Activity に宣言を追加します。

<activity
    android:name=".TestActivity"
    android:configChanges="grammaticalGender"  ← こちらを追加する
    android:exported="true">
</activity>

そして次に性別を選択する API を以下の様に実装します。

val gIM = getSystemService(requireContext(), GrammaticalInflectionManager::class.java)
gIM?.setRequestedApplicationGrammaticalGender(Configuration.GRAMMATICAL_GENDER_FEMININE)

GrammaticalInflectionManager のサービスにアクセスし、setRequestedApplicationGrammaticalGender メソッドを呼ぶのみとなります。
こちらで指定できる値は

  • Configuration.GRAMMATICAL_GENDER_FEMININE : 女性的
  • Configuration.GRAMMATICAL_GENDER_MASCULINE : 男性的
  • Configuration.GRAMMATICAL_GENDER_NEUTRAL : 中性的

となります。

本 API ですが、アプリの初回起動時のアンケート、あるいは設定画面などで性別を選択する UI を用意し、ユーザが選択したタイミングで性別を選択する API を実行する、といった使い方ができるかと思います。

なお、選択した性別を取得する API は以下の様に実装します。

val gIM = getSystemService(requireContext(), GrammaticalInflectionManager::class.java)
val grammaticalGender = gIM?.applicationGrammaticalGender

こちらも選択のケースと同様で、GrammaticalInflectionManager サービスにアクセスし applicationGrammaticalGender で値を取得するのみとなります。

性別によって文字列のリソースを分ける

次に性別によって文字列のリソースを分ける方法ですが、こちらは特にコードを書く必要はありません。
概要で説明した通り、性別のリソースファイルを用意するのみとなります。

●res/values-fr/strings.xml (言語のリソースファイル)
<resources>
    <string name="test">test</string>
</resources>

●res/values-fr-feminine/strings.xml (女性的のリソースファイル)
<resources>
    <string name="test">test_feminine</string>
</resources>

●res/values-fr-masculine/strings.xml (男性的のリソースファイル)
<resources>
    <string name="test">test_masculine</string>
</resources>

●res/values-fr-neuter/strings.xml (中性的のリソースファイル)
<resources>
    <string name="test">test_neuter</string>
</resources>

文法上の性別を選択する API で Configuration.GRAMMATICAL_GENDER_FEMININE を設定していた場合、test の識別子のリソースにアクセスすると
女性的のリソースファイルにアクセスするため、「test_feminine」という文字列が取得できる様になります。
なお、文法上の性別を選択する API を実行していない場合は言語のリソースファイルにアクセスするため「test」という文字列が取得できます。

実装としては以上となります。

注意点

言語のリソースファイルには全てのリソースの識別子が網羅されており、性別によって変化させたい文字列のみ性別のリソースファイルに定義する必要があります。
また言語のリソースファイルに定義されていないリソースの識別子を性別のリソースファイルに定義はできません。

具体例を記載します。

●res/values-fr/strings.xml (言語のリソースファイル)
<resources>
    <string name="test">test</string>
</resources>

●res/values-fr-feminine/strings.xml (性別のリソースファイル)
<resources>
    <string name="test">test_feminine</string>
</resources>

●res/values-fr-masculine/strings.xml (性別のリソースファイル)
<resources>
    <string name="test_aaa">test_masculine</string>
</resources>

このようなケースの場合、

  • values-fr-masculine に 「test」が無いが、values-fr に定義されているのでエラーにはならない (values-fr の「test」が読み込まれる)
  • values-fr-masculine に 「test_aaa」が定義されているが、values-fr に定義されていないためエラーになる

となるため、エラーを回避する場合は values-fr にも test_aaa を定義する必要があります。
こちらは大本のリソースファイルと言語のリソースファイル (values/strings.xml と values-fr/strings.xml) の関係と同様のルールとなります。

また、values-fr-neuter が存在しませんが、values-fr が存在しているのでエラーとはなりません。

まとめ

翻訳自体はかなり専門的な知識が必要となりますが、私自身海外のアプリを使用する際は翻訳が雑だと怪しいと感じ、逆に翻訳が行き届いていると丁寧なアプリだと感じることがあるので、
こういった細かい部分を丁寧に対応することがユーザの獲得、継続利用に繋がると考えています。

また日本語の場合でも、性別によって言葉を変えることでよりターゲットを絞った訴求ができることも考えられるため、一度この機能の導入を検討してみてはいかがでしょうか。

iOSプロジェクトからApolloを削除した話 - GraphQLクライアントの自前実装への移行

はじめに

この記事はevery Tech Blog Advent Calendar 2024の12日目の記事です。

DELISH KITCHENのiOSアプリ開発を担当している池田です。今回はiOSプロジェクトでのGraphQLクライアントをApollo iOSから自前実装へ移行した経験についてお話しします。

背景

DELISH KITCHENのAPIの一部でGraphQLを利用しており、開発効率向上のためにApollo iOSを導入していました。これにより、GraphQLの利用をより簡単に行える環境を整えていました。導入時の詳細については以下の記事をご参照ください。

tech.every.tv

GraphQLについて

GraphQLでは、必要な情報だけを取得できたり複数のエンドポイントのリクエストをひとつにまとめたりできる柔軟なデータ取得が特徴です。クライアント側で必要なデータを宣言的に指定できるため、データの過不足なく効率的な通信が可能になります。一方で、新しい技術仕様の習得やクエリ設計のベストプラクティスの理解など、学習コストが比較的高いことが課題として挙げられます。

Apollo iOSを使う利点

Apollo iOSには主に以下の利点があります:

  • GraphQLのスキーマとクエリからSwiftコードを自動生成できることによる開発効率の向上
  • クライアントサイドでのキャッシュ管理の簡略化とパフォーマンスの最適化
  • 型安全性の保証による実行時エラーの防止

DELISH KITCHENでは、特にコードの自動生成による開発効率向上を目的としてApollo iOSを導入していました。

自前実装の経緯

Apollo iOSは、コード自動生成機能により当初の目的であった開発効率化を実現していました。しかし、DELISH KITCHENのAPIの大半はRESTfulで、GraphQLの利用は限定的であったため、実際の効率化の効果は想定を下回っていました。

さらに、今後もGraphQLの利用を積極的に拡大しない方針が決定されたことで、Apollo iOSを維持するコストが相対的に高くなってきました。具体的には以下の課題が浮き彫りになりました:

  • iOSプロジェクトのApollo iOSへの依存関係の管理
  • コード自動生成に必要な非Swiftファイルの維持
  • 自動生成されたファイルによるプロジェクト管理上の複雑さ

これらの状況を踏まえ、Apollo iOSへの依存を解消し、必要最小限の機能に特化したGraphQLクライアントを自前で実装することを決定しました。

GraphQLクライアントの自前実装

GraphQLは本質的にはHTTP POSTリクエストであり、適切なリクエストボディを構築することで実装が可能です。ここでのクエリ文字列自体はApollo iOS使用時と同様にスキーマを元に構築する必要があります。以下に基本的な実装例を示します:

// GraphQLのエンドポイントURL
let url = URL(string: "https://api.example.com/graphql")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

// スキーマを元に構築したGraphQLのクエリGraphQLのクエリ
let query = """
    mutation CreateOrder($productId: ID!, $quantity: Int!, $shippingAddress: AddressInput!) {
      createOrder(productId: $productId, quantity: $quantity, shippingAddress: $shippingAddress) {
        orderId
        totalPrice
        estimatedDeliveryDate
        status
      }
    }
    """

// 変数の定義
let variables: [String: Any] = [
    "productId": "prod_123456",
    "quantity": 2,
    "shippingAddress": [
        "street": "123 Main St",
        "city": "Tokyo",
        "postalCode": "100-0001",
        "country": "Japan"
    ]
]

// リクエストボディの構築
let body: [String: Any] = [
    "query": query,
    "variables": variables
]

// リクエストの実行
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)

do {
    let (data, response) = try await URLSession.shared.data(for: urlRequest)
    
    // レスポンス処理
    if let httpResponse = response as? HTTPURLResponse {
        switch httpResponse.statusCode {
        case 200:
            let json = try JSONSerialization.jsonObject(with: data)
            print("注文が成功しました:", json)
        default:
            print("エラーが発生しました。ステータスコード:", httpResponse.statusCode)
        }
    }
} catch {
    print("リクエストエラー:", error)
}

このように、GraphQLクライアントの基本的な機能は標準的なネットワーキング処理で実装することができます。実際のプロジェクトでは、この基本実装をベースに、既存のRESTful APIクライアントと同じインターフェースで利用できるよう設計し、ネットワーキング層の実装を共通化しました。

おわりに

今回は、Apollo iOSから自前実装への移行について紹介しました。GraphQLの利用範囲や開発方針に応じて、時にはサードパーティライブラリへの依存を見直し、シンプルな実装へ移行することも選択肢のひとつとなり得ることが分かりました。

この記事が、同様の課題に直面している開発者の方々の参考になれば幸いです。

小売アプリのシステム移管事例紹介

はじめに

この記事は every Tech Blog Advent Calendar 2024 11 日目の記事です。

こんにちは。DELISH KITCHEN 開発部 RHRA グループ所属の池です。

2024年6月、エブリーは5つの小売アプリの運営について事業譲渡を受け、『 retail HUB 』へ移管しました。 移管してから半年間、引き継ぎ元の企業様からサポートをいただきながら、システムの移管と運営を行ってきました。

システムの移管は、システムを構成する各種サービス・ツールの公式の移管手順に従って基本的には行いますが、中には記述が不明瞭な場合もあり、試行錯誤が必要でした。

本記事では、事業譲渡を受けた小売アプリのシステム移管作業をまとめ、移管作業における注意点もあわせて紹介します。 同じようにシステムを引き継ぐ機会がある方々の参考になれば幸いです。

また、引き継ぎ作業の前段階で行ったシステムデューデリジェンスについても先日別の記事にて紹介しておりますので、もし興味があればご覧ください。

tech.every.tv

移管対象のシステムについて

事業譲渡を受けた小売アプリのシステムは、iOS/Androidのネイティブアプリと、入稿管理画面の Web アプリケーションサーバー、アプリ向け API サーバー、それらを構成するシステム(AWS環境、ロードバランサー、データベース、バッチサーバーなど)です。

これらは以下を利用して構築され、それぞれが移管対象となります。

  • システム
    • GitHub リポジトリ
    • AWS アカウント
    • Firebase プロジェクト
    • Google Analytic プロパティ
    • ドメイン
    • App Store Connect アプリ
    • Google Play Console アプリ
  • 外部サービスの契約
    • PUSH通知サービス
    • システム監視サービス
    • WAFサービス
    • SMSサービス
  • その他
    • ドキュメント
    • Backlog

また、移管にあたっての前提ですが、移管元の企業様はエブリーへの事業継承後も引き続き一部の開発を担う契約となっているため、システム移管後も開発が継続できる状態を保つことが前提となります。

そのため、各種移管において、ユーザーアカウントの権限設定やユーザー自体の移管も含めて検討・作業を行う必要があります。以下の内容はその前提を踏まえた作業内容および注意点としてお伝えします。

それでは、それぞれのシステムに関する移管作業まとめていきます。

GitHub リポジトリ の移管

GitHib リポジトリの移管は公式手順にて確認できますが、公式手順の他にもいくつかの考慮事項がありました。

  • エブリーのGitHubに招待する移管元開発者の権限設定
    • 移管対象のリポジトリのみを閲覧操作できるような権限設定が必要
  • 移管に伴う影響の確認

上記を踏まえて、GitHubリポジトリの移管は次の手順で行いました。

  1. 移管元企業様にGitHubリポジトリと連携している外部サービスを洗い出していただく
  2. 事前に移管元の開発者を移管先のGitHub組織に招待し、移管対象のリポジトリだけを閲覧できるように設定したTeamに所属させておく
  3. 移管元GitHubにてリポジトリの移管を行う
    • リポジトリの Settings > Transfer から移管先のGitHub組織名を入力して移管を行う
  4. 移管先GitHubにてリポジトリが移管されたことを確認する
  5. 移管されたリポジトリを対象のTeamに所属させる
  6. 1.で洗い出した外部サービスにおいて、GitHubと再接続させる

注意点

注意点は2つあります。

  1. 移管対象のリポジトリ自体に直接属しているGitHubアカウントが合わせて移管される
  2. リポジトリを参照しているサービスとの連携が切れる

1点目について、GitHub組織のシートに空きがある場合、移管元のリポジトリ自体に直接属しているGitHubアカウントも合わせて移管されてしまいます。

私たちもそのことを正しく把握できていなかったため、移管後に意図しない引き継ぎ元企業様のGitHubアカウントがエブリーのGitHub組織に属している状態になっており、費用が余分に発生していました。幸いにも権限設定を正しく管理していたため、他のリポジトリを閲覧操作できることはなかったのですが、組織自体の権限設定次第では見せてはいけないリポジトリが閲覧操作できてしまう可能性があるので、注意が必要です。

2点目について、移管を行うと、GitHubリポジトリを参照している外部サービスにおいて参照できない状態になるため、再接続するまで使えなくなります。

私たちのシステムでは AWS CodePipeline で GitHub リポジトリと接続しており、移管後に再接続が必要でした。もし他にも接続しているサービスがあれば再接続が必要になるので、移管前の準備として接続しているサービスやツールを洗い出して影響範囲を確認しておく必要があります。

AWS アカウント の移管

前提として、移管対象のAWSアカウントは 移管元企業様の AWS Organizations に各AWSアカウントが所属おり、それらをエブリーの AWS Organizations に属する形へと移管を行います。

AWS Organizations 間におけるAWSアカウントの移管はこちらの手順にて確認できます。

公式手順以外には以下のようなことが考慮事項でした。

  • AWSアカウントの所有権の譲渡
  • 移管後にAWSアカウントを利用する移管元開発者の権限設定

まず、移管の前段でAWSアカウントの所有権の譲渡を行いました。所有権の譲渡が完了してしまえば、後の作業は全て弊社内の作業にて完結するようになるので、先に移しておくと作業しやすくなります。

所有権の譲渡については、今までは譲渡同意書をAWSに提出する必要があったようですが、今年から譲渡同意書は不要になりました。 譲渡要件はこちらで確認ができます。今回の作業では以下のアカウント情報を更新することで所有権を譲渡できました。

  • アカウント設定
  • 連絡先情報
  • 支払いの詳細設定

所有権をエブリーに移した上で、権限設定の考慮事項を踏まえて AWS Organizations 間におけるAWSアカウント移管の作業を以下の手順で行いました。

  1. 移管元の開発者を移管対象のAWSアカウントごとにIAMユーザーとして作成(管理上の都合で各AWSアカウントごとにそれぞれIAMユーザーとして作成)
    • 最小限の権限となるようにポリシー設定を行う
  2. 移管先の AWS Organizations にてAWSアカウントの招待を行う
    • 招待の画面にて、移管対象のAWSアカウントIDを入力することで招待を行える
  3. 招待したAWSアカウントに設定されているメールアドレスに招待メールが届き、招待を承認する
  4. 移管したAWSアカウントにSSOログインできるように設定を行う

以上でAWSアカウントの移管は完了です。

注意点

AWSアカウントの移管にあたっては、大きく注意すべきポイントはありませんでしたが、強いてあるとすれば、移管したタイミングで請求書が分かれるということです。 移管した月は請求書が2つに分かれるので、そのことを把握していないと片方の請求書を見落としてしまう可能性があるので、ご注意ください。

また、今回は各AWSアカウントごとにIAMユーザーを作成する形をとりましたが、移管元企業様が引き続き開発を行える状態をどのように維持するかについて、事前に十分に協議して擦り合わせておくことが大切です。

Firebase プロジェクト の移管

Firebase プロジェクトはGoogle Cloud プロジェクトと連携しているので、該当のGoogle Cloud プロジェクトの移管を行うことでFirebase プロジェクトを移管できます。

移管の前提として、今回移管したプロジェクトは組織に属していない「組織なし」のプロジェクトを、エブリー組織に属するよう形への移管となります。

組織なしのプロジェクトの移管はこちらの手順にて確認できます。 GitHubやAWSの移管と近しいですが、手順書以外の考慮事項は以下でした。

  • プロジェクトに設定されているIAMの整理
  • 作業者アカウントの権限準備

上記を踏まえて次の手順を移管作業を行いました。

  1. プロジェクトに設定されているIAMの整理
    • プロジェクトに設定されているIAMは移管時に移管先の組織に属するように設定されてしまうため、事前に全てのIAMを確認して整理します。
  2. 作業者のアカウント権限を準備
    • 移行作業を実施する Google Cloud アカウントは以下のような状態にする必要があります。
      • 移管対象プロジェクトのオーナー
      • 移管先の組織の管理者
  3. 移管の実施
    • Google Cloud コンソールからコマンド実行するためのターミナルを開き、移管コマンドを実行します。
// 移行コマンド
gcloud beta projects move <移管対象のPROJECT_ID> --organization <移管先のORGANIZATION_ID>

// プロジェクトが属しているORGANIZATION_IDを取得するコマンド。移管確認用。
gcloud projects describe <移管対象のPROJECT_ID> --format=json | jq -r ".parent"

注意点

注意点はGitHubリポジトリの移管と同様で、アカウントも一緒に移管されることです。想定しないアカウントが移管先の組織に属する形になることを防ぐため、移管前にIAMの整理を行うことが大切です。

Google Analytics プロパティ の移管

Google Analytics プロパティの移行手順は公式の[GA4] プロパティを移行する手順にて行いました。

手順の流れは Firebase プロジェクトの移管と同様で、以下のような流れでした。

  1. プロパティに属しているアカウントの整理
  2. 作業者のアカウント権限を準備
    • 移行作業を実施する Googleアカウントを以下のように権限設定する必要があります
      • 移管対象プロパティの管理者
      • 移行先のGAアカウントに対する管理者もしくは編集者
  3. 移管の実施
    • Google Analytics コンソール画面から公式の手順に沿って移管を実行することができます

注意点

まず、移管にあたって一つ理解が必要なのは、Google Analytics は GAアカウントに複数のGAプロパティが属する構造となっており、GAアカウントとGAプロパティはそれぞれで権限設定が行えるという点です。 GAアカウントとGAプロパティの言葉の違いを理解した上で、適切にそれぞれのユーザー権限設定を行うこと大事です。

また、注意点ではないですが、移管作業では以下の点について問題ないかどうかをドキュメントから正確に読み解くことが難しかったので、記録として残しておきます。

  • 過去のデータが引き継がれるか
  • 移管の実施はデータ収集に影響がないか

これらは問題ありませんでした。過去のデータは引き継がれ、データ収集も影響がないことを確認しました。

ドメインの移管

移管対象のドメインはDoレジで管理されており、エブリーで主に利用しているお名前.comへと移管しました。移管手順は他者からお名前.comへのドメイン移管に沿って比較的楽に実施することができます。

作業自体に関しては注意点は特にありませんが、作業に費用が発生するため、注意が必要です。

App Store Connect アプリ の移管

App Store Connect アプリについては移管はこれから実施を予定しており、現在計画立てを行っている段階です。 計画立ての途中ではありますが、現時点で以下のような影響がわかっています。

  • 移管実施に伴うユーザー影響
    • キーチェーンにデータ保存している場合に参照できなくなる
    • Sign in with Apple でログインできなくなる

譲渡における影響はアプリの譲渡の概要にて確認できます。

Google Play Console アプリ の移管

Google Play Console アプリについてもこれから移管する予定となっているので、本記事では割愛いたします。 移管作業が完了したらまた具体的な話をお伝えできればと思います。

まとめ

本記事では、システム移管の作業内容と、作業から得られた注意点を紹介しました。

移管するシステムの状態によって具体の作業内容は少しずつ異なってくると思うので、その点を踏まえてご参考にしていただければ幸いです。

最後までお読みいただき、ありがとうございました。

ISUCON14 に ISUポンサーの枠で出場しました

この記事は every Tech Blog Advent Calendar 2024 の 10 日目の記事です。

エブリーで小売業界に向き合いの開発を行っている @kosukeohmura です。

エブリーは ISUCON14 にて ISUポンサーとして協賛いたしました。社に 1 枠の参加確定枠を頂き、僕は社内で きょー と mbook と組んでチーム EveryBitCounts として出場する機会をいただけました。残念ながら最終スコアは 0 と惨敗でしたが、前日までの準備と当日のこと、それから反省について書きたいと思います。

tech.every.tv

前日までの準備

チームの 3 人はいずれも業務で Go を使うバックエンドのエンジニアですが ISUCON の参加や学習経験はありませんでした。前準備として社内の ISUCON 参加経験者を招き、概要の説明を受け、それから準備のための数時間のミーティングを数回組みました。

過去回の競技内容・レギュレーションを確認し、下記の攻略記事を読み合わせたところ、

isucon.net

  1. 計測結果を見て、問題を見出す。
  2. 比較的簡単に(見える)修正を施す。
  3. 改善されたことを確認する。

以上を繰り返すとそれなりの高得点を目指せそうだと捉えました。また上記の中で「計測結果を見て、問題を見出す。」ステップには比較的自信がなかったことから、練習用の t3.micro の EC2 インスタンスを立ち上げ学習を行いました。

具体的に行ったことを記します:

  • GitHub でのソースコード管理
    • 当日用のリポジトリを作成、当日必要となりそうなコマンドを Makefile 化しておく
    • RSA 鍵ペアを生成し設置。EC2 サーバー上で git push などが行えるようにする
  • デプロイ
    • EC2 サーバー上で git pull した後サーバーアプリをビルドし、各種サービスの再起動までを 1 コマンドで行えるように
  • MySQL スロークエリの表示
    • スロークエリログの有効化、しきい値の設定方法メモ
    • mysqldumpslow の結果の読み方の把握
  • alp での NGINX のアクセスログの集計
    • コマンドの実行、結果の読み方の把握
    • -m オプションを使っていい感じに結果を束ねて表示する
  • pprof の使い方
    • プロファイリング方法、プロファイリング結果の読み方の把握
  • 問題の見出し方
    • 計測結果から変更対象の箇所を特定するまでどのような思考を経るかを話し合う

当日

チーム全員起床に成功しオフラインで集まり作業しました。時系列でざっと流れを書きます。

開始〜12:00

まず技術要素(MySQL, NGINX, go-chi/chi, jmoiron/sqlx)を軽く確認し、公開されたドキュメントを読み込みました。この間に 1 人はソースコードを Git 管理したりベンチマーク計測したりと開発準備を行いました。結果、開始 1 時間以内には変更をデプロイできるようになり、チームのうち 2 人はドキュメントを一通り把握した状態となりました。

その後 mysqldumpslowalp での集計を行いつつ、以下 3 つの問題に目をつけて 3 人でそれぞれ分担・着手しました。

  1. GET /api/owner/chairs で椅子をリストアップするクエリが重いこと
  2. GET /api/app/nearby-chairs での処理が遅く、中身を見ると椅子全件取得後に椅子とライドでの多重ループで O(N * M) なクエリ発行がされていること
  3. GET /api/internal/matching の処理内容がランダムかつ椅子 1 つのみに対しての処理であり、適切な椅子とライドのマッチングができずスコア算出に大きく影響を及ぼしそうなこと

前日までの準備の甲斐あって、デプロイや計測ツールの準備については問題なく行えました。また問題箇所の特定についても(通知周りの問題を後回しにしてはいますが)おおよそ的を得た内容だったと思います。

この時点のスコア: 900 前後 (初期状態)

12:00〜14:00

昼食を取りつつ、それぞれの問題の解消に向けて処理を読み改修方針を考える時間でした。それぞれが別々の問題に取り組んでいるので会話もまばらになります。

僕は定期実行される GET /api/internal/matching でのマッチング処理の改善を担当していました。表には出ない Endpoint であり、マッチングできさえすれば好きに変えて良い(Endpoint 自体廃止しても良い)とのことで、いろんなことを考え方針策定に時間をかけていました。具体的には下記のような事を考えていました。

  • マッチングの間隔:
    • 初期状態では 500ms ごとの処理だが、この間隔はどの程度椅子の稼働率に影響があるのか?
  • ユーザーからの評価の上げ方:
    • 高い評価を得るほどにユーザーが増え結果スコアも伸びるが、距離の長いライドに遅い椅子がマッチすると到着が遅くなり評価が得られないということになるか。
    • むしろ長距離ライドに対しては速い椅子が確保できるまでマッチングを控えたほうが平均の評価としては上がるのか?
    • とはいえマッチングまでの時間も評価対象だし、、むしろ長距離過ぎるライドは無視するのが得策か?でも緯度経度に制限がない以上どこからを長距離としてもいいかわからないな、、?
  • 全体効率の最適化:
    • 椅子の性能(スピード)にかかわらず片っ端から近い順にマッチングさせたほうが全体としては効率が良くなるか?
    • 速い椅子をフル稼働状態にすることを優先し、遅い椅子の稼働は控えめにしたほうが高効率か?

結果、考えても良くわからなくなってきたので、メンバーと相談し

  • マッチング処理の定期実行を廃止。ライドが新規発生した際と評価完了した際、それとオーナーにより新しい椅子が有効化された際にそれぞれマッチング処理を実行するようにする
    • マッチング間隔の短縮は、ライドのマッチング時間短縮と椅子の稼働率向上それぞれに寄与する。よって短いに越したことはないだろうということで
  • マッチングの際、速い椅子を遅い椅子より優先的に使い、また乗車位置に近い椅子を遠い椅子より優先的に使うように
    • 評価の上げ方は細かくは非公開であり、また全体効率も考えてもわからないと判断し一旦の決め。試行錯誤前提のロジック

と方針を立てて実装を開始しました。この時点ではスコアは伸びていないものの、まっとうに方針を決められたことで、これからしっかり実装すればスコアを爆上げできるとと考えていました。

この時点のスコア: 1,000 前後

14:00〜16:00

立てた方針に沿って実装を行いました。変更箇所が思ったより多く、見立てより時間がかかりました。

この時間帯にはぼちぼち他のメンバーの修正が完了し、デプロイが行われはじめました。しかしデプロイしても期待した上がり幅が得られなかったり、FAIL し revert したりしている様子でした。

このとき、開発フロー面での課題が明らかになってきました:

  • 変更後の検証作業が直列でしか行えない
    • 3 人で作業しているにも関わらず、共通の Makefile などを main ブランチにコミットした後、そのままの成り行きで全員 main ブランチを使っている
    • ブランチと同様に、3 台のマシンが与えられたにも関わらず 1 台のみを 3 人で共用している
  • デプロイ環境整備を行ったメンバー 1 人のみが成り行きでサーバー上での作業役となり、他のメンバーのデプロイ作業の肩代わりやデプロイ順番待ちの管理をする羽目になっている
  • ベンチマーカーが FAIL した際に直接の原因 (何の Assert に失敗したのか) はわかるものの、根本原因を知る手段 (エラーログを見るなど) がなく確実な修正ができない

この時点で開発フローを変えようという気にはなりませんでしたが、開発フローの問題は最後までつきまといました。

細かな経緯は忘れましたが、主に ride_statuses テーブルにインデックスが貼られたことにより、スコアは倍増しました。

この時点のスコア: 2,120

16:00〜終了

16:00 過ぎに僕の担当していたマッチング処理の修正が完了しました。僕としてはいち早く適用しスコア爆上げと行きたかったところですが FAIL し、その原因が修正できず revert しました。

サーバーを全員で 1 台しか使っておらず、他の修正のデプロイ&ベンチマーカー実行の試行錯誤が盛んに行われていたことから、僕は担当していたマッチング処理の修正適用を諦め、せめてサーバーと RDB のマシンを分けようとしましたが、間に合いませんでした。

終了前の数十分は焦りが増す中でベンチマーカーが FAIL を示すようになり、git reset で変更を切り戻しては混んできたベンチマーカーに Enqueue し、祈り、FAIL し絶望するという辛い時間を過ごし、終了時間を迎えました。

最終スコア: 0

悲しい結果となりましたが、以降記憶が新しいうちに振り返ってみます。

良かったところ

準備の成果を十二分に発揮できました。変更を Git 管理し反映できる状態をスムーズに作れ、また alp 等を使った問題特定も早期かつ妥当に行うことができました。

反省

圧倒的素振り不足

一度でも本番相当の演習を行っておけば気付けるような開発フローの問題が露呈し、クリティカルな敗因となりました。社内で一番良い成績を取った別チームのメンバーも「問題を何度も解いた」と書いてますし、全員揃った状態実践を想定した演習を一度でも行っておくべきでした。

tech.every.tv

パフォーマンス改善には改修を伴う

何を当たり前のことと思われるかもしれませんが、ISUCON の勘所は問題の特定だと捉え、それさえ正しくできればその後の修正は普段の開発業務の延長であろうと高をくくっていました(これはチーム全体というより僕だけかもしれません)。

本番では問題特定には概ね成功したものの、それを解消するための変更に失敗しました。普段の開発ではローカルでの開発環境があり、気軽に単体テストを書き CI/CD に反映を委ねます。それがない状況でどのように改修を行うのかを真面目に検討できていませんでした。

担当の割り振り方

これは結果論かもしれませんが、一見関係の薄そうに見える処理についても元をたどると共通の問題に当たったりします。今回(気軽に相談は挟むものの)ハッキリと問題ごとに担当を分け作業をしたので、多くの表面的な問題の背後に存在する根本的な原因について相談し共通の意思決定に持っていくことができませんでした。分担するにしてもその単位を細かくするなど担当の割振りは改善の余地があると考えています。

具体的には chairs テーブルに最新の位置情報やマッチング状態が存在しないことが、ridesride_statuses, chair_locations テーブルとの JOIN やループ処理を発生させパフォーマンス劣化につながるという事態が複数箇所で見られたはずで、そこはチーム共通で指針を立てられたら良かったと思います。

ドキュメントを全員で読み合わせなかった

最初にデプロイの仕組みを整備したメンバーは、その後クエリのチューニング作業を担当し、そのまま実作業に入りました。その結果、そのメンバーはスコアの算出方法すら後半に知るような事態が起こりました。

複数人で協力して作業するにあたり、まずドキュメントを読み合わせ、不明点を解消し、何を目指していくのかの認識をざっと揃えておくことがその後の作業のスムーズさに大きく寄与すると感じました。

さいごに

反省点はたくさんありますが、ISUCON14 に楽しく参加することができました。他の方のリポジトリやブログ記事を読みたいと思っていますが、全然追いついていません。

またこの場を借りて運営の方々への感謝を申し上げます。Discord や公式ブログでのアナウンスもとてもわかりやすかったですし、当日のライブ配信も楽しく観覧しました。出題内容やドキュメント、当日の Dashboard も良くできており、ワクワクしながら競技に参加することができました。ありがとうございました。

当日の問題やドキュメントを含むリポジトリが公開されており、docker compose でも動かせるようですので時間を見つけてまた触ってみようと思います。

github.com

エブリーでは、ともに働く仲間を募集しています。不甲斐ない結果でしたが、少しでもエブリーに興味を持っていただけた方は、一度カジュアル面談にお越しください!

corp.every.tv

VercelのAI SDKを用いてストリーミング可能な動的UIを実現する

この記事は every Tech Blog Advent Calendar 2024 9 日目の記事です。

はじめに

こんにちは。DELISH KITCHEN開発部の村上です。

DELISH KITCHENでは、これまでの『レシピ動画アプリ』から『AI料理アシスタント』を目指すべく、これまで以上にAI領域に力を入れています。詳しくはこちらにも記載があるので、ぜひご覧ください。

AI/LLMでtoC向けサービスはどう変わるのか?『DELISH KITCHEN』は、「レシピ動画アプリ」から「AI料理アシスタント」へ

このAI活用は社内での業務改善にも進んでおり、直近でOpenAI APIを用いた社内システムの開発をする機会がありました。その中で今回はVercelのAI SDKを使う機会があったのでAI SDKを用いたストリーミング可能なUIをwebアプリケーション内で実現する方法を紹介します。

AI SDKとは

VercelのAI SDKはAI/LLMを用いたwebアプリケーション開発を支援するためのツールです。AI/LLMを用いた開発ではOpenAI, Claudeなど外部APIへの繋ぎこみやチャットUIの実装、チャット履歴の保存、ストリーミング機能、RAGの利用などの機能が求められたりしますが、これを全て自分で開発しようと思うと、たとえwebフレームワークを使っていてもかなり手間がかかってしまいます。AI SDKを利用することでこうした実装工数を削減し、より周辺の機能開発に時間を割くことができます。

現状AI SDKは以下の3つから構成されています。

  1. AI SDK Core

    • テキスト生成、構造化オブジェクトの生成、LLM(大規模言語モデル)を使用したツール呼び出しを行うためのプロバイダーに依存しない統一APIの提供
  2. AI SDK UI

    • チャットやその他ユーザーインターフェースを構築するためのツールの提供
  3. AI SDK RSC

    • React Server Components (RSC) を使用してユーザーインターフェースをストリーミングする機能。※現在は実験的な開発段階。

AI SDKは同じ開発元から出ているNext.jsはもちろんNuxtやSvelteなど他環境への対応もしていますが、AI SDK RSCはNext.jsのApp Routerだけをサポートしていたりするので、環境別で何が使えるかは公式を参照するのがおすすめです。

https://sdk.Vercel.ai/docs/getting-started/navigating-the-library#environment-compatibility

AI/LLMを用いたアプリケーションにおけるストリーミング機能

特にLLMを用いたwebアプリケーション開発をしていく場合、開発上ケアしておきたいポイントはいくつかありますが、その一つにストリーミング機能があります。

まず、単純な一問一答的なものを実現しようと思っても質問内容や回答によってはAPIでLLMを用いて回答を生成する段階でその出力までユーザーに待ち時間が発生してしまいます。さらに単純な1回のAPI呼び出しだけで終わればいいですが、多くの場合では複数のAPI呼び出しや処理のステップを経て、出力結果を作っていくのでその待ち時間はユーザー体験として無視できないものになってきます。

そこで身近なところであれば、ChatGPTでも全ての回答を生成し終わる前に回答の出力が段階的に行われ、ユーザーが体感する待ち時間を軽減していると思いますが、出力や処理の途中でもユーザーにフィードバックできるようなストリーミング機能が求められます。

AI SDKではAI SDK RSCの中でその機能をサポートしているので以降ではいくつかの種類に分けて機能を紹介していきます。

テキストの出力結果をストリーミングで表示する

Server

Server側ではまず createStreamableValue でServerからClientにストリーミングで送るためのデータの格納先を準備し、streamText を使ってOpenAI APIなどProviderからストリーミングされた出力結果で更新します。

'use server';

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from 'ai/rsc';

export async function generate(input: string) {
  const stream = createStreamableValue('');

  (async () => {
    const { textStream } = streamText({
      model: openai('gpt-4o-mini'),
      prompt: input,
    });

    for await (const delta of textStream) {
      stream.update(delta);
    }

    stream.done();
  })();

  return { output: stream.value };
}

Client

Client側ではServer側が createStreamableValue で生成されたデータを readStreamableValue を用いることで簡単に読み取ることができるので受け取ったものを処理するhooksを定義します。

import { StreamableValue, readStreamableValue } from 'ai/rsc'
import { useEffect, useState } from 'react'

export const useStreamableText = (
  content: string | StreamableValue<string>
) => {
  const [rawContent, setRawContent] = useState(
    typeof content === 'string' ? content : ''
  )

  useEffect(() => {
    ;(async () => {
      if (typeof content === 'object') {
        let value = ''
        for await (const delta of readStreamableValue(content)) {
          if (typeof delta === 'string') {
            setRawContent((value = value + delta))
          }
        }
      }
    })()
  }, [content])

  return rawContent
}

表示するコンポーネント側ではServerからの結果を上記で定義した useStreambleText を使って表示するだけで簡単に実現できます。

'use client';

import { useState } from 'react';
import { generate } from '@/lib/actions';
import { useStreamableText } from '@/lib/hooks';
import { StreamableValue } from 'ai/rsc';

export default function QuestionAnswer() {
  const [answer, setAnswer] = useState<string | StreamableValue<string>>('');

  return (
    <div>
      <button
        onClick={async () => {
          const { output } = await generate('簡単に作れるお弁当レシピを教えてください。');
          setAnswer(output);
        }}
      >
        Ask
      </button>

      {answer && <AssistantMessage answer={answer} />}
    </div>
  );
}

export function AssistantMessage({
  answer,
}: {
  answer: string | StreamableValue<string>;
}) {
  const text = useStreamableText(content);

  return (
    <div>
      {text}
    </div>
  );
}

オブジェクトの出力結果をストリーミングで表示する

Server

Server側では、同じように createStreamableValue を利用するところは同じですが、ここではオブジェクト形式の出力に対応する streamObject を利用して、プロバイダーから逐次送信される構造化されたデータで更新します。AI SDKではOpenAI APIのStructured Outputsにも対応しているので、 structuredOutputs パラメーターで指定します。

'use server';

import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from 'ai/rsc';
import { z } from 'zod';

export async function generateObject(input: string) {
  const stream = createStreamableValue({ answer: '', quotation_links: [] });

  (async () => {
    const { objectStream } = streamObject({
      model: openai('gpt-4o-mini', {
        structuredOutputs: true,
      }),
      schema: z.object({
        answer: z.string(),
        quotation_links: z.array(
          z.object({
            title: z.string(),
            link: z.string(),
          })
        ),
      }),
      prompt: input,
    });

    for await (const delta of objectStream) {
      stream.update(delta);
    }

    stream.done();
  })();

  return { output: stream.value };
}

上記の例では、objectStream には常に { answer: '', quotation_links: [] } の形式が保たれた形で随時そのテキスト情報や配列要素が追加されていくので、特に複雑な加工処理をすることなく、Client側で利用可能な状態になります。

Client

Client側では、テキストをストリーミングする時と同じように readStreamableValue を使用してストリーミングされたオブジェクトを受け取り、動的に更新します。

import { StreamableValue, readStreamableValue } from 'ai/rsc';
import { useEffect, useState } from 'react';

type AnswerObject = {
  answer: string;
  quotation_links: { title: string; link: string }[];
};

export const useStreamableObject = (
  content: AnswerObject | StreamableValue<AnswerObject>
) => {
  const [rawContent, setRawContent] = useState<AnswerObject | null>(
    typeof content === 'object' && !('subscribe' in content)
      ? content
      : { answer: '', quotation_links: [] }
  );

  useEffect(() => {
    (async () => {
      if (typeof content === 'object' && 'subscribe' in content) {
        let value: AnswerObject | null = null;
        for await (const delta of readStreamableValue(content)) {
          if (typeof delta === 'object') {
            setRawContent((value = { ...value, ...delta }));
          }
        }
      }
    })();
  }, [content]);

  return rawContent;
};

定義した useStreamableObject を使用して、ストリーミングで受け取ったオブジェクトデータを表示します。

'use client';

import { useState } from 'react';
import { generateObject } from '@/lib/actions';
import { useStreamableObject } from '@/lib/hooks';
import { StreamableValue } from 'ai/rsc';

type AnswerObject = {
  answer: string;
  quotation_links: { title: string; link: string }[];
};

export default function ObjectDisplay() {
  const [answer, setAnswer] = useState<
    AnswerObject | StreamableValue<AnswerObject> | null
  >(null);

  return (
    <div>
      <button
        onClick={async () => {
          const { output } = await generateObject('環境問題に関する最新のレポートを教えてください。');
          setAnswer(output);
        }}
      >
        Ask
      </button>

      {answer && <AssistantObjectMessage answer={answer} />}
    </div>
  );
}

export function AssistantObjectMessage({
  answer,
}: {
  answer: AnswerObject | StreamableValue<AnswerObject>;
}) {
  const data = useStreamableObject(answer);

  return (
    <div>
      {data ? (
        <div>
          <p>{data.answer}</p>
          <ul>
            {data.quotation_links.map((item, index) => (
              <li key={index}>
                <a href={item.link} target="_blank" rel="noopener noreferrer">
                  {item.title}
                </a>
              </li>
            ))}
          </ul>
        </div>
      ) : (
        'Loading...'
      )}
    </div>
  );
}

オブジェクト形式のストリーミングのメリットは上記のようにそれぞれ異なる要素に対して個別のスタイリングや処理を行うことができる点です。今までのテキストでのストリーミングは表現方法が限定的になるか、やろうと思ってもテキスト情報を変換するような複雑な処理をしないといけず不安定になってしまいますが、オブジェクト形式で受け取れることによって、アプリケーションによって独自の見せ方が可能になり、自由度が上がりました。

処理に合わせてUI自体をストリーミングで表示する

Vercelではテキストやオブジェクトだけではなく、UI自体をストリーミングすることができます。この機能を使うことでテキストやオブジェクトだけではなく、LLMの回答結果をUIで表示することも可能になります。 今回は、AIアプリケーションでありがちな回答を出力するまで進捗を表示するUIを例に紹介します。

一番最初に利用した AssistantMessage と以下の WorkflowProgress コンポーネントをServerとClientでやりとりします。

進捗を表示するWorkflowProgressコンポーネント

export function WorkflowProgress({ workflowSteps }) {
  const completedSteps = workflowSteps.filter((step) => step.status === 'completed').length;
  const totalSteps = workflowSteps.length;
  const progressValue = (completedSteps / totalSteps) * 100;

  return (
    <div className="p-4 bg-gray-100 rounded-lg shadow">
      <h3 className="font-semibold text-lg">Progress</h3>
      <progress value={progressValue} max="100" className="w-full mb-2"></progress>
      <ul>
        {workflowSteps.map((step) => (
            <li key={step.id} className="mb-2">
            <strong>{step.name}:</strong> {step.status}
            </li>
        ))}
      </ul>
    </div>
  );
}

Server

サーバー側では createStreamableUI を利用して、ストリーミングするコンポーネントを追加、更新することができます。今回はAI SDKの機能紹介がメインのため、step毎の具体的な処理については省略します。

'use server';

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableUI } from 'ai/rsc';
import { WorkflowProgress } from '@/components/WorkflowProgress';
import { AssistantMessage } from '@/components/AssistantMessage';

export async function generateWithSteps(input: string) {
  const workflowSteps = [
    { id: 'step1', name: '質問を解析中', status: 'in-progress', tasks: [] },
    { id: 'step2', name: '探索方法を検討', status: 'pending', tasks: [] },
    { id: 'step3', name: '関連データを取得', status: 'pending', tasks: [] },
  ];

  const displayUI = createStreamableUI(
    <WorkflowProgress workflowSteps={workflowSteps} />
  );

  (async () => {
    // Step 1: 質問を解析
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[0].status = 'completed';
    workflowSteps[1].status = 'in-progress';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // Step 2: 探索方法を決定
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[1].status = 'completed';
    workflowSteps[2].status = 'in-progress';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // Step 3: データを取得
    await new Promise((resolve) => setTimeout(resolve, 2000));
    workflowSteps[2].status = 'completed';
    displayUI.update(
      <WorkflowProgress workflowSteps={workflowSteps} />
    );

    // 結果を生成
    const { textStream } = streamText({
      model: openai('gpt-4o-mini'),
      prompt: input,
    });

    let generatedText = '';
    for await (const delta of textStream) {
      generatedText += delta;

      // 進捗状況のコンポーネントから回答結果のコンポーネントに変える
      displayUI.update(
        <AssistantMessage answer={generatedText} />
      );
    }

    displayUI.done();
  })();

  return { display: displayUI.value };
}

Client

クライアント側では、定義したアクションを呼び出し、その結果をそのまま表示することで簡単に動的なUIが作れます。実際の挙動はまず最初にProgressを表示し、回答結果がLLMから出力され始めるとProgressは非表示になり、回答が生成されていくような形になります。

'use client';

import { useState } from 'react';
import { generateWithSteps } from '@/lib/actions';

export default function DynamicProgressDisplay() {
  const [display, setDisplay] = useState<React.ReactNode | null>(null);

  return (
    <div>
      <button
        onClick={async () => {
          const { display } = await generateWithSteps('環境問題に関する最新のレポートを教えてください。');
          setDisplay(display);
        }}
        className="mb-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        Ask
      </button>

      {display}
    </div>
  );
}

最後に

今回はVercelのAI SDKを使って、いくつかの手法でストリーミング可能なUIをアプリケーション上で実装する方法を紹介しました。この他にも会話履歴の保存やその状態の管理を簡単に扱えるように豊富な機能が提供されています。AI SDK RSCはまだベータ的な位置付けですが、webでLLMを使ったアプリケーションをする際には比較的簡単にリッチなアプリケーションが作れるので、ぜひ社内ツールや簡単に動くものを作りたい場合にはVercelのAI SDKを使ってみてください。

冒頭でも紹介したようにDELISH KITCHENではこれまでの『レシピ動画アプリ』から『AI料理アシスタント』への変化を起こそうとしています。ぜひ、この取り組みに興味を持った方は一度カジュアル面談でお話しましょう!

corp.every.tv