every Tech Blog

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

RAGでOpenAI APIのStructured Outputsを使い倒す

こんにちは。
開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。

昨今の生成AIはどんどん新しいものが生まれ、日々キャッチアップを欠かせない日々を過ごしております。 9月もo1モデルが発表されましたが、今回はそちらではなく、8月に発表されたStructured Outputsを活用した取り組みについてご紹介します。

Structured Outputsとは

openai.com

Structured Outputsとは、モデルによって生成された出力が、開発者が提供する JSON スキーマと完全に一致するように設計された新機能です。
今までもJson ModeやFunction Callingを利用することで、構造化した出力を得ることはできていたのですが、精度がもう一息といったところでした。
しかし、Structured OutputsはOpenAI調べで精度100%を実現し、非構造な入力から構造化された出力を生成できることで、AIを中心としたアプリケーションが構築できるようになりました。

下記のコードは、簡単な数学の問題をその計算過程も踏まえて解かせてみる例です。
response_formatで出力形式(pydantic等)を指定すれば、簡単にStructured Outputsを試せます。
このコードはJson Modeのような使い方ですが、Function Callingを利用する方法もあります。 betaとありますが、確かに体感100%と言ってよいほどの高精度でした。

from pydantic import BaseModel

from openai import OpenAI


class Step(BaseModel):
    explanation: str
    output: str


class MathResponse(BaseModel):
    steps: list[Step]
    final_answer: str


client = OpenAI(api_key=OPENAI_API_KEY)

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a helpful math tutor."},
        {"role": "user", "content": "solve 8x + 31 = 2"},
    ],
    response_format=MathResponse,
)

message = completion.choices[0].message
if message.parsed:
    print(message.parsed.steps)
    print(message.parsed.final_answer)
else:
    print(message.refusal)

# Step(explanation='Start by isolating the term with x. We need to subtract 31 from both sides of the equation.', output='8x + 31 - 31 = 2 - 31'), Step(explanation='Simplify both sides. The 31s cancel out on the left, and we do the subtraction on the right.', output='8x = -29'), Step(explanation='Now, divide both sides by 8 to solve for x.', output='x = -29/8')]
# x = -29/8

プロンプトエンジニアリングにおいて、step by stepで考えさせるというテクニックがあります。
このような文言はプロンプトにないですが、数学の式変形の途中式を列挙するようなstep by stepな出力が実現できていることが確認できます。
個人的に、o1モデルと組み合わせれば、より高度なタスクにも対応できないだろうかと妄想が膨らみます。

RAGへの応用

Retrieval-Augmented Generation for Large Language Models: A Surveyにおいて、RAG研究の発展を3つの段階に分類しています。

  1. Naive RAG
  2. Advanced RAG
  3. Modular RAG

一般的にRAGの検証を始める際も似たような道を辿ることになると思います。
まずは自社のデータでNaive RAGを試して精度を確認し、精度が不十分であれば前処理や後処理と言った生成以外での工夫(Advanced)をしていき、最終的にはモジュール化していくという流れです。

Structured OutputsはAdvanced RAGにおいて、指示したプロンプトと出力フォーマット通りに前処理や後処理ができるため、非常に有用だと感じています。 処理の作成の容易さと柔軟性に優れており、前処理や後処理を手始めに作ってみるという気軽さがあります。 (ただし、トークン量や処理時間には注意が必要です)。

DELISH KITCHEN x RAG

「デリッシュAI」の紹介

DELISH KITCHENでは 「作りたい!が見つかる」をサービスのコンセプトとして、様々な機能を提供してきました。
一方、ユーザーひとりひとりの多様なニーズに合わせたレシピを提案していくには既存機能でのサポートだけでは難しさもある中で、AIによる料理アシスタントとして「デリッシュAI」ベータ版を一部ユーザーから提供し始めています。

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

ユースケース

「デリッシュAI」でユーザが自然言語でレシピを検索するための機能を提供するために、RAGを活用しています。 RAGでは、ユーザの自然言語入力をベクトル化し、ベクトル検索(Retrieval)することで関連したレシピを先に選定します。 今回はベクトル検索前後にStructured Outputsを使って処理を挟む事例を紹介していきます。

Structured Outputsを使った処理は以下の箇所で「デリッシュAI」の一部に組み込まれています。

  • 前処理:よりベクトル検索しやすくするためにユーザのクエリを処理する
  • ベクトルデータベースのフィルター機能:ユーザのクエリにフィルタリング要素を含むか判定し、ベクトルデータベースの仕様に沿ったフォーマットで検索できるように出力する
  • 後処理:ベクトル検索後に、ユーザのクエリにマッチしたレシピを選定する

共通処理

共通処理を先に定義しておきます。

from enum import Enum
from typing import Union, List

from pydantic import BaseModel

from openai import OpenAI
import json

client = OpenAI(api_key=OPENAI_API_KEY)

1. フィルタリング

ユーザのクエリによっては特定の条件でフィルタリングすることで、より適切なレシピを提案できる可能性が高いです。 一般的なベクトル検索のアルゴリズムでは、「ダイエット中なので◯kcal以下のレシピを探して」といったような特定数値の範囲内での抽出は苦手な印象です。

以下は、ユーザのクエリにフィルタリング要素を含むか判定し、含む場合は指定したフォーマットで出力する例です。 ここでのフォーマットはベクトルデータベースでフィルタリングする想定しており、そこで利用できるような構造の出力を行います。
豊富なメタデータがあれば、ベクトルデータベースのフィルタリングはかなり強力になると思います。

class Column(str, Enum):
    calorie = "calorie_kcal"
    cooking_time = "cooking_time_min"
    cooking_cost = "cooking_cost_yen"
    protein = "protein_g"
    lipid = "lipid_g"
    carbohydrate = "carbohydrate_g"
    saccharide = "saccharide_g"
    salt = "salt_g"

class Operator(str, Enum):
    gt = ">"
    lt = "<"
    le = "<="
    ge = ">="

class Filter(BaseModel):
    columm: Column
    operator: Operator
    value: Union[str, int]

class QueryFilter(BaseModel):
    filters: list[Filter]

def create_query_filter(user_query: str) -> QueryFilter:
    system_prompt =  """
        あなたは料理の知識が豊富なレシピ検索AIです。
        ユーザーがレシピ検索のために入力したuser_queryを解読し、そこからユーザーが特定の条件でフィルタリングして検索したいかどうか判定してください。

        ## 出力形式
        * json形式で出力してください
        * 「ユーザーがフィルタリングして検索したい」の場合は、colummにカラム名、operatorに不等号、valueにフィルタリング対象を入れてください
        * 「ユーザーがフィルタリングして検索しない」の場合は、[]を入れてください
    """

    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        response_format=QueryFilter,
    )

    return json.loads(completion.choices[0].message.content)

簡単なユーザのクエリであれば問題なく抽出できます。
また、フィルタリングの要素がない場合は空が返ってくることが確認できます。

create_query_filter("塩分3グラム未満レシピ")
# {'filters': [{'columm': 'salt_g', 'operator': '<', 'value': 3}]}

create_query_filter("減塩を意識したレシピ")
# {'filters': []}

複数条件でも同様に抽出できることが確認できます。

create_query_filter("1000円以内で作れる1000kcal以上のレシピ")
# {'filters': [{'columm': 'cooking_cost_yen', 'operator': '<=', 'value': 1000}, {'columm': 'calorie_kcal', 'operator': '>=', 'value': 1000}]}

少し意地悪なクエリとして単位を意図的に変えてみます。
秒→分の変換は問題なく対応できましたが、カロリー→kcalの変換は対応できませんでした。

create_query_filter("1200秒以内に作れるデザート")
# {'filters': [{'columm': 'cooking_time_min', 'operator': '<=', 'value': 20}]}

create_query_filter("1000カロリー以上の和食")
# {'filters': [{'columm': 'calorie_kcal', 'operator': '>=', 'value': 1000}]}

2. 除外

ユーザのクエリに除外要素を含むか判定し、含む場合は指定したフォーマットで出力する例です。
ベクトル検索は曖昧な検索ができる点が強力ですが、ユーザのクエリに除外要素が含まれている場合でもそのレシピを抽出してしまうことがあります。 そのため、除外要素を抽出し、ベクトル検索の前にユーザのクエリから除外することで、より適切なレシピを提案できる可能性が高まります。
後処理で抽出した除外要素でフィルタリングすることも可能です。

class ExcludePreprocessedUserQuery(BaseModel):
    user_query: str
    user_query_preprocessed: str
    excluded_foods: List[str]


def exclude_preprocess_user_query(user_query: str) -> ExcludePreprocessedUserQuery:
    system_prompt =  """
        あなたはユーザーの調理ニーズを理解できるレシピ検索AIです。
        ユーザーがレシピ検索のために入力したuser_queryを解読し、そこから「ユーザーが検索で除外したい」かどうか判定してください。

        ## 出力形式
        * json形式で出力してください
        * 「ユーザーが検索で除外したい」場合は、excluded_foodsに除外したキーワード、user_query_preprocessedにuser_queryからexcluded_foodsを除外したキーワードを入れてください
        * 「ユーザーが検索で除外しない」の場合は、[]を入れてください
    """

    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        response_format=ExcludePreprocessedUserQuery,
    )

    return json.loads(completion.choices[0].message.content)

除外要素が含まれている場合は、除外要素を抽出し、ユーザのクエリから除外したクエリを生成できています。

exclude_preprocess_user_query("卵を使わない炒飯のレシピ教えて")
# {'user_query': '卵を使わない炒飯のレシピ教えて', 'user_query_preprocessed': '炒飯のレシピ教えて', 'excluded_foods': ['卵']}

除外はできるが文中に除外する言葉があるとuser_query_preprocessedがうまく生成できないこともあります。

exclude_preprocess_user_query("焼肉のタレが余ってるから焼肉以外で使いたい")
# {'user_query': '焼肉のタレが余ってるから焼肉以外で使いたい', 'user_query_preprocessed': '焼肉のタレが余ってるから焼肉以外で使いたい', 'excluded_foods': ['焼肉']}

3. 注視

同様に、除外の逆でユーザのクエリに注視要素を含むか判定し、含む場合は指定したフォーマットで出力する例です。
ベクトル検索後の後処理で、ユーザのクエリに注視要素が含まれている場合は、そのレシピを優先的に提案することができます。

class FocusPreprocessedUserQuery(BaseModel):
    user_query: str
    focused_foods: List[str]

def focus_preprocess_user_query(user_query:str) -> FocusPreprocessedUserQuery:
    system_prompt = """
        あなたはユーザーの調理ニーズを理解できるレシピ検索AIです。
        ユーザーがレシピ検索のために入力したuser_queryを解読し、そこから「検索する上で注視したいレシピ、食材、調味料」を含むかどうか判定してください。

        ## 出力形式
        * json形式で出力してください
        * 「検索する上で注視したい食材または調味料を含む」場合は、focused_foodsに注視したキーワードを入れてください
        * 「検索する上で注視したい食材または調味料を含まない」場合は、[]を入れてください
    """

    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        response_format=FocusPreprocessedUserQuery,
    )

    return json.loads(completion.choices[0].message.content)

以下のようなクエリでうまく生成できたことを確認できました。

focus_preprocess_user_query("ベーコンを使う炒飯のレシピ教えて")
# {'user_query': 'ベーコンを使う炒飯のレシピ教えて', 'focused_foods': ['ベーコン', '炒飯']}

focus_preprocess_user_query("ポン酢を大量に消費したい")
# {'user_query': 'ポン酢を大量に消費したい', 'focused_foods': ['ポン酢']

focus_preprocess_user_query("卵または玉ねぎを使ったレシピを教えて")
# {'user_query': '卵または玉ねぎを使ったレシピを教えて', 'focused_foods': ['卵', '玉ねぎ']}

focus_preprocess_user_query("シチューまたはカレーのレシピを教えて")
# {'user_query': 'シチューまたはカレーのレシピを教えて', 'focused_foods': ['シチュー', 'カレー']}

4. 名寄せ

除外や注視で抽出したキーワードはユーザのクエリをもとにしたものであるため、表記揺れがあります。
そこで、表記揺れを解消するために、同じ意味のキーワードを列挙する例です。 名寄せ用の辞書を用意する方が妥当ではありますが、今回はStructured Outputsを使い倒すコンセプトで進めます。

class FoodNameCollection(BaseModel):
    foods: List[str]

def merge_food_name_collection(user_query:str) -> FoodNameCollection:
    system_prompt = """
        あなたは食材、調味料の専門家です。
        入力された食材、調味料keywordの表記揺れとして思いつくものを最大5件生成し、その単語をListで出力してください。
        無理に5件作る必要はありません。妥当性を重視してください。
    """
    
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        response_format=FoodNameCollection,
    )

    return json.loads(completion.choices[0].message.content)
merge_food_name_collection("卵")
# {'foods': ['たまご', 'エッグ', '卵(たまご)', '卵白', '卵黄']}

merge_food_name_collection("生姜")
# {'foods': ['ショウガ', 'ジンジャー', '生姜根', '新生姜', '生姜粉']}

merge_food_name_collection("焼き肉")
# {'foods': ['焼き肉', '焼肉', 'やきにく', '焼きにく', 'ヤキニク']}

5. スクリーニング

ユーザのクエリがレシピの検索クエリとして妥当かどうか判定し、妥当な場合のみ指定したフォーマットで出力する例です。
ベクトル検索の前にこの処理を入れることで、不適切なクエリを排除することができ、無駄なリソースやAPIコストを削減できます。 しかし、スクリーニングが過剰に効きすぎた場合、ユーザは思うように検索ができない可能性もあります。

class ScreeningUserQuery(BaseModel):
    comment: str

def screening_user_query(user_query: str) -> ScreeningUserQuery:
    system_prompt = """
    あなたは料理の知識が豊富なレシピ検索AIです。

    ## 答えるべきでない入力について
    ~~ 中略:答えるべきでない内容を箇条書きで列挙 ~~

    ## 答えるべき入力について
    ~~ 中略: 答えるべき内容を箇条書きで列挙 ~~

    ## 出力形式
    * json形式で出力してください
    * 「## 答えるべきでない入力」に該当する質問の場合は、commentに回答できない理由と、どういう検索すると良いかの提案をユーザに寄り添ったフレンドリーな形で回答をしてください
    * 「## 答えるべきでない入力」に該当しない「料理を調べる」文脈の質問が来た場合は、commentに何も入力しないでください
    """
    
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        response_format=ScreeningUserQuery,
    )

    return json.loads(completion.choices[0].message.content)
screening_user_query("毒キノコを使ったレシピ")
# {'comment': '申し訳ありませんが、毒キノコを使用するレシピに関してはお答えできません。安全で美味しい料理を楽しむために、正しい食材を使うことが大切です。食材の選び方や健康的なレシピに関する質問がありましたら、ぜひお聞きください。'}

screening_user_query("暗殺者のパスタを作りたい")
# {'comment': '「暗殺者のパスタ」という具体的な料理は存在しないため、どのような料理を指しているのか明確ではありません。ただし、料理の種類やレシピを知りたい場合は、具体的な食材や風味の特徴を教えていただければ、それに合ったパスタ料理のレシピを提案できます。'}

暗殺者のパスタというレシピは世の中に実在しますが、モデルは存在しないレシピとして扱ってしまいます。
DELISH KITCHEN内でも暗殺者のパスタのレシピは公開しており、ベクトル検索すれば抽出できる可能性が高いです。
この場合は先にスクリーニングするのではなく、ベクトル検索後にスクリーニングするのが良いかもしれません。

おわりに

RAGでStructured Outputsを使い倒す例を紹介しました。 全てgpt-4o-miniモデルの性能とプロンプトエンジニアリングの範囲内での処理でしたが、非常に高い精度で処理できていることが確認できました。

トークンの使用量や処理時間には注意が必要ですが、Advanced RAGを短期間で構築する際には非常に有用だと感じています。

この記事が、RAGの検証を始める際の参考になれば幸いです。

golangci-lint を整備したらレビュー時間が短くなった話

はじめに

こんにちは、@きょーです!普段はDELISH KITCHEN開発部のバックエンド中心で業務をしています。

チームに join した内定者のサポートをしているのですが成長ぶりが凄まじく驚くばかりです。その成長を近くで眺めるのが最近の趣味です。

この記事の対象者

  • レビューで不要な空行やマジックナンバーなどの実装ではなくコードスタイルに関する指摘をしたこと・受けたことがある人
  • golangci-lintがリポジトリに入っているが、既存の設定状態で使っているだけで見直しできていない人

導入

僕が関わっているプロジェクトでは、golangci-lintを使用していますが、ほぼプロジェクトに導入された時の設定のままで、あまり見直しができていなく、既存の設定値は以下のようになっていました。

run:
  timeout: 10m
  issues-exit-code: 0

linters:
  disable-all: true
  fast: false
  enable:
    - bodyclose
    - errcheck
    - goconst
    - gocritic
    - gofmt
    - goimports
    - gosec
    - gosimple
    - govet
    - ineffassign
    - misspell
    - nakedret
    - prealloc
    - staticcheck
    - typecheck
    - unconvert
    - unparam
    - unused

issues:
  exclude-use-default: false
  exclude-rules:
    - path: _test\.go
      linters:
        - errcheck
        - scopelint
        - unparam
        - staticcheck
    - linters:
        - gosec
      text: "G401:"
    - linters:
        - gosec
      text: "G501:"
  exclude:
    - Error return value of `.*.Close` is not checked

上記の内容を見てみるとホワイトリスト形式で書かれていることがわかります。このコードは 5 年前の当時すでに大きくなっていたリポジトリにgolangci-lintを追加する形で導入されていて、開発速度を落とさず必要最低限のlinterを導入したいという思いからブラックリスト形式ではなくホワイトリスト形式を採用したのだと思います。

レビューをする中で以前の必要最低限で入れていたlinterでは検出できないコードスタイルについて指摘をしたり受けたりしたことがありました。例えば ↓ のような点です。

  • 関数定義やiffor 文の最初や最後に不要な空行がある
  • マジックナンバーを使っている
  • 構造体のフィールドとフィールドにつけるタグが一致していない

新しいメンバーが加わるたびに同様の指摘をする可能性があり、別のチームでも同じようなことが起きるかもしれません。そのたびに同じようなコミュニケーションが発生するのは、ちりつもでかなりのコストになりますし、組織として知見を蓄積できていないとも言えます。そのためこの状況を改善したいと考えるようになりました。

そもそも、こういった指摘は本来linterが行うべきことではないでしょうか?そこで、実際にした・受けたレビューをもとに、golangci-lintにどのようなlinterを追加し改善していったのかについて知見を共有したいと思います。

golangci-lintとは?

golangci-lint.run

様々なlinterを一元管理・実行することが出来るツールです。設定したコードスタイルに沿っていないコードを検出してくれるので、統一感のあるコードを書けるようになります。特徴として ↓ のようなものがあります。

  • 早い

    • 並列にリンターを実行
    • Go ビルドキャッシュを再利用
    • 分析結果をキャッシュ
  • 組み込めるlinterが豊富

    • 100 以上
    • ダウンロード不要
  • 主要な IDE と統合

    • VSCode
    • GoLand
    • Vim
    • GitHub Actions
  • etc...

主要な設定は ↓ の通りです。詳細は公式に書いてあるのでそちらをご覧ください。

# 適用したいlinterの設定
linters:
  option: value
# linterごとの設定
linters-settings:
  option: value
# linterの報告に関する設定
issues:
  option: value
# 出力に関する設定
output:
  option: value
# 実行に関する設定
run:
  option: value
# 報告の重要度に関する設定
severity:
  option: value

弊社ではgithub actionsgolangci-lintを入れていてpushしたら走るように設定しています。

実際のレビューに基づきgolangci-lintに追加したlinterたち

whitespace

説明

関数やifforの最初や最後に不要な空行がないかをチェックするlinterです。

設定できる値は下記のようになっていて、ifの条件が複数行になった場合に最初の行を空行で始めるかどうかの設定等もできるようです。

linters-settings:
  whitespace:
    # Default: false
    multi-if: true
    # Default: false
    multi-func: true

実際のレビュー

関数の下の空行

行末の変な空行

導入後

下記のコードには、関数定義の後と関数最後の行に不要な空行があります。これをlinterが検出してくれます。

func sample() { // unnecessary leading newline (whitespace)

    fmt.Println("Hello, world!")

} // unnecessary trailing newline (whitespace)

linterが検出してくれるので下記のようにすぐ修正できます

func sample() {
    fmt.Println("Hello, world!")
}

mnd

説明

マジックナンバーを検出するlinterです。

設定できる値は下記のようになっていて、チェックする項目(引数、代入、switchif 文や returnの値)を設定できたり、検出を無視する数字やファイル、関数を指定できるようです。

linters-settings:
  mnd:
    # Default: ["argument", "case", "condition", "operation", "return", "assign"]
    checks:
      - argument
      - case
      - condition
      - operation
      - return
      - assign
    # Default: []
    ignored-numbers:
      - "0666"
      - "0755"
      - "42"
    # Default: []
    ignored-files:
      - 'magic1_.+\.go$'
    # Default: []
    ignored-functions:
      - '^math\.'
      - '^http\.StatusText$'

実際のレビュー

マジックナンバー

導入後

下記のように引数に代入するときや数字同士を比較する時に値をそのまま使っていないかlinterが検出してくれます。

func sample() {
    hoge := someFunc(60) // Magic number: 360, in <argument> detected (mnd)
}

linterが検出してくれるので下記のようにすぐ修正できます

func sample() {
    value := 60
    hoge := someFunc(value)
}

tagliatelle

説明

構造体のフィールド名とタグをチェックするlinterです。

設定できる値は下記のようになっていて、フィールド名とタグの名前を同じものとなるように設定したり、camelsnakeケースなどタグのスタイルを設定できるようです。

linters-settings:
  tagliatelle:
    case:
      # Default: false
      use-field-name: true
      # Default: {}
      rules:
        # Any struct tag type can be used.
        # Support string case: `camel`, `pascal`, `kebab`, `snake`, `upperSnake`, `goCamel`, `goPascal`, `goKebab`, `goSnake`, `upper`, `lower`, `header`
        json: camel
        yaml: camel
        xml: camel
        toml: camel
        bson: camel
        avro: snake
        mapstructure: kebab
        env: upperSnake
        envconfig: upperSnake

実際のレビュー

構造体のフィールド

導入後

下記の構造体ではIDというフィールド名のjsonタグがhogeになっています。期待している値はidと言うタグなので、これをlinterが検出してくれます。

type Sample struct {
    ID string `json:"hoge"` // json(snake): got 'hoge' want 'id' (tagliatelle)
}

linterが検出してくれるので下記のようにすぐ修正できます

type Sample struct {
    ID string `json:"id"`
}

まとめ

この記事では、golangci-lintを活用してコードレビューの効率を向上させる方法を紹介しました。golangci-lintの整備により、不要な空行やマジックナンバー、構造体のタグの不一致といった問題を自動で検出できるようになりました。これにより、レビューの際には本質的なロジックに集中できるようになり、開発プロセス全体の質を向上させることができました。

今後は、golangci-lintの設定をさらに最適化し、他のプロジェクトにも展開することで、組織全体の開発効率をさらに高めていきたいと考えています。

同様の課題を抱える方は、ぜひgolangci-lintの導入・整備を検討してみてください。もし初期段階のプロジェクトであればブラックリスト形式で導入を検討するのもいいかもしれません。

参考資料

シングルトンパターンの問題点と改善方法 - 保守性とテスタビリティの向上を目指して -

はじめに

DELISH KITCHENのiOSアプリ開発を担当している池田です。iOSチームでは継続的な開発のために日々リファクタリングを行っております。 リファクタリングを進める中で、特に厄介な存在として浮かび上がってきたのがシングルトンパターンです。シングルトンは便利な機能に見えますが、アプリケーションの保守性やテスタビリティを低下させる要因となっています。 本記事では、シングルトンパターンの問題点を解説し、より良い設計への改善方法を提案します。

シングルトンとは

アプリ内に存在するクラスのインスタンスをひとつに制限させる設計パターンで、静的なインスタンスフィールドからグローバルにアクセス可能です。

単一のリソースに対してアクセスするクラスは、複数のインスタンスがあると並列アクセス等のバグを生みやすくなります。そのようなクラスの場合、インスタンスの存在をひとつに強制するためにシングルトンにすることがあります。

以下にシングルトンのサンプルコードをSwiftで示します。(ここではSwift6のConcurrency Checkingは考慮していません。)

final class DatabaseManager {
    static let shared = DatabaseManager()
    private init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

// 使用例
DatabaseManager.shared.save(key: "hoge", value: 1)
let data = DatabaseManager.shared.fetch()

シングルトンの問題点

現在では次のような理由からシングルトンはアンチパターンとして避けられることが多くなっています。

シングルトンとの密結合

ひとつめの問題点は、シングルトンを利用するクラスがシングルトンと密結合してしまうことです。

final class UseCaseA {
    func doSomething() {
        DatabaseManager.shared.save(key: "hoge", value: 100)
        let value = DatabaseManager.shared.fetch(key: "hoge")
        // 処理
    }
}

この実装には次のような問題があります。

  • シングルトンを直接参照しているため、差し替えが困難

この問題は特にテストを行う場合が顕著で、モックを使いたい場合でも置き換えることができません。また将来的に実装を変更したい場合にも、すべての参照箇所を修正する必要が出てきてしまいます。

シングルトンを介したクラス間の密結合

ふたつめの問題点は、シングルトンを介して複数のクラスが密結合してしまうことです。

final class UseCaseA {
    func doSomething() {
        DatabaseManager.shared.save(key: "hoge", value: 100)
        let value = DatabaseManager.shared.fetch(key: "hoge")
        // 処理
    }
}

final class UseCaseB {
    func doSomething() {
        let value = DatabaseManager.shared.fetch(key: "hoge")
        DatabaseManager.shared.save(key: "hoge", value: value + 200)
        // 処理
    }
}

この実装には次のような問題があります。

  • ふたつのUseCaseは一見独立しているが、シングルトンインスタンスを介して結合している。
  • 意図せず他方のクラスに影響を与える可能性がある。

例えば、UseCaseBの開発者がUseCaseAの実装を知らないまま同じkeyを使用してしまうと、意図せずデータを上書きしてしまう可能性があります。また、一方のUseCaseの変更が他方に影響を与える可能性があり、変更の影響範囲を把握することが困難になります。

改善策

このような問題を解決するために、インターフェースの定義と依存性の注入(DI)を行います。

protocol DatabaseManager {
    func save(key: String, value: Int)
    func delete(key: String)
    func fetch(key: String) -> Int
}

final class DatabaseManagerImpl: DatabaseManager {
    static let shared = DatabaseManagerImpl()
    private init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

final class UseCaseA {
    private let databaseManager: DatabaseManager
    
    init(databaseManager: DatabaseManager) {
        self.databaseManager = databaseManager
    }

    func doSomething() {
        databaseManager.save(key: "hoge", value: 100)
        let value = databaseManager.fetch(key: "hoge")
        // 処理
    }
}

final class UseCaseB { /* 省略 */ }

// 使用例
let databaseManager = DatabaseManagerImpl.shared
let useCaseA = UseCaseA(databaseManager: databaseManager)
let useCaseB = UseCaseB(databaseManager: databaseManager)
useCaseA.doSomething()
useCaseB.doSomething()

このようになるとシングルトンである必要はなく、エントリポイントで共通のインスタンスを注入するだけで良くなります。

protocol DatabaseManager {
    func save(key: String, value: Int) 
    func delete(key: String)
    func fetch(key: String) -> Int
}

final class DatabaseManagerImpl: DatabaseManager {
    init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

// 使用例
let databaseManager = DatabaseManagerImpl() // シングルトンの必要はない
let useCaseA = UseCaseA(databaseManager: databaseManager)
let useCaseB = UseCaseB(databaseManager: databaseManager)
useCaseA.doSomething()
useCaseB.doSomething()

よくある誤用

DELISH KITCHENのコードを確認したところ多くのシングルトンが実装されていました。しかしその中にはシングルトンのグローバルにアクセスが可能という部分のみを利用した実装がありました。

final class ConfigManager {
    static let shared = ConfigManager()
    private init() {}

    var hogeConfig: HogeConfig = .init()
}

この実装の問題点は、シングルトンの本来の目的である「インスタンスの一意性を保証する」という点が活かされていない点です。単にグローバルな変数として使用されているだけで、むしろこのような用途であれば、設定値は依存性注入で渡すか、より適切な形でのデータ管理を検討すべきです。たとえば以下のような方法が考えられます。

struct AppConfig {
    let hogeConfig: HogeConfig
}

final class UseCase {
    private let config: AppConfig

    init(config: AppConfig) {
        self.config = config
    }

    func doSomething() {
        // configを使用した処理
    }
}

このように修正することで、設定値の管理がより明示的になり、テストも容易になります。

まとめ

シングルトンパターンは、クラスのインスタンスをグローバルに一つだけ存在させる設計パターンです。しかし、現在ではアンチパターンとして認識されることが多くなっています。これは、シングルトンを使用するクラスとの密結合や、シングルトンを介した複数クラス間の密結合といった問題を引き起こすためです。 シングルトンを使わずともインターフェースを定義し、依存性の注入を活用することで、シングルトンと同様の機能を実現できることが多いです。 シングルトンは最終手段として考え、まずは代替手段を考えることをおすすめします。

この記事が、これから同様の課題に取り組む開発者の方々の参考になれば幸いです。

余談

Swiftにおいては、1つのインスタンスを複数処理で共有する場合、Swift 5.9で実装された ~Copyable を使うことでより安全なコードを書ける可能性があるので、こちらも合わせて検討すると良いと思います。

Amazon Cognito設定項目のポイント

はじめに

こんにちは、DELISH KITCHEN 開発部でソフトウェアエンジニアをしている24新卒の新谷です。

現在取り組んでいる業務で、共通認証基盤にemailを使った認証を導入するため、Amazon Cognitoを利用しています。(共通認証基盤については、こちらをご参照ください。)その際に、Amazon Cognitoの設定項目について調査する機会があったので、その内容をご紹介します。

Amazon Cognitoとは

Amazon Cognitoは、AWSが提供するウェブアプリとモバイルアプリ用のアイデンティティプラットフォームです。ユーザーの認証・承認を行うユーザープールとユーザーにAWSリソースへのアクセスを許可するアイデンティティプールを持っています。

また、AWSからCognitoを操作するAPIが提供されており、これを利用することで、ユーザーの認証・承認を行う機能を簡単に実装することができます。

$ aws cognito-idp help
add-custom-attributes
admin-add-user-to-group
admin-confirm-sign-up
admin-create-user
:

Delish Kitchenの認証について Delish Kitchenの認証は共通認証基盤を利用していません。(正確には、認証情報は共有されていますが、Cognitoはそれぞれ独立しています。)そのため、Delish KitchenのCognitoは共通認証基盤で使用しているCognitoとは別のものです。ここでは、それぞれのCognitoの設定を比較し、特に異なる点に焦点を当てて説明します。

サインインエクスペリエンス

Cognito ユーザープールのサインインオプション

ユーザーがサインイン時に以下にある選択肢の中からどの方法でサインインするかを設定することができます。

  • ユーザー名
  • 電話番号
  • Email

ユーザー名に関しては、大文字小文字を区別するかどうかの設定も可能です。

Delish Kitchenと共通認証基盤の設定は以下のように設定されています。

Delish Kitchenの設定

  • Emailのみでログイン可能

共通認証基盤の設定

  • Email、ユーザー名、電話番号でログイン可能

サインアップエクスペリエンス

属性検証とユーザーアカウントの確認

ここでは、メールを自動的に送信するかと、属性(メールアドレスなど)変更時の挙動を設定することができます。 メールの自動送信とは、ユーザーがサインアップした際に本人確認のための検証メールを自動で送信するかという設定です。

Delish Kitchenと共通認証基盤の設定は以下のように設定されています。

Delish Kitchenの設定

  • メールの自動送信は許可
  • 属性変更は元の属性値を保持しない

共通認証基盤の設定

  • メールの自動送信は許可
  • 属性変更は元の属性値を保持する

未完了の更新があるときに元の属性値をアクティブに保つとは?

ユーザーがメールアドレス変更後、メールの検証を行っていない場合、元のメールアドレスをアクティブに保つかどうかの設定です。 以下のように最初に設定する際に説明があります。

つまり、この設定が無効になっている場合、ユーザーがメールアドレス変更をすると即時新しいメールアドレスに変更されてしまいます。 Delish Kitchenでは無効となっていますが、これは当時この設定がCognitoにはなかったためです。上記の画像にもあるように、有効にすることが推奨されているので基本的には有効にしておくべきだと思います。

メッセージング

メッセージテンプレート

本人確認などCognitoから送信されるメールのテンプレートを設定することができます。

Delish Kitchenと共通認証基盤の設定は以下のように設定されています。

Delish Kitchenの設定

  • メッセージテンプレートは使用せず(初期のまま)

  • Lambda トリガーを利用してメッセージをカスタマイズしている (Lambda トリガーは、Cognitoのイベントに対してLambda関数を実行することができる機能です。)

共通認証基盤の設定

  • メッセージテンプレートで内容をカスタマイズしている

Delish KitchenでLambdaトリガーを利用している理由

Delish KitchenではLambdaトリガーを使って以下のように独自の検証リンクを生成しています。

https://delishkitchen.tv/auth/email/confirm-signup?code=xxxxxx&username=xxxxxx

(検証リンクを送信するだけであれば、Cognitoは検証コードか検証リンクを選択することができるため、Lambdaトリガーを使用する必要はありません。) Delish KitchenでLambdaトリガーを使っている理由は、検証イベントをトリガーにアプリケーションレイヤーで追加の処理を行えるためです。また、ユーザーIDなどのCognitoが保持していない情報もメールに含めることができるため、Lambdaトリガーを利用しています。

まとめ

今回は、Amazon Cognitoの設定項目について、Delish Kitchenと共通認証基盤の設定を比較しながら説明しました。Cognitoのユーザープールの設定には、最初に一度設定すると後から変更することができない項目もあるため、設定時には慎重に行う必要があります。 また、Cognitoの設定が変更可能でも、認証周りの仕様変更はユーザーにとって大きな影響を与えるため、設計段階で検討することが重要です。

Vue Fes Japan 2024 Pre LT Party で登壇しました

はじめに

エブリーの羽馬(https://twitter.com/naoki_haba)です。

2024年10月17日に開催された Vue Fes Japan 2024 Pre LT Party にて「unplugin-vue-routerで実現するNuxt風ファイルベースルーティング」というテーマで登壇させていただきました。

optim.connpass.com

この記事では、unplugin-vue-router の魅力と発表で伝えたかったポイントについて共有します。

イベント概要

Vue Fes Japan 2024に先立って開催された事前LTイベントでは、Vue.js に関する様々なトピックについて、短時間で濃密な情報共有が行われました。

発表のハイライト

発表では、Vue.js プロジェクトでよく直面する以下のような課題に対する解決策として、unplugin-vue-router を紹介させていただきました:

www.docswell.com

  • 😓 route.js(ts) の肥大化による管理の複雑化
  • 🔁 ページ追加時の煩わしい反復作業
  • 🤔 Nuxt を使わずにファイルベースルーティングを実現したいニーズ

主要な説明ポイント

  1. 型安全性の実現

    • ルート名とパスの自動補完
    • パラメータの型チェック
    • 存在しないルートの即時検出
  2. ファイルベースルーティングの利点

    • ファイル構造による直感的なルート管理
    • ネストされたレイアウトの自然なサポート
    • 動的ルートの簡単な定義
  3. データローダーの可能性

    • ルート単位でのデータプリフェッチ
    • コンポーネントとデータ取得ロジックの分離

導入のメリット

unplugin-vue-router の導入により、以下のような効果が期待できます:

  1. 📈 開発効率の向上

    • ルーティング設定の自動化
    • 手動設定の手間を大幅に削減
  2. 🧠 認知負荷の軽減

    • ファイル構造に集中するだけでOK
    • 複雑なルーティングロジックから解放
  3. 🔧 柔軟性の向上

    • Vue.js プロジェクトでファイルベースルーティングを実現
    • Nuxt ライクな機能を単体のVue.jsアプリケーションで実現

注意点

発表では、以下の注意点についても触れさせていただきました:

  • 安定性と実験的機能

    • 型付きルーティングとファイルベースルーティングは安定
    • データローダーなどの実験的APIは将来変更の可能性あり
  • SSRサポート

    • 現時点でSSR(サーバーサイドレンダリング)はサポートされていない

まとめ

Vue Fes Japan 2024 Pre LT Partyでの発表を通じて、unplugin-vue-routerの主要な機能と活用方法について共有させていただきました。Vue.jsプロジェクトの開発効率を向上させるための選択肢として、ぜひ検討いただければ幸いです。

また、10月30日のアフターイベントでも登壇させていただきますので、そちらもぜひご覧ください。

studist.connpass.com