TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

410

はじめに こんにちは!株式会社エブリーで約1か月間インターンシップに参加している山本です。配属チームはリテールハブ小売アプリチームで、主に小売店やそのお客さんに向けたサービスを開発しているチームになります。具体的には、スーパーなどの小売店がお客さんにお知らせをアプリ経由で配信するなどのサービスを手掛けています。本記事では、小売店向けのアプリの運用効率を向上させるために導入した管理機能と開発していく中で困ったことなどについてご紹介します。 背景と目的 現在、小売アプリにはお客さん向けアプリと小売店向けの管理画面の2つが存在します。小売店向けの管理画面では、お客さんに向けてお知らせやチラシなどを配布することができ、お客さん向けアプリではそれらを受け取り、利用することができます。 これらのアプリに関しては、マルチテナント化を進めており、単一コードで管理を行っています。しかし、現時点では小売店向けの管理画面を管理する管理機能のようなものが存在しません。そのため、各テナントの機能やカスタマイズを一元管理するような画面や利用状況などを監視、分析するような機能がありません。また、運営からメンテナンス等のお知らせを伝えることもできないため、各小売店に個別に連絡をする必要があります。対象の小売店が数店舗であれば運用可能ですが、これからさらに大規模になっていくことを考えると、小売店の管理機能を開発する必要があります。 このような背景のもと、本インターンでは運営効率の向上や顧客体験の統一化のために小売向けの管理機能の開発に取り組みました。 構成と技術スタック 今回、取り組んだタスクは一からのスタートだったため、技術選定から行う必要がありました。個人で開発を行う際は、特に何も考えず自分の好きな技術や触ってみたい技術を使っていたため、実際に必要な機能の実現可能性など様々なことを考慮しながら技術選定を行うのはとても難しかったです。 選定にあたっては、機能要件の実現可能性や開発効率などを多角的に検討した結果、以下の理由によりNext.jsによるフルスタック開発を採用しました。 開発効率の向上 :フロントエンドとバックエンドが同じ言語(TypeScript)であることで初期段階の開発をスムーズに進めることができる コードの型安全性 :フロントエンドとバックエンドで型定義を共有できるため、データの整合性を保ちやすく安全な開発が可能になる ライブラリの充実 :必要な機能である認証機能をはじめとしたライブラリが充実しており、複雑な機能も実装できる また、インフラ構成に関しては、以下のような構成にしました。ALBやセキュリティグループでIP制限をかけることで、社外からのアクセスを制限しています。デプロイに関しては、ECRへのpushとECSのデプロイはecspressoで管理をして、それ以外のコンポーネントはTerraformで管理をしています。 インフラ構成図 技術スタック一覧 Next.js AWS Terraform ecspresso Github Actions MySQL 実装した機能 本インターンは1か月という短い期間ということもあり、優先順位の高い以下の機能を実装しました。 認証機能 ログイン/ログアウト ユーザー管理 アカウント作成/削除 管理者権限/閲覧権限 お知らせ管理 お知らせ作成/編集/削除 操作ログ 誰がいつ何を行ったかを記録 ログイン画面とお知らせ管理画面は現在以下のようになっています。 ログイン画面 お知らせ管理画面 困ったこと 認証機能について 認証機能に関しては、 NextAuth.js の最新バージョンである Auth.js (v5から名称が変更) を採用しました。Auth.jsは様々な認証機能を提供しており、これらを少ないコード量で簡単に実装できるため、このライブラリを用いてEmailとパスワードでの認証機能を実装しました。 しかし、インターン期間中にXで ある記事 が流れてきました。この記事ではAuth.jsは Better Auth に統合されることが発表され、今後はフレームワーク非依存のBetter Authに移行することが推奨されています。そのため、Auth.jsで書いたコードをBetter Authに移行する必要が発生しました。 当初実装していたAuth.jsの認証ではJWTを用いて、アプリケーション側でセッション情報を持たないステートレスな認証を行っていましたが、Better Authはステートレス認証をサポートしていませんでした。そのため、DB設計なども変更になり、完全にBetter Authで書き換えるという作業になりました。 予期せぬライブラリの移行作業は大変でしたが、結果的に数日間で複数の認証技術に触れることができ、非常に学びの多い経験となりました。また、Web技術の進化の速さをリアルタイムで体感すると同時に、実務開発のリアルな一面も経験することができました。 API呼び出しについて Next.js App Router でサーバーサイドの処理を行う方法として、 Route Handlers を用いた実装方法と Server Functions を用いた実装方法があります。 Route Handlers Route HandlersはAPIエンドポイントをサーバーサイドで作り、それを呼び出します。 app/api 配下に route.ts ファイルを配置することで、ファイル構造がそのままAPIエンドポイントのURLとなり、フォルダとファイル名を見るだけでどのURLに対応するのかが直感的にわかるようになっています。 以下のコードを app/api/hello/route.ts に配置した場合、クライアント側から fetch("/api/hello") で呼び出すことができます。 export async function GET() { return Response.json({ message: "Hello World" }) } Server Functions Server FunctionsはクライアントサイドからRPCスタイルで簡単にサーバサイドの関数を呼び出せる機能です。 "use server" ディレクティブを加えることで、以下のようにサーバーサイドの関数を定義することができます。 "use server" export async function createPost(formData: FormData) { // update logic } そして、クライアントサイドではフォームなどに以下のように記述することで処理を行うことができます。 state を保持したり、 handler を定義する必要がなく、簡潔に書くことができるというメリットがあります。 "use client" import { createPost } from "@/app/actions" export function Button() { return <button formAction={createPost}>Create</button> } Server Functionsの簡潔な記述は魅力的でしたが、Next.jsのAPIを外部から呼び出す場合や、今後バックエンドをNext.jsから切り離すことも想定して、今回はRoute Handlersを用いて実装を行いました。 インフラ構成について 小売向けの管理機能はあまり使用頻度が高くない想定ということで、当初はLambdaを用いてデプロイを行う方針でした。LambdaはAPI Gatewayなどの何らかのイベントがトリガーとなり handler 関数が呼び出されるため、Lambda特有のインターフェースに沿った書き方を行う必要があります。しかし、 Lambda Web Adapter を用いることで、元々サーバーレス環境のために作られたわけではないNext.jsなどのフレームワークをそのままLambda上で動かすことができるようになります。 当初は、このLambda Web Adapterを用いて、 Terraform と Lambroll でインフラ構築を行っていました。しかし、実際にデプロイ作業を行っていく中で、DBのパスワードなどの外部公開しない環境変数の渡し方で困ってしまいました。外部公開したくないためECRにpushはせず、Secrets Managerを参照して取得したいですが、調べた限りではLambdaではそのためのコードを書いて環境変数の取得を行う必要がありました。( 参考 ) 環境変数はSecrets Managerで管理して、それを直接参照して使えるようにしたかったため、Lambdaの使用はやめ、ECS (Fargate) を用いるように変更しました。ECSではコンテナの定義にSecrets Managerのパスを書くことで直接参照することができます。 以下はTerraformで定義したSecret Managerをecspressoで参照してデプロイを行う例です。 { "name": "DATABASE_URL", "valueFrom": "{{ tfstate `module.secret_manager.aws_secretsmanager_secret.control_db.arn` }}:database_url::" } さいごに 1ヶ月という短い間でしたが、技術選定からフロントエンド、バックエンド、インフラ構築、CI/CDと様々な技術領域に触れることができ、非常に貴重な経験となりました。特に、実際に業務を進めていく中で、当初の想定通りに進まない事態に直面し、その都度相談しながら解決策を探るという実務のリアルな側面を体験することで大きな学びを得ることができました。また、この経験を通じて、実務における技術選定や計画の難しさと、状況に応じて柔軟に対応していく重要性を実感することができました。 今回のインターンシップで得た学びと経験を元にこれからも成長していき、ユーザーに価値を届けられるようなエンジニアになっていきたいです。
アバター
はじめに こんにちは。 開発本部 開発1部 デリッシュリサーチチームでデータエンジニアをしている吉田です。 本記事では、DatabricksのManaged MCP Serverを活用し、CursorからUnity Catalog Functionsをツールとして呼び出して、任意のUnity Catalogテーブルのスキーマ情報を取得するまでをまとめます。 背景 CursorでDatabricks上のコードを書く際、特定テーブルのスキーマ情報をCursor側(エージェント)に渡したい場面がありました。 どのようにして簡単にこの情報を取得して渡すか検討していたところ、Databricks Managed MCP Serverがベータリリースされていることを知り、早速使ってみることにしました。 Databricks Managed MCP Serverとは Databricks Managed MCP Serverとは、Databricks上でホストされているMCP Serverです。 Use Databricks managed MCP servers インフラはDatabricks側で管理されるため、すぐに利用できます。 現時点でベータ版として以下の機能が提供されています。 提供MCPサーバー Vector search: Vector Search Indexにクエリして関連ドキュメントを検索する Genie space: Genie Spaceにクエリを実行し、自然言語を用いてクエリを実行する Unity Catalog functions: Unity Catalog Functionを利用して、定義済みのSQLを実行する DBSQL: AI生成のSQLを実行する この記事ではUnity Catalog FunctionsのMCP Serverを扱います。 Unity Catalog Functionの実装 MCP Serverから呼び出すためのUnity Catalog Functionを作成します。 Unity Catalogのテーブルパスを受け取り、カラム名やデータ型をJSON文字列で返す関数を作成します。 Unity Catalogテーブルのスキーマを取得する方法はいくつかありますが、SQLだけで簡単に完結させたかったため、 system.information_schema.columns テーブルを参照する方法を採用しました。 system.information_schema.columns テーブルには、管理下の全テーブルのカラム情報が含まれているため、これを利用します。 以下のSQLで mcp.unity_catalog 配下に get_schema_info という名前のUnity Catalog Functionを作成します。 CREATE OR REPLACE FUNCTION mcp.unity_catalog.get_schema_info( uc_full_path STRING COMMENT ' Unity Catalogテーブルのフルパス(例: catalog.schema.table) ' ) RETURNS STRING LANGUAGE SQL COMMENT " 指定したUnity Catalogテーブルのカラム情報(カラム名、NULL許容、データ型、パーティションインデックス、コメント)をJSON形式で返す関数です。 " RETURN SELECT to_json(collect_list(struct( column_name, is_nullable, full_data_type, partition_index, comment ))) AS s FROM system.information_schema.columns WHERE table_catalog = split(uc_full_path, ' \\. ' )[ 0 ] AND table_schema = split(uc_full_path, ' \\. ' )[ 1 ] AND table_name = split(uc_full_path, ' \\. ' )[ 2 ]; Managed MCP Server(Unity Catalog Functions)として公開 Managed MCP ServerのFunctionsサーバーは https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema} のURLパターンで提供されます。 今回の関数は mcp.unity_catalog.get_schema_info なので、クライアントから接続するサーバーURLは以下になります。 https://<workspace-hostname>/api/2.0/mcp/functions/mcp/unity_catalog Cursor からの接続と実行 Cursorは、Cursor Settings -> Tools & MCPの項目から接続を設定できます。 Connect Cursor with PAT mcp.jsonファイルを以下のように設定することで接続できます。 URLで指定した <catalog>.<schema> 配下の Unity Catalog Function が自動的にツールとして登録されます。 設定イメージ(例) { " mcpServers ": { " uc-function-mcp ": { " type ": " streamable-http ", " url ": " https://<workspace-hostname>/api/2.0/mcp/functions/<catalog_name>/<schema_name> ", " headers ": { " Authorization ": " Bearer <YOUR_PAT> " } , " note ": " Databricks UC Functions " } } } Cursorから呼び出し Databricksのサンプルデータを対象に実行してみます。 uc-function-mcpを利用して、samples.bakehouse.media_customer_reviewsのスキーマ情報を教えて下さい Unity Catalog Functionの mcp.unity_catalog.get_schema_info が呼ばれ、以下のような JSON が返ります。 { " is_truncated ": false , " columns ": [ " output " ] , " rows ": [ [ [ { " column_name ": " review ", " is_nullable ": " YES ", " full_data_type ": " string " } , { " column_name ": " franchiseID ", " is_nullable ": " YES ", " full_data_type ": " bigint " } , { " column_name ": " review_date ", " is_nullable ": " YES ", " full_data_type ": " timestamp " } , { " column_name ": " new_id ", " is_nullable ": " YES ", " full_data_type ": " int " } ] ] ] } 最終的に、以下のように解釈した結果を出力してくれました。 まとめ Managed MCP Serverを利用して、Unity Catalog Functionを呼び出すことで、Cursorから安全にテーブルスキーマを取得できるようになりました。
アバター
はじめに MCP サーバーとは ハンズオン step 1 step 2 step 3 最後に はじめに こんにちは、 @きょー です!普段はデリッシュキッチン開発部のバックエンド中心で業務をしています。 このブログでは簡単な MCP サーバーを作成し、ローカルでの動作確認。そしてリモート化させるところまでをハンズオン形式で紹介しようと思います。すでに MCP サーバーを多数作成されていたり、豊富な知見をお持ちの方には物足りない内容になっているかもしれません。 MCP サーバーとは MCP サーバーは、AI アプリケーションと外部システムの間の橋渡しをする役割を担います。具体的には以下のような機能を提供します。 リソース ファイルやデータベースなどの外部リソースへのアクセス ツール 外部 API の呼び出しや特定の操作の実行 自分がよく使っている MCP サーバーを例としてあげると GitHub があり、主に issue の読み込み、作成や PR の作成などをしてもらっています。今となっては手放せない MCP サーバーです。 他にも自分や他の人が使っている MCP サーバーとして以下のようなものもあります。 atlassian(confluence) https://www.atlassian.com/ja/platform/remote-mcp-server redash 自社作成(テックブログ: https://tech.every.tv/entry/2025/08/08/115847 ) textlint https://textlint.org/docs/mcp/ terraform https://github.com/hashicorp/terraform-mcp-server chrome-devtools https://github.com/ChromeDevTools/chrome-devtools-mcp/ circleci https://circleci.com/mcp/ sentry https://docs.sentry.io/product/sentry-mcp/ context7 https://github.com/upstash/context7 github.com (↑ 最近見つけた面白いリポジトリがあるので共有させてください。いろんな MCP サーバーが紹介されています。) MCP の詳細な説明は 公式 に書かれているためここでは説明を省略とさせてください。 ハンズオン このハンズオンでは mcp/go-sdk の実装をもとに自分の名前を入力したら「Hi, {自分の名前}」と返す MCP サーバーを作成します。最初はローカル環境のみで動作できるようにサーバーを構築し、その後リモート環境(cloud run)に載せられるようにサーバーを修正していきます。完成したコードは こちら のリポジトリに残してあるので、適宜見に行っていただけますと幸いです。 必要な環境は以下の通りです。 golang docker google cloud にログインできるアカウント node.js: ^22.7.5 (動作確認で使うツール用) それでは実際にハンズオン形式でやっていこうと思います。 step 1 まずはローカルで動く MCP サーバーを作成します(通信形式は STDIO) # まずは作業場所を作成します mkdir mcp-sample cd mcp-sample # 次にgo周りの環境を整えます go mod init touch main.go # dockerファイルも用意しておきます touch Dockerfile ベースとなるコードを main.go に書いていきます。 公式 のコードをそのまま持ってきます。 package main import ( "context" "log" "github.com/modelcontextprotocol/go-sdk/mcp" ) type Input struct { Name string `json:"name" jsonschema:"the name of the person to greet"` // ユーザーに入力してもらうパラメータ } type Output struct { Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` // アウトプットとなるデータの説明 } // MCPサーバーに登録するツールの中身 func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error ) { return nil , Output{Greeting: "Hi " + input.Name}, nil } func main() { // MCPサーバーを作成 server := mcp.NewServer(&mcp.Implementation{Name: "greeter" , Version: "v1.0.0" }, nil ) // MCPサーバーにツールを登録 mcp.AddTool(server, &mcp.Tool{Name: "greet" , Description: "say hi" }, SayHi) // サーバーを起動し、クライアントが接続を切るまで待機(通信方式はSTDIO) if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) } } FROM golang:1.24-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED = 0 GOOS =linux go build -o mcp-greeter . FROM gcr.io/distroless/base-debian12 WORKDIR /app COPY --from=builder /app/mcp-greeter /app/mcp-greeter ENTRYPOINT [ " /app/mcp-greeter " ] ここで go mod tidy を実行するとディレクトリ構成は以下のようになっているかと思います。 . ├── Dockerfile ├── go.mod ├── go.sum └── main.go 以下のコマンドを実行してイメージを用意しておきます。 # イメージをビルド docker build -t mcp-greeter . mcp/inspector というツールを使って動作確認をしてみます。 npx @modelcontextprotocol/inspector 上記のコマンドを実行し、inspector の画面で以下の情報を入力し画面下部にある connect をタップすると MCP サーバーのツールの動作確認などができるようになります。 Transport Type STDIO Command docker Arguments run -i --rm mcp-greeter これでローカル環境で MCP サーバーを作成、動作確認までは終えました。 step 2 次は MCP サーバーの通信方式を変えます。 STDIO は「クライアントが MCP サーバーをサブプロセスとして起動し、標準入出力で直接通信する」ことを前提にしています。これは同一マシン上でのプロセス間通信には適していますが、リモート環境では以下の問題があります。 クライアントがリモートサーバー上でサブプロセスを起動できない 標準入出力による直接通信がネットワーク越しでは成立しない そこで、MCP 仕様で定義されている Streamable HTTP の通信方式を使用することで、ネットワーク越しの通信を可能にさせます。これによって docker や golang などを必要としていた個人の環境に依存することなく、MCP クライアントさえあれば簡単に MCP サーバーを利用できるようになります。 STDIO から Streamable HTTP の通信方式に変えるにあたり MCP サーバーと MCP クライアントの通信は大きく変わります。 左: STDIO の通信方式  右: Streamable HTTP の通信方式 詳細は 公式 に書いてあるため省きますが、通信のやり取りから Streamable HTTP ではセッションの管理で大変そうなのがわかるかと思います。 これをコードに落とし込むために main.go を修正していきます。(修正した際の PR: https://github.com/keyl0ve/mcp-migration-sample/pull/1 ) func main() { // MCPサーバーを作成 server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) // MCPサーバーにツールを登録 mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - // サーバーを起動し、クライアントが接続を切るまで待機(通信方式はSTDIO) - if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { - log.Fatal(err) - } + // HTTPハンドラーを介してMCPリクエストを処理 + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil) + + // 8080でサーバーを起動 + if err := http.ListenAndServe(":8080", handler); err != nil { + log.Fatal(err) + } } 実際に動くか確認してみましょう。 # イメージのリビルド docker build -t mcp-greeter . # 8080でリクエストを受け付ける docker run --rm -p 8080:8080 mcp-greeter 上記でサーバーを建て直したら inspector の設定を変えます。 Transport Type Streamable HTTP URL http://localhost:8080 無事に動いているのが確認できるかと思います。 ログを出すミドルウェアを挟む( 対応 PR )と MCP サーバー側でどんなリクエストが来ているか確認することもできます。 % docker run --rm -p 8080:8080 mcp-greeter 2025 / 11 / 12 07:18:56 MCP HTTP server listening on :8080 2025 / 11 / 12 07:19:02 POST / from 192 . 168 . 65 .1:49815 - > 200 ( 2 .46225ms ) time =2025-11-12T07:19:02.807Z level =INFO msg = " MCP method started " method =initialize session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ has_params =true time =2025-11-12T07:19:02.807Z level =INFO msg = " MCP method completed " method =initialize session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ duration_ms = 0 has_result =true time =2025-11-12T07:19:02.821Z level =INFO msg = " MCP method started " method =notifications/initialized session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ has_params =true time =2025-11-12T07:19:02.821Z level =INFO msg = " MCP method completed " method =notifications/initialized session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ duration_ms = 0 has_result =false 2025 / 11 / 12 07:19:02 POST / from 192 . 168 . 65 .1:49815 - > 202 ( 64 .833µs ) 2025 / 11 / 12 07:19:02 POST / from 192 . 168 . 65 .1:62785 - > 200 ( 436 .167µs ) time =2025-11-12T07:19:02.825Z level =INFO msg = " MCP method started " method =logging/setLevel session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ has_params =true time =2025-11-12T07:19:02.825Z level =INFO msg = " MCP method completed " method =logging/setLevel session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ duration_ms = 0 has_result =true time =2025-11-12T07:19:07.384Z level =INFO msg = " MCP method started " method =tools/list session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ has_params =true time =2025-11-12T07:19:07.385Z level =INFO msg = " MCP method completed " method =tools/list session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ duration_ms = 0 has_result =true 2025 / 11 / 12 07:19:07 POST / from 192 . 168 . 65 .1:62785 - > 200 ( 1 .72925ms ) time =2025-11-12T07:19:09.673Z level =INFO msg = " MCP method started " method =tools/call session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ has_params =true time =2025-11-12T07:19:09.673Z level =INFO msg = " Calling tool " name =greet args = " { \" name \" : \" aaa \" } " time =2025-11-12T07:19:09.673Z level =INFO msg = " MCP method completed " method =tools/call session_id =3XXMTTX77WN7EGTJXI3GRGMGFQ duration_ms = 0 has_result =true time =2025-11-12T07:19:09.673Z level =INFO msg = " tool result " isError =false structuredContent = " { \" greeting \" : \" Hi aaa \" } " 2025 / 11 / 12 07:19:09 POST / from 192 . 168 . 65 .1:62785 - > 200 ( 1 .143916ms ) step 3 サーバーをホスティングしていきます。 色々な方法でホスティングすることはできますが、今回はドキュメントも豊富だった cloud run を使っていこうと思います。試してはいませんが、aws の ecs などでもホスティングはできるかと思います。 # ログイン gcloud auth login # プロジェクトの選択(自分が所属しているプロジェクトを選択してください) gcloud config set project { sample-project } # イメージの作成 docker build --platform linux/amd64 -t gcr.io/ { sample-project } /mcp-greeter:latest . # イメージのアップロード docker push gcr.io/ { sample-project } /mcp-greeter:latest # cloud runにデプロイ(値は適当です) gcloud run deploy mcp-greeter --image gcr.io/ { sample-project } /mcp-greeter:latest --region asia-northeast1 --project { sample-project } --platform managed --no-allow-unauthenticated --memory 512Mi --max-instances 3 cloud run にデプロイするときに --no-allow-unauthenticated フラグをつけることで認証を強制させることができます。MCP サーバーに認証機能をつけたいけど、まだ実装できていない時につけると良さそうです。 先ほどのデプロイ時にはフラグをつけているのでそのままだと動作確認できないはずです。デプロイ時に吐き出された URL を inspector に貼り接続を確認してみてください。 gcloud auth print-identity-token コマンドを実行して token を取得しましょう。取得した token を inspector の Authorization に埋め込むことでリモート MCP サーバーに接続することができます。 (mcp.json に貼る場合は以下の通り) { " mcp-greeter ": { " url ": " https://sample-url ", " headers ": { " Authorization ": " Bearer xxx " } } } これでリモート MCP サーバーの作成は終了です。適宜ソースコードの内容を変えたり、ホスティングの方式を変えたりしてユースケースにあったリモート MCP サーバーを作成していけるかと思います。 以上です!お疲れ様でした! 最後に 実際にリモート MCP 化してみて、mcp/go-sdk は通信方式が変わってもアプリケーションコードへの影響を最小限に抑え、簡単にツールを拡張できるよう設計されていることがわかりました。また簡単に MCP サーバーをリモート化できるということを学べたので業務で活用できそうなケースがあればどんどん MCP サーバーを作っていきたいなと思います。 ただ、シンプルな MCP サーバーなら簡単にデプロイできそうだなと思いつつ、まだ以下のような課題はあると思っています。 認証まわり 今だと gcloud に権限がある人しかアクセスできない oauth などで認証を突破させたい 機密情報まわり API Key を使ってアクセスしたいリソースがある場合、人によって変わる複数の API Key をどうやって管理すれば良いか こういったケースのことはまだ考えられていないので引き続き色々と試してユースケースに合わせた MCP サーバーを構成できるようにしていきたいです! もしいい感じに課題を解決できそうな方がいましたら気軽に ↓ の repo に PR や issue で教えていただけますと幸いです! github.com
アバター
はじめに こんにちは、リテールハブ開発部の杉森です。 近年、Playwright MCPを使ってブラウザ操作やテストを自然言語経由で実施している事例が多数見られるようになりました。その流れを見ていて、「これをFlutterアプリでも実現できないか?」と考えるようになりました。 調査を進める中で、 Maestro MCP という選択肢があることを知り、実際にFlutterアプリのE2Eテストを試してみました。本記事では、その取り組みについて紹介します。 Maestroとは Maestro(マエストロ)は、モバイルアプリケーション向けのE2Eテスト自動化フレームワークです。iOS、Android、React Native、Flutterなど、幅広いプラットフォームに対応しています。 主な特徴: シンプルなYAML記法でテストシナリオを記述 クロスプラットフォーム対応(iOS/Android両方で同じテストを実行可能) 直感的なコマンドでタップ、スワイプ、入力などの操作を記述 Maestroは、複雑なセットアップを必要とせず、短期間でE2Eテストを構築できることが大きな魅力です。 参考リンク Maestro公式サイト Maestro GitHub Maestro MCPとは Maestro MCPは、Model Context Protocol(MCP)に対応したMaestroのサーバー実装です。MCPは、AIモデルとツールを接続するための標準プロトコルで、Claude Codeなどのツールから自然言語でMaestroの機能を呼び出せるようになります。 従来は、Maestro YAMLファイルを手動で記述してテストを実行する必要がありましたが、Maestro MCPを使うことで、自然言語でテストシナリオを記述するだけで、AIエージェントが適切にテストを実行してくれます。 (備考)Maestro MCPで利用可能なTools 以下は、2025年11月10日時点でMaestro MCPで利用可能なToolsです。 カテゴリ ツール 説明 デバイス管理 list_devices 接続可能なシミュレータ/エミュレータの一覧を取得する start_device 指定したデバイスを起動する アプリ操作 launch_app 指定したバンドルID/パッケージ名のアプリを起動する stop_app 実行中のアプリを停止する UI操作 tap_on テキスト、ID、座標などで指定した画面要素をタップする input_text フォーカスされたテキストフィールドに文字列を入力する back デバイスの戻るボタンを押す(Androidのみ有効) 情報取得 take_screenshot 現在の画面をPNG画像として取得する inspect_view_hierarchy 画面のUI階層を取得する テスト実行 run_flow Maestro YAMLフローをインラインで記述して実行する run_flow_files 既存のMaestro YAMLファイルを読み込んで実行する check_flow_syntax Maestro YAMLフローの構文が正しいかを検証する ドキュメント cheat_sheet Maestroの基本コマンドと構文例を含むチートシートを取得する query_docs Maestro公式ドキュメントに質問を投げて関連情報を取得する インストール方法 Maestro MCPを使用するには、以下の準備が必要です。 1. Maestroのインストール Maestroをシステムにインストールします。 # Java 17以降が必要です(インストールされていない場合は別途インストールしてください) java -version # Homebrewを使用する場合(macOS) brew tap mobile-dev-inc/tap brew install mobile-dev-inc/tap/maestro # Curlを使用する場合(Linux/macOS) curl -Ls "https://get.maestro.mobile.dev" | bash 参考リンク Maestro公式ドキュメント - Installing Maestro 2. Maestro MCPの設定 Claude Codeの設定ファイルにMaestro MCPサーバーの設定を追加します。 { " mcpServers ": { " maestro ": { " command ": " maestro ", " args ": [ " mcp " ] } } } 設定後、Claude Codeを再起動することで、Maestro MCPが利用可能になります。 参考リンク Maestro公式ドキュメント - Installing Maestro MCP 検証 テスト対象アプリの概要 今回は、手動でテストを実施していたFlutterアプリに対して、Maestro MCPを使った自動テストをiOSシミュレータを利用して試してみました。 検証したテストの一例 既存のテスト内容 ・アプリを起動(初期化状態) ・オンボーディングの実施 ・ホーム画面の表示 ・ボトムナビゲーションから「設定ボタン」を押下 ・「お気に入り店舗ボタン」を押下 (省略) ・各画面で設定されている店舗情報が反映されているかを確認する 指示内容 MCPを利用してテストを実施するために下記プロンプトを作成しました。 [テスト内容] ・アプリを起動(初期化状態) ・オンボーディングの実施 ・ホーム画面の表示 ・ボトムナビゲーションから「設定ボタン」を押下 ・「お気に入り店舗ボタン」を押下 (省略) ・各画面で設定されている店舗情報が反映されているかを確認する [環境情報] appId: xxxxxxxxxxxx [前提条件] アプリを初期状態(キャッシュクリア)から起動 [確認したいこと] ・各画面で設定されている店舗情報が反映されているかを確認する [スクリーンショット] 各画面のスクリーンショットを/xxxxx/yyyy-mm-dd_hhmmss/*に保存してください。 [注意事項] 必ず、maestro-mcp-testing-guide.md を参照してからテストを行ってください。 ※ maestro-mcp-testing-guide.md は、テスト実施時のノウハウやエラー対処法を蓄積した学習ガイドファイルです。詳細は後述の「3. 実行時のエラーと学習プロセスの整備」で説明します。 結果 手動で実施していたテスト内容を自然言語ベースの指示だけで実施・確認できた 証跡として各画面のスクリーンショットを取得できた 困った点と対応内容 想定通りテストを実施することができたのですが、Maestro MCPを使う上で、何点か詰まる箇所がありました。以下、遭遇した問題と対応策を紹介します。 1. 一部のMCP Toolが利用できない 問題 Maestroに関する情報を取得する際に利用される、 cheat_sheet と query_docs を実行した際に下記エラーが発生してしまいました。 Error: MAESTRO_CLOUD_API_KEY environment variable is required 対応策 Maestro Studio Desktopをインストールすると、APIキーの指定なしで利用できるようです。 参考リンク Maestro公式ドキュメント - Maestro Studio Desktop 2. tap_onで押下できないウィジェットがある 問題 特定のボタン押下ができず、run_flowを利用した座標ベースのボタン押下処理を実施するケースが発生していました。 対応策 Maestro MCPに限らず、一般的なE2Eテストでも同様ですが、ウィジェットを識別しやすい状態にしておかないと、ボタンを押下する際に、座標情報でボタン押下等を実施する必要が出てきます。 Flutterの Semantics ウィジェットを活用し、各UI要素に適切なラベルや識別子を付与することが重要です。 例) Semantics( label: 'ログインボタン' , button: true , child: ElevatedButton( onPressed: _handleLogin, child: Text( 'ログイン' ), ), ) 参考リンク Maestro公式ドキュメント - Flutter Support 3. 利用可能な操作が限定的 問題 Maestro MCPで直接実行できる操作には制限があり、以下の操作で困ることがありました: スクリーンショットの保存先指定 : take_screenshot コマンドでは、指定したフォルダに保存することができない 画面録画 :Maestro MCPには画面録画機能がない スワイプ操作 :Toolsだけだと、tap_onやinput_textなどの最低限の操作しか実施できない。 対応策 これらの制約に対して、以下のアプローチを取りました: スクリーンショット保存の場合 xcrun simctl を使用して、スクリーンショットを指定したパスに保存できるようにしました。 例) # iOSシミュレータのスクリーンショットを指定パスに保存 xcrun simctl io <device_id> screenshot /xxxxx/yyyy-mm-dd_hhmmss/screenshot.png 画面録画の場合 同様に xcrun simctl を使用して、画面を録画することができました。 ですが、Claude Code Sonnet 4.5でテストを実行した場合、AIエージェントの処理時間が含まれるため、録画時間がどうしても長くなってしまいます。そのため、個人的にはあまり実用的ではないと感じております。 例) # 録画を開始(バックグラウンド実行) xcrun simctl io <device_id> recordVideo xxxxx/yyyy-mm-dd_hhmmss/xxxxx.mov & # 操作を実行... # 録画を正常終了(バックグラウンド処理の停止) スワイプ操作の場合 run_flow コマンドを使用して、Maestro YAMLを直接記述するように指示をして対応しました。 例) - swipe : direction : UP duration : 500 4. 実行時のエラーと学習プロセスの整備 問題 実行時には、スムーズにテストが実施されないことが何点かありました: run_flow 内のコマンドのsyntaxエラー 画面要素の識別ミス 適切に動作しないmaestroコードの生成 これらの問題はAIエージェントが自律的に解決をしてくれるのですが、再度別プロセスでテストを実施した際に同じミスを繰り返すケースが見受けられました。 対応策 テスト実施時に詰まった内容と解決方法を maestro-mcp-testing-guide.md に適宜蓄積していくアプローチを取りました。テスト実行時に作成したmdファイルを参照してもらうことで、同じエラーを繰り返さないようにしています。 以下のような情報を蓄積しています: よくあるミスと対処法 各画面での要素の識別方法 テストパターンのサンプル 参考するべきMaestro関連のリンク集 今後の展望 今回はテストケースをこちらから提供する形でしたが、今後は以下のような取り組みにも挑戦したいと考えています: テスト設計からの自動化 :機能仕様から自動的にテストケースを生成 探索的テストの実施 :AIに自由にアプリを操作させ、潜在的なバグを発見 回帰テストの整備 :既存機能の動作を継続的に確認する自動テストスイートの構築 これらの取り組みは、今回のようにテストケースを事前に提供する形よりも、より効果的なテスト自動化を実現できる可能性があると考えています。 まとめ Maestro MCPを使ったFlutterアプリのE2Eテスト自動化について紹介しました。 モバイルアプリの自動テストに興味がある方は、ぜひMaestro MCPを試してみてください!
アバター
はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 1ヶ月前にGo Conference 2025があり色々と面白い発表があったのですが、その中にGo言語のガベージコレクションについての発表がありました。 ガベージコレクションについてやGo言語におけるガベージコレクションの動作について、学習したことがなかったため自分の知識を整理するという意味を込めてまとめられればと思います。 本記事の多くはGoのガベージコレクションの公式ドキュメントを参考にしているので合わせて確認いただければと思います。 tip.golang.org ガベージコレクションの基礎 GCとは何か ガベージコレクション(GC)とは、プログラムが動的に割り当てたメモリの中で、プログラムが将来アクセスしないと判断された領域(ガベージ)を自動的に検出し、解放して再利用するメモリ管理の仕組みです。 GCの最大の利点は、プログラマが手動でのメモリ確保・解放を行う必要がなくなることです。これにより、 メモリリーク や 二重解放 などの深刻なメモリ関連バグを根本的に防止し、システムの安定性が向上します。 Goの値が格納される場所 GCの中身の話に移る前にGoの値が格納される場所について確認したいと思います。 Goの値は主に スタック と ヒープ の2つの場所に格納されるのですが、動的にメモリが割り当てられコンパイラから解放のタイミングを決定できないヒープ領域がGCの対象となります。 スタック : GC管理対象外、コンパイラが解放タイミングを決定 ヒープ : GC管理対象、動的メモリ割り当ての場所 GCの種類 GCにはいくつか種類があるのですが、Go言語では マーク・スイープ方式 を採用しています。 方式 動作の概要 メリット デメリット Go採用 参照カウント 各オブジェクトに参照数を記録し、0で即時解放 レイテンシが低い 循環参照を検出できない ❌ マーク・スイープ ルートからポインタを辿り、到達可能オブジェクトをマーク 循環参照を処理可能 断片化が発生 ✅ コピーGC 生存オブジェクトのみを別領域にコピーし、元の領域を解放 断片化なし ヒープの半分しか利用不可 ❌ Go言語のGCの動作原理 GCサイクル(3つのフェーズ) Go言語のGCは主に以下の3つのフェーズに分かれており、3つのフェーズを繰り返すことで動作しています。 スイープフェーズ : 以前のサイクルで生存オブジェクトとしてマークされていないメモリを、新しい割り当てのために利用可能にする オフ : GCが非アクティブな期間 マークフェーズ : 生存オブジェクトを識別しマークする マークフェーズ 3つのフェーズの中でもマークフェーズに関しては動作の仕組みで2つポイントがあるので押さえて置けたらと思います。 三色マーキングアルゴリズム Go言語のGCでは 三色マーキングアルゴリズム を採用し、すべてのオブジェクトを3つの色に分類し、その色によってスイープフェーズでメモリを解放するかを決定します。 色 状態 意味 処理 白 未到達 まだスキャンされていない 回収対象 グレー 到達済み・未スキャン 到達したが子要素未確認 スキャン待ち 黒 到達済み・スキャン完了 すべての子要素も確認済み 生存確定 ライトバリア GoのGCでは、アプリケーションの実行中にGCが動作するため、本来マークされなければならないオブジェクトがマークされない可能性があります。これを解決するために ライトバリア と呼ばれる仕組みを利用して、参照先を即座にグレーに変更することで、オブジェクトの見落としを防いでいます。 GreenTea Garbage Collector 最後にGreenTea Garbage Collector(GreenTea GC)について少しだけ触れておこうかと思います。 従来のGCのマークアルゴリズムだとオブジェクトの位置を考慮せず局所性が低いことがパフォーマンス上の問題になっていました。そこで1.25からGreenTea Garbage Collectorが実験的に導入されました。 ここでは、GreenTea Garbage Collectorの詳細には触れないため、気になった方は参考文献のIssueやスライドをご確認いただければと思います。 GreenTea GCでなにが変わったか 従来のGCでは局所性に問題があったため、GreenTea GCでは局所性の課題の解消を目標にしています。そのためにマークフェーズにおけるマークの単位をオブジェクトからスパン(オブジェクトを格納するメモリブロックの単位)で行うように変更されています。厳密にはスパン単位とオブジェクト単位のマークを組み合わせたり、スパンのスパースによって処理を分岐したりとスパン単位でのマークを追加したのに合わせて、効率よく処理が行われるように処理を分岐させているようです。 まとめ 今回はGo言語でのGCを中心にガベージコレクションについて学んだことをまとめてみました。ガベージコレクションがどんなことをするものなのか概要については知っていましたが、具体的な動き方については知らなかったのでこの機会に勉強できてよかったかと思います。 また、学習の中でGo言語のGCのランタイムを追ってみたのですが、実際のコードを追ってみることで内容を理解する助けにもなったので、機会を見つけて別のランタイムのコードを追ってみようかと思いました。 参考文献 Go GC Guide - A Guide to the Go Garbage Collector Go Runtime Source runtime: green tea garbage collector 最速Green Tea 🍵 Garbage Collector
アバター
はじめに こんにちは、エブリーでサーバーサイドをメインに担当している清水です。 私のチームではPHP, Laravelを使用して小売店向けのSaaS側Webサービスの開発を行っています。 過去の記事 でご紹介した通り、 私たちはモノレポの構成を採用しており、リポジトリの中身は大きく3つに分けることができます。 モバイルアプリ向け(mobile-api) 管理画面向けAPI (dashboard-api) 両APIで共通の部分(共通パッケージやGitHub Actionsの設定ファイルなど) 過去に 本ブログで紹介 されたDev Containersを私たちのチームでも導入検討を行いましたので、本記事でご紹介いたします。 開発するときに感じていた課題 開発環境は Docker を使って整備していたものの、実際の現場では次のような課題を感じていました。 新メンバーの環境構築に時間がかかる ローカルのIDEで使用するPHPのインストール、gitの設定など必要な手順が複数あり、ドキュメントを読んでも初回セットアップで詰まるケースがありました。 ※アプリ自体は Docker コンテナ上で動作していたものの、ローカルの IDE(VS Code)で補完や静的解析を有効にするため、ローカルにも PHP をインストールする必要がありました。 ローカルを汚したくない PHP等のプロジェクト固有の開発に必要なものをローカルに直接インストールしたくない、という意見もありました。 PCを変更するときにいろいろインストールし直さなければならなくなる手間が増えることといった問題も起こりえます。 VS Code の拡張機能や設定など、開発環境がメンバーによって違う 特に「どの拡張機能を入れるべきか」が明文化されておらず、新規メンバーが最初に迷いやすい状態でした。 「そんな便利な拡張機能あったの!?」みたいなことが起きることもあります。 Dev Containers導入で期待できること 「誰が開いても同じ環境になる」再現性 各メンバーが VS Code で “Reopen in Container” するだけで PHP やその他ツールのバージョン、拡張機能が完全に一致します。 環境構築を自動化できる postCreateCommand / postAttachCommand .env 作成、composer install、DB マイグレーション、GitHub 認証など、手作業になりがちな初期セットアップを自動化できます。 私たちはこれらの作業をmakeコマンドを利用することで部分的に自動化できていましたが、さらに楽できそうです。 チーム全体で同じ VS Code 拡張をプリセット Intelephense, Xdebug, Namespace Resolver など、Laravel 開発に必要な拡張を共通化し、環境差異を解消しました。 postCreateCommand / postAttachCommand とは postCreateCommand: コンテナが 作成直後またはリビルド直後 に実行されるコマンドを定義する機能 postAttachCommand: 既存のコンテナに VS Code/Dev Containers が接続(アタッチ)されたときに実行されるコマンドを定義する機能 参考: https://containers.dev/implementors/json_reference/#lifecycle-scripts devcontainer.jsonに以下のように設定すると起動時の操作を自動化できる // 初回セットアップ " postCreateCommand ": " bash mobile-api/.devcontainer/scripts/postCreateCommand.sh ", // 再接続ごとの軽い同期 " postAttachCommand ": " bash mobile-api/.devcontainer/scripts/postAttachCommand.sh ", 導入にあたって悩んだこと・着地点 1. モノレポ全体を開発するためのコンテナを新しく作るか、既存のコンテナにDev Containersを入れるか 最初に悩んだのは、「Dev Containers専用のコンテナを新規に構築するか、それとも既存のコンテナを流用するか」でした。 リポジトリはもともと Docker Compose ベースで構築されており、すでにアプリケーションやDBなどの実行環境が整っていました。 そのため、「開発用に新しくコンテナを作り直すのは時間がかかるうえに、既に動いている環境を追加で作成するのも冗長ではないか」という懸念がありました。 まずはお試しとして、 既存の mobile-api コンテナに .devcontainer を追加する形 でDev Containers を導入してみることにしました。 2. モノレポ構成ゆえに、コンテナ内から外側を編集できずに困った 既存の mobile-api に .devcontainer を追加して起動してみたところ、思わぬ落とし穴がありました。 mobile-api はモノレポの一部のディレクトリであり、その外側には共通パッケージ ( packages/common ) や GitHub Actions の設定が配置されています。 しかし、Dev Container のマウント対象が mobile-api 直下だけだったため、コンテナ内からリポジトリ外側のファイルを編集できない状況でした。 たとえば、API 側で共通パッケージの修正をしたいときに、コンテナを立ち上げたままでは packages/common にアクセスできず、いったんホスト側で開き直す必要がありました。 モノレポで開発しているからこそ、API側と一緒に共通パッケージを編集したいケースも多いのでかなり悩みました。 最終的には、Dev Container のワークスペースマウントをリポジトリ全体( /workspaces )に変更することで解決しました。 これにより、 mobile-api ・ dashboard-api ・ packages のすべてをコンテナ内から一貫して操作できるようになりました。 devcontainer.jsonに以下のような内容を追加します " workspaceFolder ": " /workspaces ", " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached " , ] , 3. VS CodeのターミナルからGitHubに接続できない コンテナ内はローカルとは異なる環境なのでGit関連の設定が必要です。 Git操作だけローカルでやればいいのではないか?とも思いましたが、VS Code上の操作ができないことはかなり不便に感じたので対応が必要でした。 devcontainer.json内に git / GitHub CLI のインストール設定を追加 " features ": { " ghcr.io/devcontainers/features/git:1 ": {} , " ghcr.io/devcontainers/features/github-cli:1 ": {} } コンテナ内でgh auth loginを叩いてGitHub認証を行う形を採用 postAttachCommandで以下のコマンドを自動的に実行する形にしました gh auth login --hostname github.com --git-protocol https --web && gh auth setup-git 開き直すたびに認証を求められないように、GitHub CLIの認証情報は名前付きボリュームに保存する形を採用しました " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached ", " source =gh_config, target =/ root /. config / gh , type = volume " //GitHub用のボリューム ] , どうしても原因がわからないのですが、自動実行の場合のみ以下のようなエラー表示になるので、手動でブラウザでURLを開いて認証する形で妥協しました Running the postAttachCommand from devcontainer.json... [devcontainer-postattach] start [devcontainer-postattach] vendor present -> skip install [devcontainer-postattach] gh not logged in -> launching web login ! First copy your one-time code: XXXX-XXXX Press Enter to open https://github.com/login/device in your browser... /usr/bin/xdg-open: 1032: www-browser: not found /usr/bin/xdg-open: 1032: links2: not found /usr/bin/xdg-open: 1032: elinks: not found /usr/bin/xdg-open: 1032: links: not found /usr/bin/xdg-open: 1032: lynx: not found /usr/bin/xdg-open: 1032: w3m: not found xdg-open: no method available for opening 'https://github.com/login/device' ! Failed opening a web browser at https://github.com/login/device exit status 3 Please try entering the URL in your browser manually 最終的なdevcontainer.jsonの内容 { " name ": " mobile-api devcontainer ", // ルートの compose.yml を使用 " dockerComposeFile ": " ../compose.yml ", " service ": " mobile-api ", // モノレポ全体を作業対象に " workspaceFolder ": " /workspaces ", // 一緒に起動するサービス " runServices ": [ " mobile-api ", " database " ] , // VS Code 設定 " settings ": { " terminal.integrated.defaultProfile.linux ": " bash ", " git.openRepositoryInParentFolders ": " always " } , // 推奨拡張機能 " extensions ": [ " bmewburn.vscode-intelephense-client ", " xdebug.php-pack ", " mehedidracula.php-namespace-resolver ", " ms-azuretools.vscode-docker " ] , // コンテナ機能(git と GitHub CLI をインストール) " features ": { " ghcr.io/devcontainers/features/git:1 ": {} , " ghcr.io/devcontainers/features/github-cli:1 ": {} } , // モノレポ全体を /workspaces にマウント // gh CLI の認証情報は volume で永続化 " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached ", " source=gh_config,target=/root/.config/gh,type=volume " ] , // 初回セットアップ " postCreateCommand ": " bash mobile-api/.devcontainer/scripts/postCreateCommand.sh ", // 再接続ごとの軽い同期 " postAttachCommand ": " bash mobile-api/.devcontainer/scripts/postAttachCommand.sh ", // コンテナユーザー " remoteUser ": " root " } ※部分的にブログ用の内容に書き換えてあります おわりに Dev Containers を導入したことで、VS Code を開いて GitHub の認証を行うだけで、すぐに開発できる環境が整うようになりました。 これまで初期セットアップに時間を取られていた部分が一気に自動化され、特に新メンバーのオンボーディングが大幅に楽になったのではないかと感じています。 まだ本格的な運用段階には入っておらず、今後チーム全体で利用を進めていく中で、思わぬ問題が出てくる可能性もあります。 運用していくうちに「やはり Dev Containers 専用のコンテナを新規に構築した方がよかったかもしれない」と感じる場面が出てくるかもしれない気がしています。 新たに気付くことがあればまた本ブログで紹介したいと思います。 最後までお読みいただきましてありがとうございました。
アバター
はじめに デリッシュキッチンのiOSアプリを開発している成田です。 2025年10月24日にSwift SDK for Androidのプレビュー版がリリースされました( Announcing the Swift SDK for Android )。 📣Announcing the first preview releases of Swift for Android, enabling you to build Android business logic with the same Swift that you use for Apple platforms. https://t.co/UAR6LO3prQ #Android pic.twitter.com/QNKY2bCrFi — Swift Language (@SwiftLang) 2025年10月24日 Swift SDK for Androidは、Swiftで書いたコードをAndroid向けにビルド・実行できるようにするためのSDKです。 今回は、そんなSwift SDK for Androidを使ったサンプルアプリの1つを実際に動かしてみながら、その応用としてiOSアプリ内の汎用的な文字列バリデーションロジックを抽出し、Androidアプリで使ってみようと思います。 サンプルプロジェクトを起動してみる サンプルアプリを動かす前に、Swift SDK for Androidのセットアップが必要です。 公式ガイド を参考に、Host Toolchain、Swift SDK、Android NDKのセットアップを行いました。詳細な手順は他の記事でも紹介されているため、ここでは省略します。 今回はサンプルアプリとして、 swift-android-examples リポジトリの hello-swift-raw-jni を動かしてみました。 Hello from Swift ❤️ という文字列が画面の中央に表示される非常にシンプルなアプリになっています。 調べてみると、確かに helloswift.swift というSwiftファイルがあり、Swiftで書かれたコードが呼ばれているのが分かります。 import Android @_cdecl("Java_org_example_helloswift_MainActivity_stringFromSwift") public func MainActivity_stringFromSwift(env: UnsafeMutablePointer<JNIEnv?>, clazz: jclass) -> jstring { let hello = ["Hello", "from", "Swift", "❤️"].joined(separator: " ") return hello.withCString { ptr in env.pointee!.pointee.NewStringUTF(env, ptr)! } } このコードでは、以下の処理を行っています。 @ cdecl属性: JNIの命名規則に合わせた関数名を指定します。Java {パッケージ名} {クラス名} {メソッド名}という形式で、Kotlin側のMainActivity.stringFromSwift()メソッドに対応します。 JNI環境パラメータ: envはJNIのAPIを呼び出すために必要な環境へのポインタ、clazzは呼び出し元のJavaクラスです。 文字列の生成と変換: Swiftの文字列を配列から生成し、withCStringでC文字列に変換した後、NewStringUTFを使ってJavaのjstring型に変換して返します。 このように、生のJNIを使う場合は、Swift側でJNIのAPIを直接使ってJava/Kotlin側の型と相互変換する必要があります。 JNIについて JNI(Java Native Interface)は、Java/KotlinからC/C++を呼び出すための標準的なインターフェースです。 上記の例では、KotlinからC/C++をJNI経由で呼び出し、さらにそのC/C++からSwift関数を呼び出しています。 バリデーションロジックをAndroidで使えるようにする手順 概要 今回は、以下のような汎用的なバリデーションロジックを移行することを考えます。例えば、iOS側では以下のようにメールアドレスとパスワードの、正規表現を使ったバリデーションロジックを実装していたとします: iOS側での使用例(Swift): import Foundation extension String { func isValidEmail () -> Bool { let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" return NSPredicate(format : "SELF MATCHES %@" , emailRegex).evaluate(with : self ) } func isValidPassword () -> Bool { let passRegax = "^[a-zA-Z0-9^$*.\\[\\]{}()?\\-\"!@#%&\\/\\,><':;|_~`=+]{8,256}$" return NSPredicate(format : "SELF MATCHES %@" , passRegax).evaluate(with : self ) } } この汎用的なバリデーションロジックをAndroidでも使えるようにするため、Swift SDK for Androidを使用して移行していきます。 実装手順 実装は以下の7つのステップで進めます: Swiftライブラリの作成: Swift Packageとして StringValidator を実装します swift-java.config の設定: Javaラッパー生成のための設定ファイルを作成します build.gradle の設定: SwiftビルドとJavaラッパー生成の設定を追加します swiftkit-core の公開: 必要なJavaパッケージをローカルMavenリポジトリに公開します Androidアプリの作成: validation-app を作成し、 validation-lib への依存関係を追加します KotlinからSwift関数を呼び出す: 生成されたJavaラッパーを使ってKotlinから呼び出します UI実装: バリデーションをテストするためのメールアドレスとパスワードのフォームを作成します それでは、各ステップの詳細を見ていきましょう。 1. Swiftライブラリの作成 サンプルプロジェクトには既に hello-swift-java ディレクトリ配下に hashing-lib / hashing-app という例が含まれています。これらをテンプレートとして、同じディレクトリ配下に validation-lib ディレクトリを作成し、Swift Packageとして StringValidator を実装してみます。 Package.swiftの設定 // swift-tools-version: 6.1 import CompilerPluginSupport import PackageDescription let package = Package( name : "StringValidator" , platforms : [ .macOS ( .v15 )] , products : [ .library ( name: "StringValidator" , type: .dynamic , targets: [ "StringValidator" ]) ] , dependencies : [ .package ( url: "https://github.com/swiftlang/swift-java" , branch: "main" ) , ] , targets : [ .target ( name: "StringValidator" , dependencies: [ .product ( name: "SwiftJava" , package: "swift-java" ) , .product ( name: "CSwiftJavaJNI" , package: "swift-java" ) , ] , plugins: [ .plugin ( name: "JExtractSwiftPlugin" , package: "swift-java" ) ] ) , ] ) StringValidator.swiftの実装 元のiOSコードでは extension String として実装されていましたが、 swift-java プラグインが自動的にJavaラッパーを生成するため、トップレベルの public func として実装します: import Foundation import SwiftJava public func isValidEmail (_ email : String ) -> Bool { let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" return matches(pattern : emailRegex , string : email ) } public func isValidPassword (_ password : String ) -> Bool { guard password.count >= 8 && password.count <= 256 else { return false } let passRegex = "^[a-zA-Z0-9^$*.\\[\\]{}()?\\-\"!@#%&\\/\\,><':;|_~`=+]+$" return matches(pattern : passRegex , string : password ) } private func matches (pattern : String , string : String ) -> Bool { guard let regex = try ? NSRegularExpression(pattern : pattern , options : [] ) else { return false } let range = NSRange(location : 0 , length : string.utf16.count ) return regex.firstMatch( in : string , options : [] , range : range ) != nil } iOSコードとの違い iOS側: extension String で isValidEmail() や isValidPassword() として実装( "test@example.com".isValidEmail() のように呼び出し) Android側: トップレベルの public func として実装( isValidEmail("test@example.com") のように呼び出し) swift-java プラグインはトップレベルの public func に対してJavaラッパーを生成するため、この形式で実装しています。機能的には同じバリデーションロジックを提供します。 2. swift-java.configの設定 swift-java プラグインの設定ファイルを作成します。このファイルは Sources/StringValidator/ ディレクトリに配置します: { " javaPackage ": " com.example.stringvalidator ", " mode ": " jni " } javaPackage : 生成されるJavaラッパーのパッケージ名を指定 mode : jni モードを指定(JNI経由でSwift関数を呼び出す) 3. build.gradleの設定 hashing-lib の build.gradle を参考に、SwiftビルドとJavaラッパー生成の設定を追加します。 主な設定内容 Swift SDKのパス設定: getSwiftlyPath() と getSwiftSDKPath() 関数でSwift SDKとSwiftlyのパスを自動検出します。 全ABI向けのビルドタスク: arm64-v8a、armeabi-v7a、x86_64の3つのABI向けにSwiftコードをビルドします。各ABIごとに buildSwift${abi} タスクが作成されます。 Swiftランタイムライブラリのコピー: Swiftランタイムライブラリ( swiftCore 、 Foundation など)を自動的にコピーします。 生成されたJavaファイルのソースディレクトリへの追加: swift-java プラグインが生成したJavaラッパーファイルを、Androidライブラリのソースセットに追加します。 build.gradle の主要な設定は以下の通りです: plugins { alias(libs.plugins.android.library) } android { namespace "com.example.validationlib" compileSdkVersion 34 defaultConfig { minSdkVersion 28 } } dependencies { implementation( 'org.swift.swiftkit:swiftkit-core:1.0-SNAPSHOT' ) } // Swift SDKのパスを取得する関数 def getSwiftlyPath() { // 環境変数またはgradle.propertiesから取得 // または一般的なパスを検索 } def getSwiftSDKPath() { // Swift SDKのパスを取得 } // ABI定義 def abis = [ "arm64-v8a" : [triple: "aarch64-unknown-linux-android28" , .. .], "armeabi-v7a" : [triple: "armv7-unknown-linux-android28" , .. .], "x86_64" : [triple: "x86_64-unknown-linux-android28" , .. .] ] // 全ABI向けにSwiftビルドタスクを作成 def buildSwiftAll = tasks. register ( "buildSwiftAll" ) { inputs.file( new File(projectDir, "Package.swift" )) inputs.dir( new File(projectDir, "Sources/StringValidator" )) // ... } abis. each { abi, info -> def task = tasks. register ( "buildSwift ${abi.capitalize()} " , Exec) { workingDir = layout.projectDirectory executable(getSwiftlyPath()) args( "run" , "swift" , "build" , "+ ${swiftVersion} " , "--swift-sdk" , info.triple) } buildSwiftAll.configure { dependsOn(task) } } // 生成されたJavaファイルとJNIライブラリをソースセットに追加 android { sourceSets { main { java { srcDir(buildSwiftAll) } jniLibs { srcDir(generatedJniLibsDir) } } } } preBuild.dependsOn(copyJniLibs) 4. swiftkit-coreの公開 swift-java プロジェクトは、SwiftからJava/Kotlinへのラッパー生成に必要なJavaパッケージ( swiftkit-core など)をまだ公式のMavenリポジトリに公開していません。そのため、Androidプロジェクトで利用するには、ローカルMavenリポジトリに公開して参照可能にする必要があります。 SwiftやiOS開発での例に置き換えると、CocoaPodsやSwift Package ManagerでまだGitHubや公式リポジトリに公開されていないライブラリをローカルパスから利用するのと同じイメージです。Mavenリポジトリは、Java/Kotlinのライブラリを配布する仕組みで、SPMやCocoaPodsリポジトリのようなものです。 以下のコマンドをターミナルで実行します: $ cd hello-swift-java/validation-lib $ swift package resolve $ ./.build/checkouts/swift-java/gradlew --project-dir .build/checkouts/swift-java :SwiftKitCore:publishToMavenLocal 5. Androidアプリの作成 validation-lib と同じディレクトリ配下( hello-swift-java ディレクトリ配下)に validation-app ディレクトリを作成し、 validation-lib への依存関係を追加します。 Androidアプリのビルドと依存関係の管理には、Gradleというビルドツールを使用します。iOS開発でSwift Package Manager (SPM)の Package.swift やCocoaPodsの Podfile で依存関係を定義するのと同様に、Androidでは build.gradle.kts ファイルで依存関係を定義します。また、Xcodeプロジェクトの .xcodeproj で設定を管理するのと同様に、Gradleでは build.gradle.kts でアプリの設定と依存関係を一括で管理します。 build.gradle.ktsの設定 plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } android { namespace = "com.example.validationapp" compileSdk = 36 defaultConfig { applicationId = "com.example.validationapp" minSdk = 28 targetSdk = 36 } buildFeatures { compose = true } } dependencies { implementation(project( ":hello-swift-java-validation-lib" )) // Jetpack Compose関連の依存関係 implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.material3) // ... } 6. KotlinからSwift関数を呼び出す 生成されたJavaラッパーを使って、KotlinからSwift関数を呼び出します。 swift-java プラグインが自動的に生成した StringValidator クラスを使用します: import com.example.stringvalidator.StringValidator val isValid = if (email.isNotEmpty()) { StringValidator.isValidEmail(email) } else null このコードでは、 swift-java プラグインが自動生成した StringValidator クラスの静的メソッド isValidEmail() を呼び出しています。 生成されたJavaラッパーは、Swiftのトップレベル関数をJava/Kotlinの静的メソッドとして提供するため、通常のクラスメソッドを呼び出すのと同じ感覚で、Swiftで書いた関数を利用できます。 7. UI実装 Jetpack Composeでバリデーションをテストするための入力フォームを実装します。 この辺はCursorによしなに作ってもらいました。偉大です。 MainActivity.ktの実装例(主要部分のみ) import com.example.stringvalidator.StringValidator import androidx.compose.runtime. * import androidx.compose.material3. * @Composable fun ValidationScreen() { var email by remember { mutableStateOf( "" ) } var password by remember { mutableStateOf( "" ) } Column { // Emailバリデーション val isValidEmail = if (email.isNotEmpty()) { StringValidator.isValidEmail(email) } else null TextField( value = email, onValueChange = { email = it }, isError = isValidEmail == false ) // Passwordバリデーション val isValidPassword = if (password.isNotEmpty()) { StringValidator.isValidPassword(password) } else null TextField( value = password, onValueChange = { password = it }, isError = isValidPassword == false ) } } このコードでは、 StringValidator.isValidEmail() と StringValidator.isValidPassword() を直接呼び出すことで、Swiftで書いたバリデーションロジックを使用しています。 remember { mutableStateOf(...) } で状態を管理し、 TextField の onValueChange で値が変更されるたびにバリデーション関数が自動的に再実行されます。その結果が isError プロパティに反映されるため、ユーザーが入力している間、リアルタイムでバリデーション結果が表示されます。 動作確認・デモ エミュレータでの動作確認 Android Studioで validation-app を実行し、エミュレータで動作確認をします。 アプリを起動すると、メールアドレスとパスワードの入力フォームが表示されます。各フィールドに入力すると、リアルタイムでバリデーションが実行され、以下のように動作します。 Emailフィールド : 有効なメールアドレス形式で入力すると、エラー表示が消えます。無効な形式(例: test@ や test など)では、エラー状態が表示されます Passwordフィールド : 8文字以上256文字以下の要件を満たす有効な文字列を入力すると、エラー表示が消えます。要件を満たさない場合は、エラー状態が表示されます まとめ 今回は、Swift SDK for Androidを使ってiOSアプリで使っていた汎用的な文字列バリデーションロジックをAndroidアプリで再利用する手順を紹介しました。ポイントは以下の通りです。 Swift SDK for Androidを使うことで、既存のiOSのSwiftコードをAndroidでも活かせる swift-java プラグインを使えば、Swiftのトップレベル関数をJava/Kotlinから簡単に呼び出せる まだプレビュー版で制約もありますが、今後はより多くのiOSアプリでのコードをAndroidで再利用できるようになると思われます。 おまけ 今日はハロウィンらしいので、おまけとしてSwiftUIだけで作った可愛いアニメーションを載せておきます(笑)。
アバター
はじめに はじめまして。2025年の8月から1ヶ月間、株式会社エブリーのインターンシップに参加していた山本陽右と申します。配属は、国内最大級のレシピ動画メディア「デリッシュキッチン」の知見を活かし、リテールメディアのプラットフォーマーを目指す「リテールハブ」事業部の「小売アプリ」開発です。今回、小売アプリの機能改善に取り組みました。 経緯 学部、大学院と建築学専攻である私がプログラミングに興味を持ったきっかけは、卒業論文でカビの成長モデルをJuliaで実装したことでした。それがきっかけで、建設業界だけでなくソフトウェア業界にも興味を持って就職活動していました。そこで縁あって未経験ながらエブリーのインターンに参加し、学業と両立しながら事前学習を進めました。 背景・課題 リテールハブ事業部では、小売店の収益拡大やファン創出のために、 デジタルサイネージを用いた「 ストアDX 」 ネットスーパー 小売アプリ の3つの事業を展開しています。私は今回、小売アプリの開発に携わりました。 小売アプリにはお客様に送信するチラシやPOPを編集する「お知らせ」機能があります。これがお客様の端末でどう見えるかプレビューする機能があるのですが、これの使い勝手があまりよくありませんでした。 具体的には、 編集中にプレビューボタンを押しても、編集中の内容が反映されない プレビュー画面(/preview)から戻ると、編集内容が保持されない これらの課題を解決し、ユーザー体験を向上させることに注力しました。 改善計画 改善案 まず、要件の整理からやらせていただきました。手探りでしたが、アドバイスもいただき、最終的には上記のように2つの課題に切り分けて別々に対処することにしました。 当初は、pinia(Vueで、ローカルの情報をグローバル変数として保持できる機能)を使って、ローカルで編集中の内容を保持・反映することで対応しようと思い、提案書を作りましたが、「プレビューは本番と同じ形式 = 一度データベースに保存し、それを取得・表示するからこそ意味がある 」とレビューをいただき、バックエンドの改修も作業に含めることにしました。 技術スタック 上記のような理由から、データベースから改修する必要がありました。 小売アプリではフロントエンドは Vue 、バックエンドは Laravel を使用しています。そのため、Laravelのマイグレーションを活用しました。現状のテーブルに要素を付け足すのではなく、(変更履歴やバージョン管理など)今後の拡張性も考慮して公開されている記事用のデータベース"articles"とは別に、下書き保存用のデータベース"article_drafts"を作成することにしました。また、2つ目の課題にはpiniaを使用し、手早く実装しようと考えました。 実装方針 新規APIの作成(下書きの保存) webルートの拡張(既存ロジックの拡張) 既存のプレビュー機能は埋め込みhtmlで実装しており、それを活用しながらデータの取得先を下書きDBに繋ぎかえた piniaによる編集内容の復元 書いたコード バックエンド 既存のコードに則って、コントローラー、サービス、リポジトリの3層構造+リクエスト(認証、バリデーション)で実装しました。まず、この設計思想を理解するのに時間がかかりました。理解を深めるためにphpの歴史を調べながら、実際にコードに触れて学んでいきました。 <?php // === コントローラーレイヤー === // HTTPリクエストの受け取り、認証・権限チェック、レスポンスの返却を担当 public function storeDraft ( DraftRequest $ draftRequest ) : JsonResponse { // リクエストデータのバリデーション済みデータを取得 $ validated = $ draftRequest -> validated () ; // (権限周りの処理) // サービス層に処理を委譲し、下書きデータを保存 $ draft = $ this -> articleService -> storeDraft ( $ validated , $ staff ) ; // JSONレスポンスとして保存された下書きデータを返す return response () -> json ([ 'article' => $ draft ]) ; } // === サービスレイヤー === // ビジネスロジックの実装、トランザクション管理、DTOの作成を担当 public function storeDraft ( array $ validated , Staff $ staff ) : array { // データベーストランザクションを開始(データの整合性を保証) DB :: beginTransaction () ; try { // バリデーション済みデータをDTO(Data Transfer Object)に変換 // DTOはレイヤー間でのデータ受け渡しを構造化・安全に行うためのオブジェクト $ createDraftDto = new CreateDraftDto ( // (省略) ) ; // リポジトリ層に処理を委譲し、下書きデータを保存 $ articleDraft = $ this -> articleRepository -> storeDraft ( $ createDraftDto ) ; // トランザクションをコミット(変更を確定) DB :: commit () ; // 保存されたデータを配列形式で返す return $ articleDraft -> toArray () ; } catch ( \ Exception $ e ) { // エラー発生時はロールバック(変更を破棄) DB :: rollBack () ; throw $ e ; } } // === リポジトリレイヤー === // データベース操作を担当し、モデルのCRUD操作を抽象化 public function storeDraft ( CreateDraftDto $ createDraftDto ) : ArticleDraft { // article_idとstaff_idの組み合わせでレコードを検索 // 見つかれば既存レコードを更新、なければ新規作成(Upsert操作) // これにより、同じ記事の同じスタッフによる下書きは常に1件のみ保持される $ articleDraft = ArticleDraft :: updateOrCreate ( // (省略) ) ; // 保存されたArticleDraftモデルインスタンスを返す return $ articleDraft ; } 困ったこと バックエンドの実装を終え、当初の方針でフロントエンドとの繋ぎ込みを進め、8割ほど実装が完了した段階で、設計時に考慮しきれていなかった新たな課題が複数見えてきました。 具体的には、 直リンク問題: プレビュー用のURLに直接アクセスされた場合の挙動が保証できない。 ブラウザバック時の挙動がブラウザによって異なる可能性:piniaで保持していた編集内容が消えてしまうケースがある。 これらは技術的には解決可能な問題ではありますが、コードが膨れ上がってしまいます。 インターン期間中にも終わらなくなってしまいます 。そこで一度立ち止まってアプローチそのものを見直すことにしました。そして、これらの問題の根本原因は「プレビューのためにページを遷移している」ことにあると考え、プレビュー処理を ページ遷移からモーダル表示に変更する という解決策にたどり着きました。仕様の変更なのでデザインの修正は必要になりますが、モーダルだとそれ専用のページがあるわけではない = 画面遷移しないので、直リンクの対策も、ブラウザバックの挙動も気にする必要がなくなります。 フロントエンド 以上から、仕様を変更してモーダルで実装することにしました。 <iframe :src= "createApiUrl(`/web/articles-drafts/${articleId}`)" class = "preview-iframe" frameborder= "0" ></iframe> ページ遷移をモーダルに変更しましたが、内部処理は同じで、埋め込みhtmlのiframeを使用しています。基本的なロジックは改善前から完成していたので、モーダルへの移行は比較的簡単でした。 const handlePreview = async () => { isLoading.value = true //下書き保存APIの呼び出し await storeDraft(articleId, currentValuesForPreview()) isLoading.value = false //モーダルを開く showPreviewModal.value = true } これはプレビューボタンを押した時に呼び出される関数なのですが、フロントではこの実装が一番気に入っています。リーダブルコードの原則に則り、見通しよく書けたと思っています。最終的にはシンプルになりましたが、仮実装でハードコードしてしまったり、苦労して書き上げたものが無意味になったりと、かなり回り道しました。ですが、汚くても一通りコードを書いて自分で理解することで、削って整えることができるのだとも思いました。このバランス感覚は今後の課題にしたいと思います。 // 下書き保存用のAPIを呼び出す関数 const storeDraft = async ( id : number , values : ArticleFormValues ) => { // ガード節 if (id === INVALID_ARTICLE_ID || !values) { return false } // CSRFトークンを取得(セキュリティ対策) const csrfToken = await getCsrfToken() // APIエンドポイントURLを生成 const url = ` ${ createApiUrl( '/articles-drafts' ) } / ${ id } ` // useFetchを使ってAPIリクエストを実行 await useFetch(url, createCsrfFetchOptions(csrfToken), { // リクエスト前に実行 beforeFetch ( { options } ) { errors.value = undefined options. body = JSON . stringify (convertToAPIValuesForDraft(id, values)) return { options } } , // エラー発生時の処理 onFetchError : ( ctx ) => handleAPIError(ctx, errors), // レスポンス受信後の処理 afterFetch ( ctx ) { const { data : { article : apiArticle } , } = ctx.data article.value = convertToClientArticle(apiArticle) return ctx } , } ) .post() // POSTリクエストを送信 .json() // JSONレスポンスとして処理 // エラーがなければtrue、発生していればfalseを返す return !errors.value } 既存のコードを利用しつつ、storeDraft(下書きの保存・更新)APIは新規で作りました。 1つ目のコードブロックではstoreDraftをカプセル化していますが、内部でも適宜、下位問題を切り出して見通しをよくする努力をしました。 言われてみれば当たり前なのですが、変数や関数の名前を「それが何をしているか、何を表しているか」をわかりやすいものにするだけで、可読性がかなり高まることは、とても学びになりました。 改善結果 改善前後の比較 改善前 変更前タイトル 改善前のプレビュー機能 変更後のタイトル プレビューに編集内容が反映されておらず、編集画面から戻ると編集内容も消えてしまう(画像はタイトルのみですが、本文も同様) 改善後 改善後のプレビュー機能 変更後本文 編集内容が保持・反映されており、要件が満たされていることがわかリます。 実装コスト削減とUX向上 ページ遷移をモーダルに変更した結果、 piniaによる編集内容の保持が不要になった アクセス方法が制限されるため、直リンクやブラウザバックへの対策が不要になった モーダル枠外の任意の場所をクリックしても編集に戻れるようになった これまでは「編集に戻る」ボタンと、ブラウザバックでしかプレビューを終了できなかった というメリットが生まれ、要件を満たしながら、実装コストを下げつつUXも向上させるという、 一石二鳥の実装 ができました! 学んだことと振り返り モダンな技術スタック :要件の整理からVue、Laravel、データベースまで、幅広く勉強させていただきました。もう少し事前学習ができていればインフラまで踏み込めたのが少々心残りです。 初めてのチーム開発経験 :個人開発と違って、全員が同じ方向性を向いて、同じ目的意識を持って開発を進めるためには意見のすり合わせが不可欠だと感じました。また、会議の初めにゴールを明確にするなど、会議のための会議にならない工夫を実践することもできました。 仕事の姿勢 :納期があり、制約があり、その中で優先順位をつけて良いものを届けるという、就業型インターンならではの経験ができました。慣れていないから余計に目先の実装に追われがちで、システム全体やそれを使うクライアントにまで意識を向けることが難しかったですが、今後も大事にしていきたい経験です。 ソフトウェアエンジニアに求められる思考 :期間中に読んだ「リーダブルコード」だけでなく、社員さんの話し方、考え方から少しずつ吸収しようと心がけていました。インプットだけでなく学んだ知識の実践までできたので、自らの血肉になった実感があります。 おわりに 学業や学会の準備をしながらのインターンで、非常に忙しかったですが、充実した1ヶ月間でした。要件を満たして実装完了までできて、とても嬉しいです。 最後になりましたが、小売アプリの皆さんには大変お世話になりました。とてもよくしていただきました。この場で御礼申し上げます。
アバター
はじめに 開発本部でデリッシュキッチンのアプリウェブグロース向けの開発を担当している hond です! 9月末にMCPの最新仕様が2025/11/25にリリースされることが発表されました。この記事では 2024-11-05 から 2025-03-26 、 2025-06-18 、そして次期 2025-11-25 でどのような変更が行われてきたか主要な機能と私が特に興味を持った点について説明しようと思います。 先日Go公式のMCP SDKについて発表したスライドもあるので、興味のある方はぜひチェックしてください。 speakerdeck.com MCPとは MCP(Model Context Protocol)は、LLM に外部コンテキストを安全かつ一貫した方法で渡すためのオープンプロトコルです。プロトコルは JSON‑RPC 2.0 で、基本的には stateful な接続を前提としています。主要なcomponentは Host 、 Client 、 Server の3つで、IDE やチャットなどの実行環境( Host )と、実際に Server とやり取りを行う Client が Server ごとに生成されます MCP Architecture 主要機能 Client には作業の起点を示す Roots と、LLMへの生成依頼を行う Sampling があり、 Server には実行可能な機能一覧を返す Tools 、ファイルやデータベーススキーマなどLLMがコンテキストとして利用可能なソースを公開する Resources 、再利用可能な Prompts が用意されています。 バージョニングの原則 MCPの仕様はリリースされた日付( YYYY-MM-DD )で管理されています。初期化時には Client / Server 間で利用可能なバージョンの合意を行います。 以降の章ではバージョンごとの主要な変更点についてまとめていきます。 2024-11-05 概要 最初の安定版にあたる 2024-11-05 では、通信方式・ライフサイクル・機能群の土台が定義されました。ざっくりの全体像は次のとおりです。 Transport: HTTP+SSE、stdio ベース: JSON-RPC 2.0 / Base lifecycle / Capabilities Client: Roots / Sampling Server: Tools / Resources / Prompts ユーティリティ: Pagination / Logging / Completion Client Roots Client が「どこを起点に動くのか」ファイルシステムのルートを明示するのが Roots です。初期化フェーズで Server が Roots を確認し、その後の操作はこの範囲(ワークスペースやルートディレクトリなど)の中で行われます。 Sampling Server が Client を介してLLMに生成や補完を依頼する仕組みです。モデルのヒントや優先度、トークン数などを指定して、対話生成を柔軟にコントロールできます。 Sampling を使うと生成・補完はLLMに任せることができ、 Server は特定のドメイン知識を抱える必要がなくなります。 例 Sampling - Model Context Protocol 下記が公式から引用した例になります。 フランスの首都に関する情報をLLMに補完させることで Server が、それらの情報を持つ必要をなくしています。 Request { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " sampling/createMessage ", " params ": { " messages ": [ { " role ": " user ", " content ": { " type ": " text ", " text ": " What is the capital of France? " } } ] , " modelPreferences ": { " hints ": [{ " name ": " claude-3-sonnet " }] , " intelligencePriority ": 0.8 , " speedPriority ": 0.5 } , " systemPrompt ": " You are a helpful assistant. ", " maxTokens ": 100 } } Response { " jsonrpc ": " 2.0 ", " id ": 1 , " result ": { " role ": " assistant ", " content ": { " type ": " text ", " text ": " The capital of France is Paris. " } , " model ": " claude-3-sonnet-20240307 ", " stopReason ": " endTurn " } } Server Tools ファイル操作や外部 API 呼び出しなどのServerが行えるアクションを、 Client に公開する機能です。 Client はToolsから実行したい機能を選択し、必要な引数を渡して実行します。 Resources Server が Client にファイルやデータベーススキーマなどの Server が有する情報を公開する機能です。各リソースはURIによって公開され、 Client はこれらの情報をLLMにコンテキストとして利用することが可能です。 例 Resources - Model Context Protocol 下記はResourceの一覧取得に関するRequestとResponseになります。 Request { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " resources/list ", " params ": { " cursor ": " optional-cursor-value " } } Response { " jsonrpc ": " 2.0 ", " id ": 1 , " result ": { " resources ": [ { " uri ": " file:///project/src/main.rs ", " name ": " main.rs ", " description ": " Primary application entry point ", " mimeType ": " text/x-rust " } ] , " nextCursor ": " next-page-cursor " } } Prompts Server が利用可能なプロンプトの雛形を公開する機能です。 Client は prompts/list で候補を見つけ、 prompts/get でテンプレート本文と変数を取得します。 2025-03-26 2025-03-26 では認証方式の追加や通信方式の改善、スキーマの改善が行われました。( Key Changes )以降はその中でもMajor changesに当たるOAuth,Streamable HTTP,JSON-RPC Batching,Tool Annotationsについて説明します。 Authorization Framework HTTPベースのTransportではOAuth 2.0,OAuth 2.1の仕様に基づいたAuthorizationの実装が推奨されるようになりました。stdioではAuthorizationは非推奨となっており代わりに環境変数を用いた方式が推奨されています。 この仕様追加により各言語SDKが認証を公式提供する様になったので、企業など不特定多数にMCPを提供する際に特定のユーザーのみに制限する実装が容易になりました。 Streamable HTTP Transportの推奨がstdio,HTTP+SSEからstdio,Sreamable HTTPに変更されました。これにより Client としては単一のエンドポイントで双方向の通信が可能になるので以前より実装が容易になります。 双方向の通信を実現するためになぜWebSocketを採用しなかったのかなどSreamable HTTPに移行するにあたる詳細な判断理由はこちらの PR に記述されています。 JSON-RPC Batching 複数のリクエストをまとめて送る仕組みであるJSON-RPC Batchingが導入されました。 JSON-RPC 2.0 の仕様にはBatchingが定義されているものの、MCPの仕様では明確化されておらずJSON-RPCの仕様から外れた状態になっていたこともありこのタイミングで仕様書含め明記される様になりました。 例 [ { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " tools/list ", " params ": {}} , { " jsonrpc ": " 2.0 ", " id ": 2 , " method ": " resources/list ", " params ": {}} ] Tool Annotations inputSchema / description だけでは伝わりづらい動作特性(read‑only / destructive / sensitive など)を明示するためにTool Annotationsが追加されました。 これによりLLMがTool利用する際の判断基準がこれまでより明確になり、より場面にあったTool選択を適切に行える様になりました。 2025-06-18 2025-06-18 では、セキュリティと相互運用性の強化、実装の簡素化を中心に改善が行われました。( Key Changes )以降はJSON-RPC Batchingの削除、Structured Tool Output、Elicitationの内容を抜粋し説明します。 JSON-RPC Batchingの削除 前バージョンである 2025-03-26 で「JSON‑RPC準拠」として導入されたバッチングですが、実運用面で採用が伸びず言語SDK・ストリーミングとの両立で実装の複雑さが増したため削除されました。 Structured Tool Output Toolの結果を構造化データとして返せる機能が追加されました。これにより Client はレスポンスを検証可能になるためより型安全な運用が可能になります。 既存の仕様ではスキーマが固定されていなかったため柔軟なJSONパースが求められていましたが、型が厳密になるためレスポンスに応じた Client の動作を行うことが可能になります。 例 Tools - Model Context Protocol Tool { " name ": " get_weather_data ", " title ": " Weather Data Retriever ", " description ": " Get current weather data for a location ", " inputSchema ": { " type ": " object ", " properties ": { " location ": { " type ": " string ", " description ": " City name or zip code " } } , " required ": [ " location " ] } , " outputSchema ": { " type ": " object ", " properties ": { " temperature ": { " type ": " number ", " description ": " Temperature in celsius " } , " conditions ": { " type ": " string ", " description ": " Weather conditions description " } , " humidity ": { " type ": " number ", " description ": " Humidity percentage " } } , " required ": [ " temperature ", " conditions ", " humidity " ] } } Response { " jsonrpc ": " 2.0 ", " id ": 5 , " result ": { " content ": [ { " type ": " text ", " text ": " { \" temperature \" : 22.5, \" conditions \" : \" Partly cloudy \" , \" humidity \" : 65} " } ] , " structuredContent ": { " temperature ": 22.5 , " conditions ": " Partly cloudy ", " humidity ": 65 } } } Elicitation Server がTool実行に必要な不足情報を、 Client 経由でユーザーに尋ねる機能です。Elicitationの内容としては単純に同意を求めるものから実際にemailなど文字列の入力を求めるものが可能です。 またStructured Tool Outputと組み合わせることでより柔軟な値を型安全に扱うことが可能です。 スキーマは下記の4つの型がサポートされています。 String Number Boolean Enum 例 Elicitation - Model Context Protocol 下記の例は Server がユーザーに対して「名前」「メールアドレス」「年齢」の入力を求めるものになります。 Request { " jsonrpc ": " 2.0 ", " id ": 2 , " method ": " elicitation/create ", " params ": { " message ": " Please provide your contact information ", " requestedSchema ": { " type ": " object ", " properties ": { " name ": { " type ": " string ", " description ": " Your full name " } , " email ": { " type ": " string ", " format ": " email ", " description ": " Your email address " } , " age ": { " type ": " number ", " minimum ": 18 , " description ": " Your age " } } , " required ": [ " name ", " email " ] } } } Response { " jsonrpc ": " 2.0 ", " id ": 2 , " result ": { " action ": " accept ", " content ": { " name ": " Monalisa Octocat ", " email ": " octocat@github.com ", " age ": 30 } } } 2025-11-25 最後にリリース予定の 2025-11-25 について実装にあたり特に意識する必要がありそうな点をまとめます。このバージョンについては現状まだ仕様がリリースされていないので mcp blog を元に説明します。 非同期処理 現状の実装ではMCPは処理がすべて同期的に処理が行われます。しかし、すべてのToolの処理がすぐ終わるわけではなく大量のファイルの入出力などは膨大な時間を要します。これにより移行の処理がブロックされてしまう問題がありました。これらの問題やさらに長い時間を要する操作も可能にするために非同期処理の追加が提案されています。詳細についてはこちらの issue から確認することが可能です。 非同期処理の具体的な実行フローは下記が提示されています。 Client が tools/list を用いてToolの発見行う tools/call を用いて非同期Toolの実行をリクエストする。この際に Server はバックグラウンドでの実行開始し、非同期処理を追跡するためのTokenを Client に返す Client はTokenを用いて tools/async/status を呼び出し非同期処理の実行ステータスを取得する 実行完了ステータスを取得後 Client は tools/async/result を呼び出し結果を取得する Server はクリーンアップ処理を行う 公式拡張 MCPの成長に伴い特定の分野において、実装パターンが固まりつつあるようです。それらの内容を仕様に記述するのではなく今後は公式拡張という側面でプロトコル拡張機能として文書化していく方針が提示されています。これによって特定の分野におけるMCPの実装をより加速することが見込まれているようです。 まとめ 今回はMCPの正式リリースされたバージョン 2024-11-05 から次期リリースの 2025-11-25 の主要な変更点についてまとめました。JSON-RPC Batchの追加・削除のように利用しやすいよう柔軟に仕様を変更していく姿勢が利用者としては好感を持てるなと思いました。私自身Goの公式MCP SDKに関心があるので 2025-11-25 の仕様が確定次第改めて仕様の詳細確認して追っていこうと思いました。 また、 blog末尾 にTypescriptやSwiftのメンテナーが不足している旨が記述されていたので機会があったらチャレンジしてみたいです。 ここまで読んでいただきありがとうございます。MCPの仕様を追っている方の参考になったら幸いです。 参考資料 仕様 2024-11-05: spec.modelcontextprotocol.io/specification/2024-11-05 仕様 2025-03-26: spec.modelcontextprotocol.io/specification/2025-03-26 仕様 2025-06-18: spec.modelcontextprotocol.io/specification/2025-06-18 仕様 Draft: spec.modelcontextprotocol.io/specification/draft MCP Blog: blog.modelcontextprotocol.io SEP-1391(非同期の提案): github.com/modelcontextprotocol/modelcontextprotocol/issues/1391
アバター
目次 はじめに 運営チームの設立 ブース内容の選定 作成物のラフ作成 アンケートボード フォトブースパネル デザイン依頼からの発注発送 デザイナーとの調整 発注発送周り 当日運営メンバーの募集と運営マニュアル 前日準備から当日 まとめ はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 先月は Go Conference 2025 が開催され、エブリーではスポンサーブースを出展させていただきました。 今回はそのブースの準備の話を綴っていきます gocon.jp 運営チームの設立 まず社内で運営に手伝ってくれるメンバーを募集し、私含め 4 名で進めていくこととなりました。 スケジュールを精査し、準備すべきことをまとめていきました。 今回だと以下のようになりました。 日時 やること 08/11~15 予算確定 スケジュール確定 ラフ案確定 08/18~22 デザイン変更不要なものの発注 デザイン依頼 08/25~29 当日運営メンバー募集 09/08~12 制作物の発注、当日説明会 09/15~19 この週までに物品揃える 09/22~25 荷物発送、当日シフト最終確定 09/26 前日設営 09/27~28 当日 また運営チームでは週 1 回程度のミーティングを行い進捗や相談事を確認していきました。 ブース内容の選定 every をカンファレンス参加者の方々に多く知ってもらい、かつ楽しめるブースを軸にしました。 もともと過去のカンファレンスで行っていたものを踏襲し、費用面を抑えつつ新たな取組を検討しました。 去年の初めてカンファレンス協賛をするときから同じなのですが、全体的なカラーはコーポレートカラーではなく認知が広いサービスであるデリッシュキッチンをベースに黄色を用いるのは変えず、ブース内容で色々工夫していきました。 これまでのカンファレンスを通して普段やってきたものである X フォローのプレゼントキャンペーン X アカウントのフォローによってデリッシュキッチンのキッチングッズが当たるくじ引き ハズレでもステッカーとコーヒーバッグをプレゼント デリッシュキッチンのサイネージ 連携している小売様のところで設置させていただいているサイネージのデモ アンケートボード 参加者との会話や認識を広める質問を設定したボード を続投しつつ、新たな取組として「SNS で拡散してもらいやすいよう会社やサービスの認知拡大を目的としたフォトブース」を行うこととしました。 他には Gopher くんのキャラ弁当をデリッシュキッチンのレシピとして紹介する案もありましたが、準備期間の都合上断念しました... 作成物のラフ作成 ボードとして設置するものである「アンケートボード」と「フォトブースパネル」である構成案を出していきました。 アンケートボード 今回年齢層の広い方に回答してもらえるよう横軸を「Go 歴」、縦列を「気になるトピック」を質問として設定しました。 Go 歴は Go 言語を触り始めた年として、1.0 がリリースされた 2012 年 3 月 28 日から考慮し、15 年を超えるものを含めるようにしました。また気になるトピックは Go 言語自体の話題と Go 言語を用いた開発体験のグループで考えていき、最終的には以下のような質問を設定しました。 Go1.25 Go と AI コーディング Go のパッケージ Go と DB 他社の Go の知見 その他 フォトブースパネル 大きすぎると会場の妨げや予算的にも難しいため、デザインを工夫して 2 種類のパネルで構成することとしました。 会社やサービスロゴを用いたパネル Go のキャラクターでもある Gopher くんを用いたパネル 特に後者の Gopher くんを用いたパネルを、実際に会社のサービスであるデリッシュキッチンと絡めるに当たって、料理をしている Gopher くんのラフを作成しました デザイン依頼からの発注発送 デザイナーとの調整 ラフ案が固まった後に社内のデザイナーさんにデザインを依頼していきます。このときプロダクト開発の施策もある中でのデザイン依頼になるので、発注物の到着の時期を考慮しつつ長めにバッファを持ってデザイン依頼をかけれるように心がけています。 ラフと文字だけでは伝わりにくいことがあるので、デザイン依頼時には他社の実例や過去の制作物で近いものも追加の資料として添付することで、コミュニケーションを円滑に行えるようにしました。 発注発送周り 会場への搬送を運送会社に依頼する都合上、会社には荷物発送の前週までに揃えるように発注タイミングを設定していきます。 デザイン制作が必要なものに関しては作成完了次第逐次発注で、またこれまでのカンファレンス運営でも用いていたステッカーやコーヒーバッグはラフが必要ないのものは早めに発注をかけていきました。 注意としてコーヒーバッグのような賞味期限があるものに関してはあまり早めに発注をかけると無駄になるので、カンファレンス当日から逆算して発注をかけています。 発注したものが社内に届き次第、実物の確認を行い荷物を箱詰めしていきました。このとき、納品時の段ボールそのままだと箱の数が多く、会場での返送用の段ボールを多く抱えることになるため、極力荷物はまとめるようにして早めに発送をかけました。 また箱詰めの際は何が入っているかを発送伝票だけではなく箱にも記載することで設営や撤収の際に迷わないようにしています。 当日運営メンバーの募集と運営マニュアル 社内のエンジニアで募集をかけて当日ブース運営スタッフを募集をするのですが、今回スポンサー協賛特典でいただける招待枠のチケットと一般参加のチケットで予算的に負担可能な枚数を考慮して人員数を決めています。 今年の Go Conference 2025 は 2 日間開催となるため、両日参加できるメンバーを優先して募集をかけ、人員確保を行いました。 また前日準備や当日運営の手順をまとめたマニュアルを作成していきます。運営マニュアルでは今回のカンファレンスでの運営について以下の内容をまとめています。 会場マップとブースの配置 ブース運営の目的と全体感 各施策の説明 フォトブース デリッシュキッチンのサイネージ アンケートボード X フォローのプレゼントキャンペーン(くじの割合も記載) 服装 聞きたいセッション&ワークショップのチェックシート 前日準備出のやることリスト 当日の入館方法 当日シフト(キックオフ時では未完成だが、チェックシートをもと作成する) ブース運営以外でのやること共有(SNS 広報、参加レポート執筆など) 実際に作成したもの運営メンバーとキックオフミーティングをしてすり合わせを行いました。このとき当日シフトを作成するに当たって運営メンバーの聞きたいセッション&ワークショップのチェックシートの記載の依頼や、ブース運営以外でのやることの分担を相談していきます。 またシフトに関しては、今回は新卒メンバーの参加が多くブース運営の経験者が少ない状況であるため、常に経験豊富なメンバーを 1 人つけるようにし、当日の混雑が予想されるお昼の時間帯などでは人員を多めに設定しました。 前日準備から当日 前日は会場に向かいブースの設営を進めていきました。 このとき極力当日の朝に慌てずにスタートできるよう、くじ引きなど数がはっきりしているものは当日分で分けておくようにしました。 そのおかげで当日はリラックスして運営を行うことができました。 詳しい当日の実際の様子は参加レポートからご覧いただけます。 tech.every.tv まとめ 今回の Go Conference 2025 スポンサーブース運営についてのざっくりとした流れをご紹介しました。 まだ会社としてのカンファレンスでのスポンサー協賛の経験が浅くはありますが、数をこなせばこなすほど反省点や改善ポイントが出てくるので、次回以降はより良いものにしていくことができます。 またイベントを通してその言語やフレームワークの愛され方を感じることができるので、参加者とのコミュニケーションをより良いものにしていくことができると思います。 最後に、今回の Go Conference 2025 の開催にあたり、運営の皆様には大変お世話になりました。スポンサーとして参加させていただく機会をいただき、心より感謝申し上げます。 また本記事が初めてカンファレンス運営のスポンサーブース運営をする方々の参考になれば幸いです。
アバター
はじめに 1ヶ月間株式会社エブリーでデータサイエンティストとしてインターンをしている中村です。 私が配属された「デリッシュリサーチ」チームでは、デリッシュキッチンの膨大な検索ログデータを抽出・加工して、メーカー・小売の意思決定を支援しています。 本インターンでは、アプリ内の検索データから未来の「食トレンドワード」の予測に挑戦しました。 開発背景 食品業界では新商品の企画から販売までに時間がかかるため、企画の段階で「販売時期のトレンド」を正確に予測することがビジネスの成否を大きく左右します。 一般的に、トレンドが本格化するまでには、感度の高い層の検索行動などに「先行指標」が現れます。 そこで私たちは、「デリッシュキッチン」の膨大な検索ログデータにこのトレンドの”予兆”が現れるのではないか、という仮説を立てました。 今回のインターンでは、この仮説に基づきデータドリブンに未来のトレンドを予測するという課題に挑みました。 トレンド予測のパイプライン 今回作成したコードは月に1度実行され、前月までの検索データの推移をもとにトレンド予測を行います。 大きな流れとして、「1. SQLによる候補の抽出」と「2. LLMによる絞り込み」という2つのステップで構成しました。 1. SQLによる絞り込み まず、SQLクエリを用いて、検索データ全体からトレンドの兆候を示す可能性のあるワードを絞り込みます。 ここでの目的は、再現率(Recall)を重視し、ポテンシャルのある単語を可能な限り拾い上げることです。 当初、仮説ベースでクエリを設計しましたが期待したような出力は得られませんでした。 そこで、過去のトレンドワードのデータを分析し、ブーム発生前の共通パターンを特定する帰納的アプローチに切り替えました。 過去のトレンドワードの流行のきっかけと推移を調査し、トレンド候補として取得したい時期を設定し各ワードがその時期に結果に含まれるようクエリを設計しました。 分析の結果、これらのワードには以下のような2つの特徴が共通することが判明しました。 検索数が少ない:流行前は世間的に認知が低いため検索数が一般的な料理ワードと比較して少ない傾向にありました。 検索頻度スコアの最大を更新:流行の兆しが見られているタイミングでアプリ内でも検索頻度スコアが過去最高を更新していたことが判明しました。 (注) 検索頻度スコア:全検索ワード1000回あたりの特定のワードの検索回数 過去トレンド例:せいろ 過去トレンドの例として、せいろのトレンド推移を紹介します。 せいろは2024年9月にレシピ本が出版されたことをきっかけにブームとなり、デリッシュキッチン内でも急上昇を見せています。 しかし、トレンド化する予兆が全くなかったわけではありません。 2023年6月以前はほとんど検索されていなかったものの、インフルエンサーの投稿などから注目が集まり2023年7月~2024年1月の多くの月で検索頻度が過去最高を更新しています。 このように多くの過去トレンドワードでは大流行する前に先述した2つの特徴を持つトレンド化の予兆を示す時期があることが判明し、十分クエリで絞り込み可能と考えました。 先述した2つの条件をクエリに落とし込み候補を約1500件まで絞り込みました。 次に、この結果を分析したところ「バレンタイン」や「秋刀魚」といった季節性要因で検索が増加したワードが多数含まれていました。 これらはトレンドと異なるため周期的なパターンを検出するロジックを作成し、これらを除外する処理を追加しました。 この処理によってデータは約900件にまで絞り込めました。 後述するLLMでの絞り込みではデータ数に比例したコストがかかるため、絞り込んだ全てのワードを使うことはできません。 そこで昨年からの検索数の増加量を基準に並び替えを行い上位100件を"トレンドワード候補"として使用しました。 2. LLMによる絞り込み クエリによる絞り込みでは正解の単語を確実に取得することを重視しているため、中にはデリッシュキッチンのSNS経由など他の要因で検索が増加した単語が含まれています。 そこで、各候補ワードの定性的な評価を行うため、LLMを用いた分類ステップを導入しました。 LLMには、Web検索機能を用いて各単語の背景(定義、メディアでの扱われ方、SNSでの話題性など)を調査させた上で、以下の5つのトレンドタイプに分類するタスクを実行させます。 この中でfuture(high)に分類されたワードを、最終的に使用します。 past : 過去に流行したもの ongoing : 現在流行しているもの future(low) : 今後流行する可能性があるが、現時点では限定的 future(high) : 流行の兆しがあり、今後大きなインパクトが期待されるもの stable : 一過性の流行ではなく、社会に定着しているもの 最後に出力用にデータの整形を行います。 データや分析を提供する目的は、企業の意思決定支援です。 そのためには、単に単語リストを提供するだけでは不十分であり、そのワードの定義や分類の根拠を説明する必要があります。 先ほどの分類ステップでLLMには分類結果と同時に、その判断に至った具体的な理由や背景情報をテキストで生成させています。 その説明を入力にLLMに要約を作成させ、表示用の説明文としました。 ここでは具体例を掲載することはできませんが、韓国ブームや健康志向といったマクロな社会潮流と一致する単語を複数抽出できており、本手法の有効性を確認できました。 技術的な工夫 非同期処理の活用 LLMによる分類ステップでは、100件の候補ワードを処理する必要がありました。 当初、APIリクエストを同期的に逐一実行していたため、1ワードあたり約3分、全体で約5時間を要し開発イテレーションの大きなボトルネックとなっていました。 これでは、プロンプトチューニングを行う上でも実際の実行でも問題となります。そこでPythonの非同期処理を用いて並列でリクエストを送信しました。 ただし、OpenAI APIにはレート制限が存在します。 短時間にリクエストが集中するとエラーが返されるため、リトライ処理の実装が不可欠です。 今回はtenacityライブラリを活用し、リクエスト失敗時に最大6回まで再試行するロジックを組み込み、処理の安定性を確保しました。 これらの対応により、全体の処理時間を大幅に短縮でき、プロンプトチューニングや本番実行を短時間で行えるようになりました。 @ retry (wait=wait_random_exponential( min = 1 , max = 60 ), stop=stop_after_attempt( 6 )) async def call_gpt (search_word: str , prompt_template: str , schema: dict ,date_formatted: str ,recipe_master_attention: str , model_name: str = "gpt-5-mini-2025-08-07" ) -> tuple : try : prompt = prompt_template.format(research_word=search_word,date_formatted=date_formatted,recipe_master_attention=recipe_master_attention) response = await client.responses.create( model=model_name, tools = [{ "type" : "web_search" , "user_location" :{ "type" : "approximate" , "country" : "JP" , "city" : "Tokyo" , "region" : "Tokyo" }, }], input =[ { "role" : "system" , "content" : "あなたは、食のトレンドを専門とするリサーチャーです。" }, { "role" : "user" , "content" : prompt}, ], text = schema ) res_dict = json.loads(response.output_text) res_dict[ "search_word" ] = search_word return res_dict, response.usage except Exception as e: print (f "❌ Failed to analyze word: {search_word}, Error: {e}" ) raise e 分類根拠の説明 OpenAI APIは構造化出力をサポートしており、指定したスキーマでレスポンスを受け取ることができます。 これを利用して、トレンドタイプに加えてその分類の根拠もテキスト形式で出力させています。 分類根拠を出力させることでLLMの推論の過程を理解することができ、プロンプトチューニングが効率化されるだけでなく、その内容を要約してクライアント向けの説明文を生成することも可能になりました。 今後の課題 今回の分析である程度期待した精度の出力を得ることに成功しましたが、予測精度と提供価値をさらに高めるために、2つの改善点が考えられます。 LLMは検索データを考慮していない 現状、LLMによる分類のステップでは、プロンプトにアプリ内の検索数の推移を含めていません。 LLMに検索増という事実だけでなく生のデータを与えることで、検索増の要因の考察の精度が上昇することが期待されます。 過去の予測を考慮した出力 クライアントである企業にとっての価値は、「まだ見ぬトレンドの種」をいち早く知ることです。 その点で、過去に提示した単語が数ヶ月後に再び表示されると、「新しい発見がない」という印象を与えかねません。 現在のクエリでは、一度候補に入ると3ヶ月間は必ず"トレンド候補"となります。この期間が適切であるかは考慮する必要があります。 対策として、一度予測として提示した単語をフィルタリングするといった出力制御ロジックを組み込むことで、常に新鮮で多様な「未来のヒント」を提供できるようになると考えています。 まとめ アプリ内の検索データをもとに、SQLを用いた定量的な候補抽出とLLMを用いた定性的な評価によって食トレンドを予測する機能を実装しました。 今回の実装は、LLMのweb検索機能を使用しているため、過去データでの性能検証ができません。 現在の予測が正しいかは数ヶ月後になってみないとわかりません。 予測には海外で流行している料理などもあり、今後日本で話題となることを期待しています。 今回のインターンでは、丁寧なコードレビューや毎日のフィードバックを元に、開発を改善しひとつの機能を実装することができました。 膨大なデータから価値を創造した体験を経て、データサイエンティストとして働く上での解像度が劇的に高まりました。 特に整備されたデータ基盤のもと試行錯誤を繰り返したことで、技術的に成長し、普段の勉強や研究では得られないような業務上の知識を多く得られました。
アバター
はじめに こんにちは。デリッシュキッチン開発部でiOSエンジニアをしている谷口恭一です。 デリッシュキッチンでは新規画面のUI実装は主にSwiftUIを使用していて、@State、@Publishedなどを使って状態管理の仕組みを学びながら日々実践しています。 SwiftUIの状態管理に関連する言語機能は便利な機能ですが、使い方を誤ってコンパイルエラーになったときに全く意味がわからないというような状況になることがあります。 そこで、これらの言語仕様を調査してみようと考えました。特に@Bindingがどのように動作しているのかを調査したので、それを解説します。 目次 Bindingとは PropertyWrapper DynamicMemberLookup + KeyPath Bindingの詳細な動作 まとめ Bindingとは まずSwiftUIでよく見るBindingについて簡単に説明します。Appleのドキュメントには以下のような例があります。 struct PlayButton : View { @Binding var isPlaying : Bool var body : some View { Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } } } struct PlayerView : View { var episode : Episode @State private var isPlaying : Bool = false var body : some View { VStack { Text(episode.title) .foregroundStyle(isPlaying ? .primary : .secondary) PlayButton(isPlaying : $isPlaying ) // Pass a binding. } } } /// 以下structとPreviewは自分で定義 struct Episode { let title : String } #Preview {     PlayerView(episode : . init (title : "エピソード1" )) } https://developer.apple.com/documentation/swiftui/binding このように親Viewである PlayerView では@Stateで isPlaying を定義して、この値の状態の変更によって画面を再描画できるようにしています。 子Viewである PlayButton では isPlaying を@Bindingで定義することにより、子Viewでの値の変更でも親Viewが更新されるようにしています。 isPlaying = false isPlaying = true 一見すると、@Stateと同様、@Bindingを付与した変数も変更時にUI更新が行われるような気がします。つまり、@Bindingとは、@StateのようなSwiftUIのView再描画用機能の1つであると思われます。 しかし、@Bindingはそのような機能を提供していません。最終的に、その理由を理解することをゴールとして解説していきます。 AppleのドキュメントによるとBindingは以下のように定義されています。 @frozen @propertyWrapper @dynamicMemberLookup struct Binding < Value > ここから読み取れることとして、 Binding型は、ある型をラップするための型であるということ frozen: 構造体が将来変更されないということ propertyWrapper、dynamicMemberLookupという機能が付与されているということ 実際に実装を確認すると以下のようになっています。 @frozen @propertyWrapper @dynamicMemberLookup public struct Binding < Value > { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } public init (projectedValue : Binding < Value > ) public subscript< Subject > (dynamicMember keyPath : WritableKeyPath < Value , Subject > ) -> Binding < Subject > { get } } まずはこれらにどのような意味があり、背景の言語仕様がどのようなものであるかについて1つ1つ解説していきます。 PropertyWrapper まず、Binding型に付与されているものとして@propertyWrapperがあります。 swift.orgのドキュメントによるとPropertyWrapperとは、ある型のラッパーを作ったときに、そのプロパティ自体の定義と、値の 保存方法の管理 を分離する機能だと示されています。 A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property. https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/ つまり、以下の例の場合、Bool型というのがプロパティ自体の定義ですが、これに加えて値の保存方法を指定された方法で管理できますよ、という仕組みのことです。 @Binding var isPlaying : Bool PropertyWrapperはwrappedValueという計算プロパティを実装する必要があります。この計算プロパティ内で実装者は値の保存方法、更新時の処理などを追加することができます。 また、任意でprojectedValueという計算プロパティを実装すると、この値には $ という記号で簡易アクセスする機能が付与されます。 以下の例では、 @Wrapper var a: Int = 1 と定義すると、 $a と書くと a.projectedValue と同等の意味になり、 projectedValue: 1 というStringを返すというような動作になります。 @propertyWrapper struct Wrapper < Value > {     private var value : Value           init (wrappedValue : Value ) {         self .value = wrappedValue     }          var wrappedValue : Value {         get { value }         set { value = newValue }     }          var projectedValue : String {         return "projectedValue: \( value ) "     } } $ の簡易アクセサはSwiftUIでよく見る、 PlayButton(isPlaying : $isPlaying ) このような書き方の正体です。 また、PropertyWrapperには、アンダースコア( _ )プレフィックスを使うことでWrapper自体にアクセスできる機能もあります。 例えば、 @Wrapper var a: Int = 1 と定義した場合: a → wrappedValue ( Int 型の値そのもの)にアクセス $a → projectedValue (この例では String )にアクセス _a → Wrapper自体( Wrapper<Int> )にアクセス SwiftUIでは通常、structで実装されたViewはイニシャライザを省略できるため、 _ を使う機会は少ないですが、カスタムイニシャライザを実装する際などに使用します。 では、@Bindingというプロパティラッパーが提供する「 保存方法 」とは何でしょうか? Appleのドキュメントによると、 A property wrapper type that can read and write a value owned by a source of truth. つまり、「 A Source of Truthな値を読み書きできる 」という保存方法です。 「A Source of Truth」はエンジニアならお馴染みの「 Single Source of Truth 」と同じ概念です。 「Single Source of Truth(SSoT)」とは、 単一の信頼できる情報源 という意味です。 最初の例で言うと、isPlayingという情報は2種類あります PlayerViewのisPlaying PlayButtonのisPlaying このとき、信頼できる情報源はもちろん親ViewであるPlayerViewのisPlayingです。(親ViewのisPlayingがどのように 単一の信頼できる情報源 を提供しているかについては後述します。) PlayButtonで管理しているisPlayingは常に必ず親ViewのisPlayingと同じでなければなりません。そうでないと、同じ画面に2種類の状態が混在して、どちらが正しく再生状態を表しているかわからなくなってしまいます。 そこで、親ViewのPlayerViewの情報源を 単一の信頼できる情報源 として、それを参照し、いつでも子ViewのisPlayingが親Viewのものと同じ状態であり続ける機能が欲しくなると思います。 しかし、SwiftUIのViewはstructであり、その中で定義されたプロパティは値型です。 よって、子Viewに渡されるisPlayingの実態は、初期化時に作成された親ViewのisPlayingのコピーであり、親Viewのプロパティとは別のメモリ領域に格納されます。 したがって、通常の方法では親Viewのプロパティを直接参照したり値を更新することはできません。 そこでBindingという機能を付与することによって、このような値を読み書きできるようにしています。 Bniding型が読み書きしている親ViewのisPlayingは @Stateを付けることによって、「 単一の信頼できる情報源 」を提供しています。 @StateのPropertyWrapperの「 保存方法の管理 」とは何かをAppleのドキュメントから確認すると、 A property wrapper type that can read and write a value managed by SwiftUI. Use state as the single source of truth for a given value type that you store in a view hierarchy. SwiftUI manages the property’s storage. When the value changes, SwiftUI updates the parts of the view hierarchy that depend on the value. https://developer.apple.com/documentation/swiftui/state つまり、 View階層内でSwiftUIが管理している、Single Source of Truthな値を読み書きできる 値が変更されるとSwiftUIはその値に依存するView階層の箇所を更新する という保存方法であるということがわかります。 上図のように、View内で@Stateで宣言された値は、単にスタックメモリに保存されるわけではなく、SwiftUIが提供する特別な保存領域で管理されるというわけです。@Stateは読み書きできることに加えて、値の変更時に依存するViewを再計算するという機能も備わっています。 そして、そのような値を子Viewから読み書きするためにBindingを提供する必要があったという背景です。 以下にState型の実装を示しました。ここから、State型のpropertyWrapperのprojectedValueは Binding<Value> であることがわかります。 @frozen @propertyWrapper public struct State < Value > : DynamicProperty { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } } よって、 @State private var isPlaying : Bool = false ... PlayButton(isPlaying : $isPlaying ) というように@Stateが付与された値( State<Bool> )に $ をつけてアクセスしたときは、 Binding<Bool> が返されるという挙動になり、PlayButtonの @Binding var isPlaying: Bool で定義された Binding<Bool> 型に値を渡せることに納得がいくかと思います。 このように、State型、Binding型におけるPropertyWrapperは、SwiftUIの階層的にViewを構築していくという設計思想を実現するための最重要機能であることがわかると思います。 DynamicMemberLookup + KeyPath 次に、Bindingの定義に付与されていた@dynamicMemberLookupについて解説します。swift.orgのドキュメントによると、 Apply this attribute to a class, structure, enumeration, or protocol to enable members to be looked up by name at runtime. The type must implement a  subscript(dynamicMember:)  subscript. つまり、DynamicMemberLookupとは実行時にメンバーを名前で検索できるようにする機能です。メンバーとは、その型に紐づくプロパティやメソッドなどです。 https://docs.swift.org/swift-book/documentation/the-swift-programming-language/attributes/ @dynamicMemberLookup struct DynamicStruct { subscript (dynamicMember member : String ) -> String { return " \( member ) was accessed" } } let obj = DynamicStruct() print(obj.someProperty) // "someProperty was accessed" print(obj.anyName) // "anyName was accessed" 上の例では、objは全くメンバーを持っていませんが、 someProperty や anyName にアクセスすることができます。アクセス時の動作は subscript で実装することができます。アクセスできるメンバーは実行時に動的に決定されるので、型安全性は失われます。 検索には任意の型を用いることができ、Bindingではこの型としてKeyPathという型を使用しています。KeyPathとはプロパティ自体を変数として使用できる機能です。以下の例のように、 \.title というように書くと、メンバー自体を変数にできます。 struct Recipe { var title : String } var recipe1 = Recipe(title : "レシピ1" ) // 読み取り let keyPath : KeyPath < Recipe , String > = \.title // 書き込み let writableKeyPath : WritableKeyPath < Recipe , String > = \.title recipe1[keyPath : writableKeyPath ] = "ハンバーグ" print(recipe1.title) // ハンバーグ KeyPathをダイナミックメンバーの検索時の型として用いる例は以下のようになります @dynamicMemberLookup struct Wrapper < T > { let value : T subscript< U > (dynamicMember keyPath : KeyPath < T , U > ) -> U { return value[keyPath : keyPath ] } } let wrapper = Wrapper(value : Recipe (title : "レシピ1" )) print(wrapper.title) // "レシピ1" Wrapperという型はvalueというプロパティしか持っていませんが、titleというプロパティに直接アクセスすることができています。また、KeyPathはアクセスするときに存在するプロパティかどうかをコンパイル時にチェックするので型安全にdynamicMemberLookupを使用することができます。 つまり、最初の例のようにDynamicMemberとしてStringを受け取っていたときと違って、somePropertyなどの存在しないメンバーにアクセスしようとするとコンパイルエラーになります。 ここで、Bindingの定義を再度見てみます。 @frozen @propertyWrapper @dynamicMemberLookup public struct Binding < Value > { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } public init (projectedValue : Binding < Value > ) public subscript< Subject > (dynamicMember keyPath : WritableKeyPath < Value , Subject > ) -> Binding < Subject > { get } } ここの subscript() を見ると、Bindingは保持するValue型の任意のメンバー(Subject型)に直接アクセスすることができて、アクセスした結果、アクセスしたメンバーのBinding( Binding<Subject> )が取得できるということを表しています。 Bindingの詳細な動作 以上の説明によって、Bindingがどんな言語仕様を用いているかがわかりました。ここで、最初のPlayerの例に戻りたいと思います。 以下のコード例では、PlayerViewにおいて、最初の例とデータの持ち方を少し変更しました。 具体的には、isPlayingを直接定義するのではなく、PlayStateという構造体を使って再生状態を管理するようにしました。 struct PlayButton : View { @Binding var isPlaying : Bool // ここでは、前述のPropertyWrapperの`_`プレフィックスを使って、Wrapper自体(`Binding<Bool>`)を直接代入しています。 //通常、SwiftUIが自動的にinitを生成するため、このコードは省略可能です。 init (isPlaying : Binding < Bool > ) { self ._isPlaying = isPlaying } var body : some View { Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } } } struct PlayerView : View { var episode : Episode @State private var playState : PlayState = . init (isPlaying : false ) var body : some View { VStack { Text(episode.title) .foregroundStyle(playState.isPlaying ? .primary : .secondary) PlayButton(isPlaying : $playState .isPlaying) // Pass a binding. } } } struct Episode { let title : String } struct PlayState { var isPlaying : Bool } #Preview { PlayerView(episode : . init (title : "エピソード1" )) } ここで PlayButton(isPlaying : $playState .isPlaying) というようにアクセスしている部分は PlayButton(isPlaying : playState.projectedValue.isPlaying ) と解釈できます。 playState.projectedValue は Binding<PlayState> 型です。Binding型には当然isPlayingというメンバーは存在しません。しかし、 Binding<Value> のdynamicMemberLookupのsubscriptが返却する型は Binding<Subject> であることから、 Binding<PlayState>.isPlaying は Binding<Bool> になります。 よって、子ViewのPlayButtonのイニシャライザに渡すことができます。 次にPlayButtonの Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } この部分で、isPlayingの値の変更は、Bindingが参照する 単一の信頼できる情報源 である親ViewのisPlayingを変更します。この変数は@Stateで定義されているため、SwiftUIが管理する保存領域が変更され、この値に依存しているUIが更新されるという仕組みになっています。 今回の例では、@Binding自体はこのような特殊な値の保存管理方法をする@Stateな変数を読み書きできるという能力を持っていることがわかります。 逆に@Binding自体はSwiftUIのViewに作用してViewを更新したりする能力は持っていないことに注意する必要があります。@Stateと違って、@BindingはSwiftUIとは一切関係ない機能であると捉えることもできると思います。 まとめ SwiftUIのBinding型はPropertyWrapper、DynamicMemberLookup + KeyPathという言語機能が使われている。 PropertyWrapperは値の定義と値の「 保存方法の管理 」を分離する機能である。 DynamicMemberLookupは動的にメンバーにアクセスできる仕組みで、KeyPathをダイナミックメンバーに用いると、型安全にメンバーにアクセスできる。 Binding型は「 Single Source of Truthな値を読み書きできる 」機能が付与されていて、SwiftUIの@Stateなどで定義された値を読み書きするために主に使われている。しかしBinding自体は、SwiftUIのViewに作用する機能ではない。 SwiftUIは非常に記述量が少なく、簡単に階層的な状態管理を実装することができますが、裏側の仕組みとしてものすごく複雑で面白い言語仕様が使われていることに気がつきました。Swiftのコミュニティと言語仕様に感謝ですね。
アバター
はじめに こんにちは、開発1部でソフトウェアエンジニアをしている新谷です。 今回は、AIエージェントで仕様駆動開発を実現する国産ツール「cc-sdd」を実務で約1ヶ月使用してみたので紹介します。 cc-sddとは cc-sddは、仕様駆動開発をAIエージェントで実現する国産ツールです。 github.com 2025年10月10日時点では、以下のAIツールに対応しています。 Claude Code Cursor IDE Gemini CLI Qwen Code 本記事の事例では、Claude Codeを使用しています。 cc-sddは、3つのファイルを順次承認制で作成していく仕組みになっています。 requirements.md: 要件定義フェーズで使用するファイル。受け入れ基準がEARS記法で書かれます design.md: 技術設計書として使用するファイル。実装の詳細な設計を記述します task.md: 実装可能な単位にタスクを分解したファイルです 各ファイルの具体的な内容については、後述の実務事例で紹介します。 仕様駆動開発を実務で導入したいモチベーション 端的に言うと、設計書を書けばシステムが完成するという開発フローが、チームでの開発生産性を大きく向上させると考えているからです。 見込める開発効率 具体的には、以下のような開発効率の向上が見込めると考えています。 コードを書くときにはAIとやり取りしなくていいので、その間別タスクが可能 design.mdを作るときにチームで設計が問題ないか認識を合わせられる 事前に設計などを共有できているため、コードレビューの負担が軽減される 課題 一方で、以下のような課題もあると考えています。 結局実装しないと分からない部分もあるのではないか design.mdをどの粒度まで作り込むべきか判断が難しい 実務で取り組んだ事例 実際に2つのAPI開発でcc-sddを使用してみました。 前提条件 チーム全員が使っているわけではなく、私だけが使用 バックエンドのAPI開発での適用 まだ1ヶ月程度の使用期間 既存サービスに新しいAPIを追加する実装 最低限のCLAUDE.mdは作成済み 事前にsteeringファイルは作成済み 事例1: アプリ内のプッシュ通知設定API 要件の概要 アプリにプッシュ通知のon/offボタンを作成 現在の状態を取得するAPIと、状態を変更するAPIを実装 作成されたファイルの例 requirements.md(一部) ### Requirement 1: 通知設定の永続化 **User Story:** アプリのユーザーとして、プッシュ通知の受信設定を保存し、その設定が永続的に維持されることを期待する #### Acceptance Criteria 1. WHEN ユーザーが初めてアプリを利用開始する THEN システムはプッシュ通知設定をデフォルトで「有効」として初期化 SHALL 2. IF データベースにユーザーの通知設定が存在しない THEN システムはデフォルト値として「有効」を返す SHALL 3. WHEN ユーザーが通知設定を変更する THEN システムはその設定をデータベースに即座に永続化 SHALL 4. IF データベース保存に失敗した THEN システムはエラーレスポンスを返し、設定は変更されない SHALL 5. WHERE 同一ユーザーが複数デバイスを使用している THE SYSTEM SHALL 全デバイスで統一された通知設定を適用 design.md(一部) ## コンポーネントと インターフェース ### バックエンドサービス & メソッドシグネチャ #### NotificationSettingService type NotificationSettingService struct { repo domainRepository.UserNotificationSettingRepository } // GetUserNotificationSetting ユーザーの通知設定を取得 func (s *NotificationSettingService) GetUserNotificationSetting(ctx context.Context, userID int64) (*domainModel.UserNotificationSetting, error) // UpdateUserNotificationSetting ユーザーの通知設定を更新 func (s *NotificationSettingService) UpdateUserNotificationSetting(ctx context.Context, userID int64, enabled bool) (*domainModel.UserNotificationSetting, error) task.md(一部) ## パート1: 通知設定API機能 ### フェーズ1: データモデルとマイグレーション - [x] 1. データベーススキーマとマイグレーションの作成 - db/migrations/配下に新しいマイグレーションファイルを作成 - user _ notification _ settingsテーブルのCREATE文を実装 - インデックス設計(user _ id, enabled)を含める - ロールバック用のDROP文も実装 - _要件: REQ-1, REQ-6_ cc-sddの適用結果 design.mdは、チームへの共有も含めて3日ほどかけて作成・修正しました task.mdは少し修正した程度で済みました 実装後にコードの大きな修正は不要でした 事例2: ミッション達成計算API 要件の概要 ゲームでよくあるアクションによって実績が解除される機能 事前に決めたミッションをユーザーが達成しているかどうかを判定 ミッションは複数あり、入れ替わりや制限期間はなし 作成されたファイルの例 requirements.md(一部) ### Requirement 1: ミッション管理機能 **Objective:** 管理者として、ミッションの定義と管理を柔軟に行いたい、将来的な拡張が容易にできるようにするため #### Acceptance Criteria 1. WHEN システムが起動される THEN ミッションサービス SHALL サーバー設定から全てのミッション定義を読み込む 2. IF 新しいミッション定義がサーバー設定に追加される THEN ミッションサービス SHALL アプリケーション再起動なしに新ミッションを有効化する 3. WHERE ミッション定義が存在する THE ミッションサービス SHALL 以下の情報を管理する:ミッションID、名称、達成条件、表示順序 4. WHEN ミッション定義が不正な形式で設定される THEN ミッションサービス SHALL エラーログを出力し、該当ミッションを無効化する design.md(一部) ### Domain Layer #### Mission **Responsibility & Boundaries** - **Primary Responsibility**: ミッション定義とその達成条件を管理 - **Domain Boundary**: ミッションドメイン - **Data Ownership**: ミッションのメタデータと達成条件 - **Transaction Boundary**: 読み取り専用(設定ファイルから) **Dependencies** - **Inbound**: MissionService - **Outbound**: なし - **External**: なし **Contract Definition** type Mission struct { ID string `json:"id"` Title string `json:"title"` RequiredCount int `json:"required_count"` } task.md(一部) ## ミッション機能Phase1 実装タスク - [x] 1. データベースとドメインモデルの基盤構築 - [x] 1.1 ユーザーミッション達成記録テーブルの作成 - user _ mission _ completionsテーブルのマイグレーションファイル作成 - user _ id, mission _ id を複合主キーとして定義 - created _ atインデックスの追加 - ロールバック用のダウンマイグレーション作成 - _Requirements: 1.3, 7.1, 8.1_ cc-sddの適用結果 design.mdは5日ほどかけて作成・修正しました task.mdは少し修正した程度でした 実装に関しては、コードの責務や書き方などが不適切で大きく修正が必要でした うまくいかなかった原因 ロジックが複雑だったにもかかわらず、design.mdに詳細を記載できていなかった CLAUDE.mdの記載が不足していた ロジックをどこに配置すべきか、責務の定義が不明確 テストの書き方の指針が不足 task.mdのレビューが不十分だった design.md作成について 今回初めてdesign.mdを作成しましたが、思った以上に時間がかかりました。 原因としては、以下のようなものがあると考えています。 自分の設計力不足 どの粒度で記載すべきかの判断に迷った 別タスクとの並列作業によるスイッチングコスト チームへの設計共有時に発生するレビュー時間 まとめ cc-sddを約1ヶ月実務で使用してみて、以下のことがわかりました。 簡単な新規のAPI追加実装であれば、CLAUDE.mdを適切に書いておくことで修正不要で実装できる可能性がある 複雑なロジックを持つAPIや既存APIの改修については、まだチューニングが必要 design.mdにどこまでの粒度で記載すべきか、まだ明確な基準が定まっていない design.mdの作成には3〜5日かかっており、設計スキルやツールへの習熟が必要 仕様駆動開発は、設計とレビューだけで実装が完了する世界を作れる可能性があると考えています。 今後も試行錯誤を重ねながら、開発速度を爆速にできるよう取り組んでいきたいと思います。
アバター
はじめに こんにちは、トモニテで開発を担当している吉田です。 デジタル広告の運用において、広告パフォーマンスの分析とレポート作成は重要な業務の一つです。しかし、弊社では手動でレポートを作成しており、営業活動に集中する時間を削ってしまう課題がありました。 本記事では、Google Ad Manager(GAM)の REST API と BigQuery を連携させ、レポート作成を自動化するシステムの構築事例について、紹介します。 背景:セールスレポート作成の課題 ビジネス課題 セールスチームが Google Ad Manager の広告レポートを手動で作成する際、以下の課題に直面していました。 レポート作成工数の嵩み : 現状 30 分〜1 時間程度の工数が発生 データ抽出の複雑さ : GAM から直接データを取得する手間 営業活動時間の減少 : レポート作成に時間を取られ、営業活動に集中できない 期待される成果 レポート作成の自動化により、以下の成果を期待しました。 工数削減 : レポート作成時間の短縮 営業活動の強化 : レポート作成時間を営業活動に充て、売上貢献の向上 データ活用の効率化 : BigQuery での SQL による柔軟なデータ抽出 技術選定:REST API の採用 既存システムの課題 社内の別サービスでは、Google Ad Manager の SOAP API を使用していました。しかし、以下の理由から REST API(現在 Beta 版)で実装することを決定しました。 項目 SOAP API REST API 実装の複雑さ XML ベースで複雑 JSON ベースでシンプル エラーハンドリング 複雑な XML パースが必要 標準的な HTTP ステータスコード デバッグの容易さ XML ログの可読性が低い JSON ログで直感的 メンテナンス性 古い技術スタック モダンな技術スタック ドキュメント 限定的 豊富で分かりやすい REST API の選択理由 開発効率の向上 : JSON ベースのシンプルな実装 保守性の向上 : モダンな技術スタックによる将来性 エラー処理の簡素化 : 標準的な HTTP レスポンスの活用 チーム開発の効率化 : より直感的な API 設計 注意 : Google Ad Manager REST API は現在 Beta 版のため、本番環境での使用には注意が必要です。API の仕様変更や制限事項について、公式ドキュメントを定期的に確認することをお勧めします。 システムアーキテクチャ 全体構成 Cloud Run : メイン処理コンテナ GAM REST API を呼び出してレポートデータを取得 取得したデータを BigQuery に格納 GAM REST API : 広告データの提供 BigQuery : データの保存と分析 データフロー 実行開始 : Cloud Run が HTTP リクエストまたはイベントで実行 日付抽出 : リクエストから対象日付を取得 レポート生成 : GAM API を使用してレポートデータを取得 BigQuery 挿入 : 取得したデータを BigQuery に保存 GAM REST API の実装詳細 API クライアントの初期化 from google.ads import admanager_v1 # GAM クライアントの初期化 client = admanager_v1.ReportServiceClient() レポート定義の作成 GAM REST API では、レポートの構造を詳細に定義する必要があります。 def create_report_definition (target_date: date, dimensions: list , metrics: list ) -> admanager_v1.Report: """GAMレポートの定義を作成""" report = admanager_v1.Report() # ディメンションとメトリクスの設定 report.report_definition.dimensions = dimensions report.report_definition.metrics = metrics report.report_definition.report_type = admanager_v1.types.Report.ReportType.HISTORICAL # フィルター条件の設定(特定のプレフィックスから始まるアドユニットのみを対象にする) report.report_definition.filters = [ admanager_v1.types.Report.Filter( field_filter=admanager_v1.types.Report.Filter.FieldFilter( field=admanager_v1.types.Report.Field( dimension=admanager_v1.types.Report.Dimension.AD_UNIT_NAME ), operation=admanager_v1.types.Report.Filter.Operation.MATCHES, values=[ admanager_v1.types.Report.Value(string_value= "PREFIX_.*" ) ] ) ) ] # 日付範囲の設定 report.report_definition.date_range.fixed = admanager_v1.types.Report.DateRange.FixedDateRange( start_date=date_pb2.Date( year=target_date.year, month=target_date.month, day=target_date.day ), end_date=date_pb2.Date( year=target_date.year, month=target_date.month, day=target_date.day ) ) return report レポートの実行とデータ取得 def create_and_run_report (client: admanager_v1.ReportServiceClient, report: admanager_v1.Report) -> str : """GAMレポートを作成して実行""" # レポート作成 request = admanager_v1.CreateReportRequest( parent=f "networks/{NETWORK_ID}" , report=report, ) create_response = client.create_report(request=request) report_id = create_response.report_id # レポート実行 run_request = admanager_v1.RunReportRequest( name=f "networks/{NETWORK_ID}/reports/{report_id}" ) operation = client.run_report(request=run_request) run_result = operation.result() return run_result.report_result データの抽出と変換 GAM API から取得したデータを Pandas DataFrame に変換する処理です。 def extract_dimension_value (dim_value) -> any : """ディメンション値を抽出""" if dim_value.string_value: return dim_value.string_value elif dim_value.int_value: return dim_value.int_value elif dim_value.double_value: return dim_value.double_value # その他の型も同様に処理 else : return None def extract_metric_value (primary_value) -> any : """メトリクス値を抽出""" if primary_value.int_value: return int (primary_value.int_value) elif primary_value.double_value: return primary_value.double_value else : return None def fetch_report_data (client: admanager_v1.ReportServiceClient, report_result_name: str , column_names: list [ str ]) -> pd.DataFrame: """レポートデータを取得してDataFrameに変換""" fetch_request = admanager_v1.FetchReportResultRowsRequest( name=report_result_name ) rows_response = client.fetch_report_result_rows(request=fetch_request) rows_list = [] for row in rows_response: row_data = [] # ディメンション値を取得 for dim_value in row.dimension_values: row_data.append(extract_dimension_value(dim_value)) # メトリクス値を取得 for metric_group in row.metric_value_groups: for primary_value in metric_group.primary_values: row_data.append(extract_metric_value(primary_value)) rows_list.append(row_data) df = pd.DataFrame(rows_list, columns=column_names) return df BigQuery との連携設計 スキーマ設計の考え方 BigQuery へのデータ保存では、以下の設計思想を採用しました。 日付別テーブル分割 : パフォーマンスとコスト最適化 型安全性の確保 : 適切なデータ型の設定 効率的なクエリ : 分析に適したスキーマ設計 スキーマ定義 以下のスキーマ定義は一例です。実際のプロジェクトでは、ビジネス要件や分析ニーズに応じて適切なカラム名とデータ型を設定してください。 # ディメンションのスキーマ DIMENSION_SCHEMA = [ bigquery.SchemaField( "date" , "INTEGER" ), bigquery.SchemaField( "advertiser_name" , "STRING" ), bigquery.SchemaField( "advertiser_id" , "INTEGER" ), bigquery.SchemaField( "order_name" , "STRING" ), bigquery.SchemaField( "order_id" , "INTEGER" ), bigquery.SchemaField( "line_item_type" , "STRING" ), bigquery.SchemaField( "line_item_name" , "STRING" ), bigquery.SchemaField( "line_item_id" , "INTEGER" ), bigquery.SchemaField( "ad_unit" , "STRING" ), bigquery.SchemaField( "ad_unit_id" , "INTEGER" ), bigquery.SchemaField( "demand_channel_name" , "STRING" ), bigquery.SchemaField( "creative_name" , "STRING" ), bigquery.SchemaField( "creative_id" , "INTEGER" ), ] # メトリクスのスキーマ METRICS_SCHEMA = [ bigquery.SchemaField( "total_impressions" , "INTEGER" ), bigquery.SchemaField( "total_clicks" , "INTEGER" ), bigquery.SchemaField( "total_ctr" , "FLOAT" ), ] BigQuery へのデータ挿入 def insert_df_to_bigquery (df: pd.DataFrame, target_date: date, bigquery_schema: list [bigquery.SchemaField], table_name: str ): """Pandas DataFrameをBigQueryに挿入""" client = bigquery.Client() job_config = bigquery.LoadJobConfig( schema=bigquery_schema, write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, ) date_str = target_date.strftime( '%Y%m%d' ) table_id = f "{PROJECT_ID}.{DATASET}.{table_name}_{date_str}" job = client.load_table_from_dataframe(df, table_id, job_config=job_config) job.result() Cloud Run の実装 メイン処理の実装 @ cloud_event def main (cloud_event: CloudEvent) -> None : """Cloud Run のエントリーポイント""" try : # 対象日付を抽出 target_date = extract_target_date(cloud_event) # GAM レポートデータを取得 df = get_gam_report_data(dimensions, metrics, column_names, target_date) # BigQuery に挿入 insert_df_to_bigquery(df, target_date, schema, table_name) print ( "レポート処理が完了しました" ) except Exception as e: print (f "処理でエラーが発生しました: {e}" ) raise e 運用面での工夫 Cloud Run のデプロイと実行 Cloud Run のデプロイは gcloud コマンドで行い、以下の設定で実行されます。 Region : asia-northeast1 Runtime : Python 3.13 Memory : 512MB Trigger : HTTP 手動実行のためのコマンド 運用効率を向上させるため、以下のような手動実行用のコマンドを作成しました。 単独日付指定 : 特定の日付のレポートを生成 範囲指定 : 開始日から終了日までの期間でレポートを一括生成 これらのコマンドにより、スケジュール実行以外にも必要に応じて柔軟にレポートを生成できるようになっています。 システムの実行方式 システムは Cloud Run として実装されており、様々な実行パターンに対応できます。例えば、以下のような方法があります。 手動実行 : HTTP トリガーによる直接実行 スケジュール実行 : Cloud Scheduler による定期実行 イベント駆動 : Pub/Sub や Eventarc を経由した実行 ブログ内で言及はしていませんが、弊社では Cloud Scheduler から Pub/Sub トピックを起動し、サブスクリプションを通じて Cloud Run を定期実行する仕組みを構築しています。この仕組みにより、毎日決まった時間にレポートデータが自動的に更新され、手動作業を大幅に削減できています。 実装で得られた知見 1. GAM REST API の特徴 メリット : JSON ベースで直感的な実装 豊富なドキュメントとサンプルコード 標準的な HTTP エラーハンドリング 注意点 : レポート実行は非同期処理のため、完了待ちが必要 大量データの場合はページネーションが必要 レート制限に注意が必要 2. BigQuery との連携 最適化のポイント : 日付別テーブル分割によるクエリ性能向上 適切なスキーマ設計によるストレージコスト削減 WRITE_TRUNCATE モードによる冪等性の確保 成果と今後の展望 期待される成果 工数削減 : レポート作成時間の短縮(現状 30 分〜1 時間) 営業活動の強化 : レポート作成時間を営業活動に充て、売上貢献の向上 データ活用の効率化 : BigQuery での SQL による柔軟なデータ抽出 まとめ Google Ad Manager REST API と BigQuery の連携により、セールスレポート作成の自動化を実現しました。 このシステムにより、セールスチームが営業活動により多くの時間を割けるようになり、結果として売上の向上に貢献することが期待されます。 同様の課題を抱えている組織の参考になれば幸いです。 参考 developers.google.com googleapis.dev googleapis.dev cloud.google.com
アバター
1. はじめに こんにちは、everyで1ヶ月間のインターンシップに参加させていただいた宮田です。本記事では、デリッシュキッチンの新機能開発に携わった経験と、そこで得られた学びを紹介します。 現在、デリッシュキッチンの既存仕様に対して、ユーザー体験を向上させるための新しい機能開発を進めています。今回のタスクでは、ユーザーをグループ化する新機能のバックエンドAPI実装を担当しました。 2. プロジェクト全体像と技術スタック デリッシュキッチンサーバーの概要は下の図のようになっています。ダッシュボード側ではユーザー情報の管理・監視を行い、モバイルアプリ側ではデータベースからリモートキャッシュにセットした情報をユーザー管理画面に表示します。詳細は DELISH KITCHENのシステムアーキテクチャ で説明しています。今回は、ユーザーをグループ化するAPIとそのグループに招待するコード作成・取得機能を中心としたAPIを実装しました。 技術スタック バックエンド : Go (Echo) データベース : MySQL リモートキャッシュ : Redis 3. デリッシュキッチンサーバー・バックエンド実装 デリッシュキッチンのバックエンドはクリーンアーキテクチャで構成されています。クリーンアーキテクチャとは、ビジネスロジックを外部のフレームワークやツールから切り離すことで、保守性・拡張性を高める設計手法です。主にrepository、infrastructure、service、handler、routerの5つの階層を用いています。最近では、多くの企業で標準的に採用されているようですが、私は今回が初めての経験だったため、概念の理解やコード分割に苦戦しました。 infrastructure・repository infrastructureは、外部システムとの接続やデータの永続化を担当する層です。repositoryは、データアクセスロジックを抽象化し、ビジネスロジックからデータベースの実装詳細を隠蔽する役割を持ちます。この2つによって、データベース操作の詳細をビジネスロジックから分離し、テスタビリティと保守性を向上させています。 今回のグループ機能実装では、グループの作成・招待コード生成・招待コード取得のためのリポジトリインターフェースを定義し、MySQL用の実装を作成しました。 // 招待コード作成のリポジトリ実装例 func (r *InvitationCodeRepository) CreateTx(ctx context.Context, tx dbr.SessionRunner, m *model.InvitationCode) (*model.InvitationCode, error ) { result, err := tx.InsertInto(r.getTable()). Columns( "group_id" , "invitation_code" , "expires_at" , "is_active" ). Record(m). Exec() if err != nil { return nil , e.Wrap(err, "couldn't create invitation code" ) } id, err := result.LastInsertId() if err != nil { return nil , e.Wrap(err, "couldn't get last insert id" ) } m.ID = id return m, nil } データベースへのINSERT操作をトランザクション内で実行しています。招待コード作成ではグループとの関連( group_id )と状態管理( is_active 、 expires_at )を含めたレコードを作成しています。 LastInsertId() で生成されたIDを取得してモデルに設定し、エラー処理は pkg/errors パッケージでラップして詳細な情報を保持しています。 service serviceは、ビジネスロジックを実装する層で、repositoryを通じて取得したデータに対して業務要件を満たす処理を行います。複数のrepositoryを組み合わせて複雑な処理を実現し、トランザクション管理も担当します。 今回の実装では、グループへの招待コード自動生成や、招待コード取得時のアクセス権限チェックなどのビジネスルールを実装しました。特に招待コードは、セキュリティを考慮してランダム文字列生成と有効期限設定を行っています。 // 招待コード生成のサービス実装例 func (s *InvitationCodeServiceImpl) CreateInvitationCode(ctx context.Context) (*model.InvitationCode, error ) { // トランザクション開始 session := db.GetSession( "t3" ) tx, err := session.Begin() if err != nil { return nil , e.Wrap(err, "failed to begin transaction" ) } defer tx.RollbackUnlessCommitted() // 既存の招待コードを無効化 _, err = s.invitationCodeRepo.DeactivateByGroupIDTx(ctx, tx, group.ID) if err != nil { return nil , e.Wrap(err, "failed to deactivate existing invitation codes" ) } // セキュアなランダム文字列生成 code, err := random.GenerateInvitationCode() if err != nil { return nil , e.Wrap(err, "failed to generate invitation code" ) } // 24時間の有効期限設定 expiresAt := time.Now().Add( 24 * time.Hour) newInvitationCode := model.NewInvitationCode(group.ID, code, expiresAt) createdInvitationCode, err := s.invitationCodeRepo.CreateTx(ctx, tx, newInvitationCode) if err != nil { return nil , e.Wrap(err, "failed to create invitation code" ) } if err := tx.Commit(); err != nil { return nil , e.Wrap(err, "failed to commit transaction" ) } return createdInvitationCode, nil } serviceレイヤーでは、複数のリポジトリを組み合わせたビジネスロジックを実装しています。招待コード生成では、トランザクション管理下で既存コードの無効化と新規コード生成を一貫して行い、ACID特性を保証しています。また、セキュリティ面では24時間の有効期限設定やランダム文字列生成を行い、システムの安全性を確保しています。 handler・router handlerは、HTTPリクエストを受け取り、リクエストデータの検証、serviceの呼び出し、レスポンスの組み立てを行う層です。routerは、URLパスとHTTPメソッドに基づいて適切なhandlerにリクエストを振り分ける役割を担います。この2つで、外部からのAPIリクエストを適切に処理し、JSONレスポンスを返すWebAPIを実現しています。 今回は、グループ作成・招待コード生成・招待コード取得の3つのエンドポイントを実装しました。各エンドポイントでは、リクエストパラメータのバリデーション、認証チェック、エラーハンドリングを適切に行っています。 // 招待コード作成のハンドラー実装例 func (h *InvitationCodeHandlerImpl) CreateInvitationCode(c echo.Context) error { user := h.userAuth.GetUser(c) if user == nil { return types.ErrNotAuthorized } invitationCodeModel, err := h.invitationCodeService.CreateInvitationCode(dctx.NewUserContext(c)) if err != nil { return err } invitationCodeResponse := response.NewInvitationCode(invitationCodeModel) return JSONHTTPSuccessHandlerAsMap( "invitation_code" , invitationCodeResponse, c) } handlerレイヤーでは、HTTPリクエストを受け取ってserviceレイヤーに処理を委譲し、適切なJSONレスポンスを返しています。全てのエンドポイントで共通して認証チェック( userAuth.GetUser() )を実行し、未認証の場合は ErrNotAuthorized エラーを返しています。また、 dctx.NewUserContext() でユーザー情報をコンテキストに埋め込み、service層でユーザー固有の処理ができるようにしています。レスポンス生成では、統一的なフォーマット( JSONHTTPSuccessHandlerAsMap )を使用してクライアントに一貫した形式でデータを返すよう設計されています。 4. インターンシップを通じて学んだこと GoとTypeScriptの比較 今回初めてGoを使用して開発を行ったため、書き方や仕様を把握するのが大変でした。普段はTypeScriptを使用することが多いのですが、Goを触ったことで以下のような気づきを得ました。 型の違い TypeScriptでは柔軟で表現力が高いのに対し、Goはシンプルで設計の曖昧さを許さないという違いがあります。ポインタやスライス設計を意識せざるを得ない点は新鮮でした。 非同期処理とcontext TypeScriptはPromise/async-awaitが主流ですが、Goはcontext.Contextで処理のライフサイクルを統一的に管理できます。これは信頼性を高める強力な仕組みだと実感しました。 テスト文化 Jestでの振る舞いテストが中心のTypeScriptに比べて、Goは層ごとの責務を意識してモックを徹底的に利用します。特に、データベースに直接触らずにテストするという設計方針は強く印象に残りました。 実装について アーキテクチャ設計とパフォーマンス 今回の実装では、プロジェクトのコーディング規約に従った型設計の重要性を学びました。例えば、スライス型の設計では []Type ではなく []*Type を使用することで、パフォーマンス向上とコードベース全体の一貫性を保つことができます。また、クリーンアーキテクチャにおける依存関係の管理では、定義されていない方法での依存が発生しないよう、各層の責務を明確に分離することが重要でした。 トランザクション管理とエラーハンドリング データベース操作では、単体の関数とトランザクション版の関数を分離し、前者は後者を呼び出すだけにしてメインロジックは後者に集約する設計パターンを学びました。エラー処理では、 == ではなく errors.Is() を使用した適切な比較や、 types パッケージで定義された標準エラーの活用により、一貫性のあるエラーハンドリングを実現できました。 コード効率性とパフォーマンス最適化 実装時には、早期returnの活用やfor-rangeでの要素検索における標準パッケージ slices の使用など、効率的なロジック設計を心がけました。また、無駄なDBアクセスを避けるためのロジック設計や、ORMの LoadOne メソッドを適切に使用することで、パフォーマンスの向上を図りました。 エラー処理の考え方 TypeScriptのtry-catchに比べ、Goは戻り値で明示的にerrorを返すため、どこで失敗する可能性があるかが明確に見えます。特に、infrastructure層でwrapしたエラーをservice層で再度wrapするかどうかの判断や、エラーの発生源を意識したスタックトレース設計の重要性を学びました。 コーディング規約と命名規則 変数名とコメントの適切性 実装時には、変数名がデータベースのカラム名や既存のプロジェクト慣習に則っているかを常に確認することの重要性を学びました。また、コードを読めば分かる内容についてはコメントを書かず、本当に必要な説明のみをコメントとして残すことで、コードの可読性を向上させることができました。 関数の命名と設計 新しい機能を作成する際には「Add」ではなく「Create」を使用するなど、既存のコードベースの命名規則に従うことの重要性を実感しました。また、使用されていないinterfaceや関数定義は削除し、コードベースをクリーンに保つことも大切だと学びました。 テスト実装 モックの活用とテスト設計 単体テストでは実データベースを使用せず、下位のservice/repositoryにはモックを使用することで、テストの独立性と実行速度を確保できました。テストケース作成時には「このテストで何が検証できているのか」を常に意識し、冗長なテストを避けることの重要性を学びました。 並列テストとテストケース設計 DBアクセスを行わないテストでは t.Parallel() を使用した並列化を必ず行い、テスト実行時間を短縮しました。また、全てのエラーパターンを網羅的にテストケースに含め、特に gomock.Any() ではなく具体的な型での検証を行うことで、より堅牢なテストを実現できました。 プルリクエストとレビュー文化 プルリクエスト作成時の配慮 PR作成時には、将来のタスクで使用予定の実装でも、今回のPRに関係ない部分はレビューの邪魔になるため除外することの重要性を学びました。また、テストが落ちている状態でPRを作成しないよう、事前にテストを実行して通った状態にしておくことも基本的なマナーだと感じました。 レビュー可能なPRの作成 プロジェクトに関わっていないレビュアーでもレビューできるよう、PRのdescriptionには初見では分からない情報や背景を丁寧に記載することの大切さを実感しました。これにより、チーム全体での知識共有とコードの品質向上に貢献できます。 レビュー文化 レビューの返ってくるスピードの早さに驚きました。レビューをしないと他の人の作業を止めてしまう、また、人のコードを客観的に見ることで自分も勉強になるから優先的にレビューを行うという考え方が非常に良いと思い、ぜひ自分も真似していきたいと感じました。モックの生成コマンドをMakefileに追加するなど、チーム開発での協調性を意識した細かい配慮も重要だと学びました。 5. まとめ 1ヶ月間のインターンシップを通して、デリッシュキッチンのグループ機能という大事な新機能実装を任せていただいて非常に貴重な体験となりました。普段行っているWeb開発では体験できないテストやCI/CDの自動化ツールであったり、リリース作業などを体験させていただけました。これまで概念として知っていたデータベースのインデックスやトランザクションなど、実際に自分の知識を初めてコードに反映することができてよかったです。また、細かくレビューしていただいたことで、商用としてのより良い実装だけでなく、社内の実装ルールやPR作成時に気をつけなければいけないことなど、自分の中に今までなかった様々なことを学ばせていただきました。今回のインターンシップ参加を通して、従業員として業務をこなしたことによる新しい発見や成長を得ることができ、自分がこれから勉強するべきことなども見つけることができました。また、どれだけ既存コードが理解できていなくてわからない状態でも、実装や開発は非常に楽しいなと常に思っていたので、改めて自分が開発が好きだということを再確認できてよかったです。これからは、今回の実装で学んだことやレビューいただいた内容を元に、どんどん成長して、より良いエンジニアになっていきたいです。
アバター
はじめに こんにちは。デリッシュキッチン開発部でバックエンドエンジニアをしている鈴木です。 Go言語の組み込み関数 len() は、一見シンプルに配列やスライスなどの「長さ」を返す関数ですが、その実装はコンパイラやランタイムレベルで特別な扱いを受けています。本記事では、 len の言語仕様からコンパイラ内部の処理フロー、SSA最適化、最終的なアセンブリコード、さらにはruntime内部構造体に至るまでを網羅的に順を追って詳しく説明していきます。 len の仕様と定数評価 まず、Go言語仕様において len(x) がどのように定義されているかを確認しましょう。 len は組み込み関数であり、以下のような様々な型に適用できます。 文字列 ( string ) : バイト数(文字列の長さ)を返す 配列 ( [n]T またはポインタ *[n]T ) : 配列の要素数を返す(固定長n。ポインタ経由でも配列長は型で決まる) スライス ( []T ) : スライスの現在の長さ(要素数)を返す マップ ( map[K]T ) : マップに定義されているキーの数を返す チャネル ( chan T ) : チャネルのバッファに蓄積されている要素数を返す いずれの場合も len(x) の返り値の型は int であり、その値は必ず int 型に収まります。また、 nil のスライス・マップ・チャネルに対する長さは常に0 になることが明示されています。 さらに len は場合によって コンパイル時定数 として評価されます。具体的には、 引数が 文字列リテラル の場合、 len の結果はコンパイル時定数になります(文字列のバイト数をそのまま定数として扱う)。 引数の型が 配列型 (または配列へのポインタ型)で、その引数の式にチャネル受信や非定数関数呼び出しを含まない場合、 len と cap の結果は定数とみなされます。この場合、その配列式自体は実行時に評価されません。言い換えれば、配列長がコンパイル時に判明していて副作用もないとき、コンパイラは len を単なる定数として処理します。 例 以下のように、長さが決まっている配列リテラルに対する len はコンパイル時定数となり得ます(Go仕様より) const c1 = 1.0 const c2 = len ([ 10 ] float64 { 2 }) // [10]float64{2}には関数呼び出しがなく定数とみなせる const c3 = len ([ 10 ] float64 {c1}) // c1自体は定数なのでlen(...)は定数 Fig. 1. コンパイル時定数となる len の例 以上の仕様から、 len は他の言語における通常の関数というより 演算子的な性質 を持つ設計になっていることが分かります。その場で値を計算するというよりも、「この値(または型)の長さ」というビルトインのプロパティを返すものとして扱われます。 コンパイラ内部での len 処理フロー len は組み込み関数として コンパイラに特別扱い されます。Goコンパイラは構文解析・型チェック・SSA変換・最適化・コード生成といった複数のフェーズを経てソースコードを機械語に変換します。ここでは len がソースからどのようにコンパイルされていくか、主要な段階ごとに追ってみましょう。 Universeブロックへの組み込み関数登録 Goでは Universeブロック と呼ばれる特別な領域に、組み込みの定数・型・関数があらかじめ定義されています。 len もこの中で定義されており、コンパイラ起動時に下記のように登録されます( len は内部的な演算コード OLEN に対応付けられます)。 { "append" , ir.OAPPEND}, { "cap" , ir.OCAP}, { "clear" , ir.OCLEAR}, { "close" , ir.OCLOSE}, { "complex" ,ir.OCOMPLEX}, { "copy" , ir.OCOPY}, { "delete" , ir.ODELETE}, { "imag" , ir.OIMAG}, { "len" , ir.OLEN}, { "make" , ir.OMAKE}, ... Fig. 2. 組み込み関数と内部コードの対応( len は ir.OLEN として登録) 上記はコンパイラ内部 ( cmd/compile/internal/typecheck/universe.go ) での builtinFuncs 配列の一部です。コンパイラはこれを使って、ソース中で len という識別子を見つけた際に通常の関数ではなく 組み込み関数として処理 します。実際、構文解析の段階で len(x) という構文を読み込むと、 len は単なる関数呼び出しではなく「組み込み関数 len の適用」という特別なノードとしてAST(抽象構文木)に格納されます。 型チェックとAST変換 構文解析後、コンパイラはAST上で各ノードの型チェックを行い、不正な操作を検出したり必要な変換を施したりします。 len については 関数呼び出しではなく単項演算子的な扱い になるため、型チェック段階でASTノードが変換されます。具体的には、 len(x) に対応するノードは ir.UnaryExpr (単項式)に置き換えられ、その操作種別として ir.OLEN が設定されます。 また型チェック中に、 len の引数の型が正しいかどうかを検証します。Goコンパイラ内部では先述の通り len が適用可能な型を予めフラグテーブル okforlen で定義しており、例えば配列・チャネル・マップ・スライス・文字列に対して len が使えるよう真に設定されています( src/cmd/compile/internal/typecheck/universe.go )。 okforlen[types.TARRAY] = true okforlen[types.TCHAN] = true okforlen[types.TMAP] = true okforlen[types.TSLICE] = true okforlen[types.TSTRING] = true Fig. 3. 組み込み関数 len が適用可能な型の定義(コンパイラ内部テーブル) 型チェック関数 typecheck1 内では、ノードの種類が OLEN (または OCAP )の場合に専用の処理に分岐し( src/cmd/compile/internal/typecheck/typecheck.go )、関数 tcLenCap で詳細なチェックと型設定を行います( src/cmd/compile/internal/typecheck/expr.go )。その実装コードの概略をFig. 4に示します。 switch n.Op() { ... case ir.OCAP, ir.OLEN: n := n.(*ir.UnaryExpr) return tcLenCap(n) } // tcLenCap typechecks an OLEN or OCAP node. func tcLenCap(n *ir.UnaryExpr) ir.Node { n.X = Expr(n.X) n.X = DefaultLit(n.X, nil ) n.X = implicitstar(n.X) ... var ok bool if n.Op() == ir.OLEN { ok = okforlen[t.Kind()] } else { ok = okforcap[t.Kind()] } if !ok { base.Errorf( "invalid argument %L for %v" , l, n.Op()) n.SetType( nil ) return n } n.SetType(types.Types[types.TINT]) return n } Fig. 4. len / cap ノードの型チェック処理(不正な型ならエラーし、戻り値型を int に設定) 上記のように、まず len の引数 n.X を再帰的に式として型チェックし( Expr(n.X) 等)、デフォルトのリテラル型適用やポインタ間接の暗黙的挿入( implicitstar )を行った後、 okforlen テーブルを参照して引数型が許容されるか検査しています。もし許可されない型であればエラーを報告し(invalid argument for len )、ノードの型を nil にして終了します。問題なければ、 len ノード自体の型( n.Type )を int 型に設定します。これにより、この時点でコンパイラは「 len(x) の結果は int である」ことをAST上で確定させるわけです。 型チェック段階までで特に重要なのは、 len が 実際の関数呼び出しではなくコンパイラ内部で特別扱いされる 点です。Goの組み込み builtin.go には func len(v Type) int と宣言されていますが実体はなく、IDEなどで定義を見ても空っぽな関数が出てくるだけです。これはコンパイラがビルトインを直接処理するためで、 len はユーザが実装を見るような通常の関数ではないのです。 SSA形式への変換(中間表現) すべての型チェックが終わると、次は SSA形式 への変換(静的単一代入形式の中間表現)に入ります。Goコンパイラでは各関数ごとにASTからSSAを構築し、最適化を行った後、機械語の生成へと進みます。 len についてはSSA生成時にさらに各型ごとに扱いが分岐します。その処理を示したのが以下のコードです( src/cmd/compile/internal/ssagen/ssa.go ) // expr converts the expression n to ssa, adds it to s and returns the ssa result. func (s *state) expr(n ir.Node) *ssa.Value { ... switch n.Op() { case ir.OLEN, ir.OCAP: n := n.(*ir.UnaryExpr) // Note: all constant cases are handled by the frontend. If len or cap // makes it here, we want the side effects of the argument. See issue 72844. a := s.expr(n.X) t := n.X.Type() switch { case t.IsSlice(): op := ssa.OpSliceLen if n.Op() == ir.OCAP { op = ssa.OpSliceCap } return s.newValue1(op, types.Types[types.TINT], a) case t.IsString(): // string; not reachable for OCAP return s.newValue1(ssa.OpStringLen, types.Types[types.TINT], a) case t.IsMap(), t.IsChan(): return s.referenceTypeBuiltin(n, a) case t.IsArray(): return s.constInt(types.Types[types.TINT], t.NumElem()) } Fig. 5. len / cap ノードのSSA変換処理(引数の型に応じて異なるSSA命令や定数に展開) 上記のように、SSA生成フェーズでは len (および cap )に対し以下のような分岐処理が行われます。 引数が 配列型 の場合(デフォルトケース) — n.X.Type().NumElem() で配列要素数を取得し、単にその値を定数(SSA上の定数値)として返します。すなわち、コンパイル時点で配列長が分かる場合、SSA上では既にリテラルな定数となります。例えば [5]int 型の変数であれば、その len は5という定数になります。この実装ではコンパイル時に型オブジェクトから NumElem() メソッドで配列長(内部的には型情報中の Bound フィールド)を取得しています(Fig. 5中の t.NumElem() 部分)。 引数が スライス型 の場合 — ssa.OpSliceLen というSSA命令を生成します( cap の場合は ssa.OpSliceCap )。これはスライスの長さ情報を取り出す専用のSSA命令です。 引数が 文字列型 の場合 — ssa.OpStringLen というSSA命令を生成します。文字列については cap は存在しないので len の場合だけです。 引数が マップ型またはチャネル型 の場合 — s.referenceTypeBuiltin という専用のヘルパー関数を呼び出します。マップとチャネルは内部実装が参照型であるため、これらについては汎用的な処理が取られています(この部分は次節で詳説)。 このSSA段階の分岐により、 len の動作は 引数の型ごとに最適化 されます。配列長は定数畳み込みされ、スライス・文字列長はSSA上で専用命令(後述のとおり最終的には単なるメモリアクセスに変わる)となり、マップ・チャネル長は多少複雑な処理( nil チェックを含むコード)に展開されます。 コード生成と最適化 SSAフォームへの変換後、コンパイラはアーキテクチャ固有の最適化・コード生成を行います。 len に関しても、この段階でSSA命令が具体的な機械語に置き換わります。各型における主な変換は以下のとおりです。 配列 : SSA上既に整数定数になっているため、そのまま即値リテラルとしてコード中に埋め込まれます。実行時の計算は発生しません。 スライス・文字列 : OpSliceLen や OpStringLen といったSSA命令は、実行時には 構造体の該当フィールドを読み取る単純な命令 に変換されます。Goにおけるスライスは実体として内部にポインタ・長さ・容量のフィールドを持つ構造体で表現されますし、文字列もデータへのポインタと長さを持つ構造体です。したがって、例えばスライスの長さ取得はメモリ上で「ポインタの直後にある int 値」を読み出す操作になります。GoコンパイラはSSA最適化の Late Expansion 段階でこれらを展開し、ポインタ演算で適切なオフセットから長さを取り出すコードにします(典型的にはポインタサイズ分オフセットした位置がlenフィールドです)。実際、x86-64アーキテクチャではスライス長の取得は1命令で完了します。例えば「レジスタに入っているスライス構造体の長さフィールドを別のレジスタに移す」といった具合です。 (具体例は後述) マップ・チャネル : これらも内部的にはポインタで表現された参照型で、runtimeパッケージ内の構造体( hmap や hchan )として実装されています。 len を求める場合、構造体の先頭に格納されたフィールド(マップなら要素数 count 、チャネルならキュー中の要素数 qcount )を読み出せば良いのですが、 注意点はポインタが nil の可能性 です。 len(nil) が0を返すという仕様を守るため、コンパイラは nil チェックをコード中に組み込みます。SSAで生成された referenceTypeBuiltin 関数内の処理はまさにそれを実現しています。 Fig. 6は referenceTypeBuiltin 関数内の該当部分を抜粋したものです( src/cmd/compile/internal/ssagen/ssa.go )。このコードは「マップ/チャネルの len / cap 用のSSAコード」を生成します。 lenType := n.Type() nilValue := s.constNil(types.Types[types.TUINTPTR]) cmp := s.newValue2(ssa.OpEqPtr, types.Types[types.TBOOL], x, nilValue) b := s.endBlock() b.Kind = ssa.BlockIf b.SetControl(cmp) b.Likely = ssa.BranchUnlikely bThen := s.f.NewBlock(ssa.BlockPlain) bElse := s.f.NewBlock(ssa.BlockPlain) bAfter := s.f.NewBlock(ssa.BlockPlain) ... switch n.Op() { case ir.OLEN: // length is stored in the first word for map/chan s.vars[n] = s.load(lenType, x) ... } return s.variable(n, lenType) Fig. 6. マップ/チャネルに対する len / cap のSSA展開( nil チェックと長さフィールド読み取りの生成) この生成ルーチンでは、まず引数ポインタ x が nil と等しいか比較するSSA値を作り( OpEqPtr )、if文ブロックを構築しています。 cmp が真(つまりポインタが nil )の場合は「 len =0」を返す経路、偽の場合は実際に長さを読み取る経路に分岐する形です。実際の長さ読み取りは、 s.load(lenType, x) によって行われます。これは与えられたポインタ x (マップまたはチャネル)から lenType ( int 型)の値を読み取る、つまり構造体の先頭の int フィールドを読み出すことを意味します。上述のようにマップでは先頭に count 、チャネルでは先頭に qcount があるため、ちょうどそれが len の返すべき値になっています。 最後に return s.variable(n, lenType) とすることで、この計算結果をSSA上の変数(仮想レジスタ)として len ノードに対応付けています。こうしてSSA上では、 nil 分岐と読み取りコードが表現され、最終的にバックエンドでこれが具体的な分岐命令とメモリアクセス命令に変換されます。 型ごとの len の挙動と実装詳細 以上、コンパイラ内部での変換処理を見てきました。ここで改めて、各データ型について len がどのように動作し、最終的にどんなコードになるのかをまとめます。 配列に対する len 配列型( [n]T )に対する len は コンパイル時に決まる定数 です。配列の長さ n はその型の一部であり、Goではコンパイル時に配列サイズが確定しています。したがって、配列変数や配列リテラルに対する len は、コンパイラがその場で n という定数値に置き換えます。 var a [ 5 ] int fmt.Println( len (a)) // コンパイル時にlen(a)は5に置き換えられる Fig. 7. 配列に対する len の使用例(コンパイル時に定数5に置き換わる) 上記 len(a) は実行時の計算を必要とせず、生成されるコード上では定数5として扱われます。仮にポインタ型 *[5]int であっても、指している配列長は5と決まっているため、同様に len(p) は5となります。ただし、ポインタが nil であっても配列長自体は型から分かるため、 (*[5]int)(nil) に対する len も5を返します(もっとも、そのようなコードを書くことは稀ですが、仕様上そうなっています)。 コンパイラ実装的には、配列長は型オブジェクト内のフィールド( types.Array.Bound src/cmd/compile/internal/types/type.go )に保持されており、 NumElem() メソッド( src/cmd/compile/internal/types/type.go )で取得可能です。Fig. 8にGoコンパイラ内部の配列型定義の抜粋を示します。 // 配列型の定義(コンパイラ内部表現) type Array struct { Elem *Type // 要素の型 Bound int64 // 要素数(未確定の場合<0) } func (t *Type) NumElem() int64 { t.wantEtype(TARRAY) return t.Extra.(*Array).Bound } Fig. 8. 配列型 Array の定義と長さを返す NumElem メソッド このようにしてコンパイラは配列長を取得します。結果として、 配列の len 呼び出しは単なる定数参照 となり、ランタイムコストはゼロです。 スライスに対する len スライス( []T )は可変長のシーケンスを表す構造体で、内部的にはポインタ(配列データへの参照)、長さ Len 、容量 Cap の3つのフィールドから構成されています。Goの標準パッケージ reflect では以下のように定義されています( src/reflect/value.go )。 type SliceHeader struct { Data uintptr Len int Cap int } Fig. 9. スライスの内部構造体 SliceHeader スライスに対する len(s) は、この構造体の Len フィールドの値を返します。コンパイル後の機械語では、スライスの長さ取得は 対応するフィールドを読み出すだけ の操作になります。例えばx86-64の場合、スライスがレジスタやスタック上に載っていれば、その Len 部分をMOV命令で読み込むだけで済みます。 コンパイラはSSA命令 OpSliceLen でスライス長取得を表し、最終的なコード生成時にそれを適切なオフセットの読み出し命令に置き換えます。すでに述べた通り、 Len フィールドは構造体先頭のポインタの直後に位置するため、ポインタのサイズ分オフセットしたメモリアドレスから int 値を読み取れば len が得られます。コンパイラはこの offset 計算と読み出しを自動的に行います。 nil スライス の場合でも、内部表現上は Data=nil, Len=0, Cap=0 という構造体値になっています。したがって len(nilSlice) もメモリ上0を読み取るだけで、特別な分岐なしに0が得られます。スライスに関しては、 nil であっても長さフィールドは常に0にセットされているため、 余分な nil チェックは不要 という点がマップ/チャネルとは異なります。このため、例えば関数の引数でスライスを受け取る場合、コンパイラは nil かどうかに関わらず単一の命令で長さを取得するコードを生成します。 文字列に対する len 文字列( string )は不変のバイト列を表す型で、内部的にはデータへのポインタと長さを持つ点でスライスに似ています(容量がないぶんスライスよりフィールドが一つ少ない構造体です)。 len(str) は 文字列のバイト数 (メモリ上の長さ)を返します。こちらも実行時には文字列構造体の長さフィールドを読み取るだけで、スライス同様に1命令で取得可能です。 文字列は nil という値は存在しません(空文字列""は Len=0 ですが Data フィールドはスライスとは異なりゼロではない可能性があります)。しかし言語仕様上、文字列はゼロ値では Data フィールドが特定の nil ではなく 別の特殊な場所 を指している実装になっていますが、長さは0となっています。そのため len("") は0を返し、その他の場合も格納されたバイト数を返します。 マップに対する len マップ( map[K]V )は参照型で、内部的にはハッシュマップ構造体( runtime.hmap 型)のポインタとして実装されています。 len(m) はマップの要素数(エントリ数)を返します。ランタイムの hmap 構造体定義を見ると、先頭に count というフィールドがあり、そこに現在の要素数が保持されています。以下にその一部を示します( src/runtime/map_noswiss.go )。 type hmap struct { count int // # live cells == size of map. Must be first (used by len() builtin) flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets unsafe.Pointer ... } Fig. 10. マップの内部構造体 hmap (先頭の count に要素数を保持) この count こそが len の返す値です。コンパイル後のコードでは、マップのポインタ( *hmap )がレジスタなりメモリなりにあるとして、そのアドレスに対して 先頭の int 値を読み出す 処理になります。例えばx86-64では、マップポインタがレジスタRDIに入っている場合、 MOVQ (RDI), AX のような命令で先頭8バイト(64ビット)の count をAXレジスタに読み込み、それを返り値とする、といったコードになります。 しかしマップの場合、スライスと違い ポインタが nil である可能性 があります。 nil マップは要素数0と定義されているため、 nil を扱う際は0を返さねばなりません。 nil ポインタのまま先頭を読みに行けばメモリアクセス違反になるため、コンパイラは事前に nil かどうかチェックするコードを生成します(Fig. 6参照)。実際のアセンブリでは、 CMPQ RDI, $0 (マップポインタが0か比較)といった命令で nil 判定し、ゼロなら長さ0をセットして終了、それ以外なら MOVQ (RDI), AX で count を読み込む、といった分岐になります。 チャネルに対する len チャネル( chan T )も参照型で、内部的には双方向キュー構造 runtime.hchan のポインタで表現されています。 len(ch) はチャネルバッファに蓄積している要素の個数を返します。ランタイムの hchan 定義では先頭に qcount という uint 値があり、これが現在のバッファ内データ数を保持しています( src/runtime/chan.go )。 type hchan struct { qcount uint // total data in the queue dataqsiz uint buf unsafe.Pointer elemsize uint16 closed uint32 elemtype *_type ... } Fig. 11. チャネルの内部構造体 hchan (先頭の qcount にキュー内要素数を保持) この qcount が len(chan) の返り値になります。実装上はマップと同様、チャネルポインタの先頭ワードを読み出すだけです。ただしチャネルも nil ポインタの可能性があるため、やはり nil チェックを含むコード になります。 nil チャネルは長さ0と定義されていますので、 nil であれば0を返すよう分岐します。非 nil なら qcount を読み取ります。 SSAから最終アセンブリへの変換例 最後に、実際のアセンブリコード上で len がどのようになるか、簡単な例を示します。以下にスライスとマップの len を返す関数を想定し、x86-64アセンブリ出力を例示します。 // func lenSlice(s []int) int lenSlice : MOVQ RSI , AX // s.Len(RSIに入っている長さ)をAXレジスタに移動 RET // そのまま返り値として返す // func lenMap(m map[int]int) int lenMap : CMPQ RDI , $ 0 // mポインタ(RDI)がnilか比較 JEQ .Lnil // ==0(nil)の場合.Lnilラベルへジャンプ MOVQ ( RDI ), AX // *m.count(マップ先頭のcountフィールド)をAXにロード RET .Lnil : XORL AX , AX // AXを0クリア(len=0) RET Fig. 12. len のアセンブリ出力例(スライスの場合は1命令、マップの場合は nil チェック+読み取り) 上記のように、スライス長取得は Len フィールドが保持されているレジスタ(ここでは関数呼出規約上RSIに格納)からそのままAXへコピーするだけで完了しています。一方、マップ長取得ではRDIレジスタにマップのポインタが渡されており、まずそれがゼロかどうか比較した後、ゼロでなければメモリアドレスRDIが指す先の値( count )を読み取っています。 nil の場合はジャンプしてAXレジスタをゼロクリアすることで0を設定し、リターンしています。 このように、実行時コードにおいて len は非常に低コストな操作です。実際、 スライスや文字列の len 取得はオーバーヘッドのない単なるメモリ参照 となり、 マップやチャネルでも nil 判定+メモリ参照程度 に展開されます。この最適化された生成により、例えばループの終了条件に len(slice) を毎回書いても問題ない(自明なインライン展開なので)のはこのためです。 コンパイラは場合によってはさらなる最適化も行います。例えばループ内で長さが変わらないスライスに対して毎回 len を呼んでいると、最適化でループ前に一度だけ len を計算しレジスタに保持する、といったことも行われます。また、コンパイラは range ループのコード生成時にも内部で len を使いますが、これも一定の場合で定数とみなして評価を省略する挙動があります。 まとめ: なぜ len は演算子的に設計されているのか 以上を踏まえ、最後に len が「関数」ではなく言語組み込みの演算子のように設計されている理由についてまとめます。 1. 多様な型に対応するため len は配列、スライス、文字列、マップ、チャネルといった複数の組み込み型に対して使えます。もし通常の関数として定義しようとすると、これらすべての型について関数やメソッドを用意する必要があり煩雑です。しかし組み込み関数としてコンパイラが特別扱いすることで、統一した名前 len で様々な型の「長さ」を取得できるようになっています。ジェネリクスが導入された現在でも、 len はビルトインのままです(型パラメータPに対して len(x) が使えるのは、その型集合内のすべての具体型について len が定義されている必要がある、という形で言語仕様に組み込まれています)。 2. 効率のため 上述のとおり、 len の実装は非常に効率的に最適化されます。コンパイル時に分かる長さは定数化し、実行時に必要な場合も単なるフィールドアクセスや軽微な分岐で済みます。これはコンパイラレベルで len を演算子的に扱っているからこそ可能となる最適化です。通常の関数呼び出しであればインライン展開や最適化の制約が生じえますが、 len は言語レベルで特別扱いされるためそのようなオーバーヘッドがありません。 3. コードの簡潔さと安全性 len を組み込みとする設計は、言語利用者にとっても扱いやすさと安全性につながっています。例えば len は定義上panicを起こし得ません(どんな引数でも0以上の整数を返す)し、 nil も安全に処理されます。仮に len が通常の関数であった場合、 nil 参照のチェックや異常系処理をユーザが意識する必要があったかもしれませんが、現在の設計ではそうした心配は不要です。また、ビルトインであるため ユーザは len をオーバーライドしたり別の意味に使ったりできません 。これにより、常に len という表記は言語仕様どおりの意味を持ち、コードの可読性・一貫性が保たれます。 4. 内部実装のカプセル化 len を組み込み関数としたことで、各データ構造の内部実装(例えばマップの構造体やチャネルの構造体)を直接公開せずに「要素数」という情報だけを提供できます。ユーザはこれら構造の詳細を気にせず len を使えますし、仮に将来内部実装が変わっても len の振る舞いは保証されます。実際、Goのランタイム実装は自由に変更可能ですが、 len の結果だけは常に正しくなるようコンパイラとランタイム側で約束しています。 以上の理由から、Goの len は言語レベルで特殊扱いされる演算子的な組み込み関数として設計されています。そのおかげで、私たちは len をまるで配列やスライスなどに対する演算子のように どんな場面でも安心して 使うことができます。実装上も無駄なコストがなく、 「長さを求める」という非常に基本的な操作を高速かつ安全に行う ことをGoは保証しているのです。 おわりに 本記事では、Goにおける基本的な組み込み関数 len の内部挙動についてまとめてみました。普段何気なく使っている関数ですが、調べていくと知らないことばかりで驚きの連続でした。Goには len の他にもいくつか同様の組み込み関数( cap , new , make など)があります。これらも len と同じくコンパイラの Universe ブロックに定義され、内部で特別に処理されます。Go言語の公式ドキュメントや実装コードを読むことで、ビルトインがどのように扱われているかさらに理解が深まるでしょう。本記事が、 len という身近な関数の背後にある言語仕様とコンパイラ技術について理解を深める一助になれば幸いです。 参考文献: The Go Programming Language Specification How does Go calculate len()..? - tpaschalis Go Documentation Server - universe.go Go Packages - reflect Stack Overflow - How to see Go func len() Implementation
アバター
はじめに こんにちは、開発部でデータサイエンティストをしている蜜澤です。 現在Amazon QuickSightを使用して、データ分析ツールを作成しています。 Highcharts Visualを有効に活用できていませんでしたが、従来の折れ線グラフでは実現できなかった、数値によって小数点表示を変更することや、凡例をクリックすることでグラフの表示/非表示を切り替えることが可能になったので、分析ツールの利便性向上のために検証を行いました。 その際に少し困ったことがあったので、紹介させていただければと思います。 本記事の内容はかなりニッチな内容になっているため、QuickSightを日頃から使用している方向けの内容となっております。 使用するデータ 以下のような、レシピ動画サイトでのユーザーの検索ログを集計したという想定の模擬データを使用します。 それぞれのカラムの定義は以下の通りです。 event_date:日付(2025-09-01~2025-09-07) search_word:検索されたワード(キャベツ、豚肉) count:検索回数 QuickSightの準備 QuickSightのデフォルトのビジュアルを使用した折れ線グラフと、Highcharts Visualを使用した折れ線グラフを作成します。 コントロールに入力したワードの検索回数が見れるようにフィルターとコントロールの設定もします。 作成したビジュアルが以下になります。 ほとんど同じ見た目になるように作成しました。 チャートコードは以下になります。 { " tooltip ": { " headerFormat ": " {point.x:%b %Y-%m-%d}<br> ", " crosshairs ": [ { " width ": 1 , " color ": " gray " } ] , " borderWidth ": 1 , " borderColor ": " #C0C0C0 ", " shadow ": false } , " legend ": { " enabled ": false } , " xAxis ": { " categories ": [ " getColumn ", 0 ] , " labels ": { " style ": { " fontSize ": " 10px " } , " rotation ": -45 } , " tickmarkPlacement ": " on ", " tickLength ": 5 , " tickWidth ": 1 } , " yAxis ": { " title ": { " text ": "" } , " labels ": { " style ": { " fontSize ": " 10px " } } , " min ": 0 } , " series ": [ { " type ": " line ", " data ": [ " getColumn ", 1 ] , " name ": " 検索回数 ", " lineWidth ": 2 , " marker ": { " enabled ": true , " symbol ": " circle ", " radius ": 2 , " states ": { " hover ": { " enabled ": false } } } , " states ": { " hover ": { " lineWidth ": 2 } } } ] , " colors ": [ " getColorTheme " ] } 困ったこと 先ほどの例ではコントロールで「キャベツ」を指定していましたが、「豚肉」に変更すると、以下のようにビジュアルも変更されます。 しかし、データに存在しないワードの場合は以下のようになります。 これがまさに困った点で、デフォルトの折れ線グラフの場合は「データなし」と表示されるので、「にんじんのデータは無いんだな」と利用者が気づけますが、Highcharts Visualを使用すると、何も表示されないため、「データがないのか」「読み込み中なのか」「他に問題があるのか」どういう状況なのかわからなくなってしまいます。 試したこと Highcharts Visualでデータがない場合に「データなし」と表示する方法がないか調べた結果、チャートコードにnodataというオプションがあるということがわかったので、それを設定してみました。 以下のコードを先ほどのチャートコードに追加しました。 " lang ": { " noData ": " データなし " } , " noData ": { " style ": { " fontWeight ": " bold ", " fontSize ": " 12px ", " color ": " #606060 " } } しかし、「データなし」とは表示されず、何も表示されないままでした。 それでは、このオプションはなんのためにあるのだろうと疑問に思ったので、色々と検証してみた結果、以下のようにチャートコードのdataで参照する値がnullになっている場合に表示されるものだとわかりました。 " series ": [ { " type ": " line ", " data ": [] , ... チャートコードのdataの部分を上記のようにして実行すると、以下のように「データなし」と表示されました。 まとめ QuickSightの折れ線グラフでデータに存在しない値をフィルターで指定した時に、デフォルトの折れ線グラフを使用した場合は「データなし」と表示されるが、Highcharts Visualを使用している場合は現状では残念ながら「データなし」と表示する方法がないことがわかりました。 nodataというオプションはありますが、これはチャートコードで表示するデータを指定できていない場合に「データなし」と表示できるだけであり、上記の問題を解決できるわけではありませんでした。 現在Highcharts Visualはベータ版なので、今後のアップデートに期待したいです!
アバター
はじめに こんにちは。開発部でiOSエンジニアをしている野口です。 以前にCursor✖️iOS開発の記事を書きまして、現在もCursorでiOS開発を続けているのですが、その記事では「ビルドエラーはXcodeで解消している」と記載していました。 実際に運用してみて、ビルドエラーが発生するたびにXcodeに切り替えて確認するのが少し手間に感じるようになってきました。そこで今回は、XcodeのビルドエラーをCursorに自動的に取り込んで、より快適にCursorで開発に集中できる環境を作ってみました。 この記事は上記の記事を前提にしている箇所がありますので、まだお読みでない方は先にそちらをご覧いただけるとわかりやすいかと思います。 今回やったこと 今回は、Xcodeのビルドエラーを自動的にCursorに取り込むスクリプト( xcode_build_watch.sh )を作成しました。 このスクリプトは以下の処理を自動化します。 Xcodeでビルドを実行 :AppleScriptでCmd+Rを送信 ビルドログを監視 : fswatch で新しいログファイルの作成を検知 エラーを解析 :ログファイルからエラー行を抽出 エラーを出力 :エラーを出力(オプションでCursorのチャットに自動入力) また、前回の記事でご紹介した tasks.json の設定を利用して、Cursorで Cmd+R でスクリプトを実行できるようにします。 { " version ": " 2.0.0 ", " tasks ": [ { " label ": " xcode.run ", " type ": " shell ", " command ": " ${workspaceFolder}/xcode_build_watch.sh ", " problemMatcher ": [] } ] } それでは、実際のスクリプトの詳細を見ていきましょう。 まず、スクリプト全体を掲載し、その後で各部分の動作を詳しく解説していきます。 xcode_build_watch.sh #!/bin/bash # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 設定 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ビルドログディレクトリ BUILD_LOG_DIR = " /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build " SCRIPT_DIR = " $( cd " $( dirname " $0 " ) " && pwd ) " OUTPUT_FILE = " $SCRIPT_DIR /latest_build_log.txt " # 最新のビルドログを保存するファイル ERROR_TEMP_FILE = " $SCRIPT_DIR /latest_errors.txt " # 最新のエラーを保存するファイル # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 関数定義 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Xcodeでビルドを実行 run_xcode_build() { echo " 🚀 Xcodeビルド&監視を開始します " echo "" # Xcodeが起動しているか確認 if ! pgrep -x " Xcode " > /dev/null ; then echo " ⚠️ 警告: Xcodeが起動していません " echo " Xcodeを起動してから再実行してください " return 1 fi # XcodeでCmd+Rを実行 if osascript \ -e ' tell application "Xcode" to activate ' \ -e ' tell application "System Events" to keystroke "r" using {command down} ' \ -e ' delay 0.5 ' \ -e ' tell application "Cursor" to activate ' 2 > /dev/null ; then echo " ✅ Xcodeでビルドを開始しました " echo "" return 0 else echo " ❌ エラー: Xcodeの操作に失敗しました " return 1 fi } # ビルドログを監視 watch_build_logs() { echo " 🔍 ビルドログ監視を開始しました " echo " ⚡ 次のビルドが完了したら自動的にエラー解析を実行します " echo "" # ディレクトリの存在確認 if [ ! -d " $BUILD_LOG_DIR " ]; then echo " ❌ エラー: ビルドログディレクトリが見つかりません: $BUILD_LOG_DIR " return 1 fi # fswatchのインストール確認 if ! command -v fswatch &> /dev/null ; then echo " ❌ エラー: fswatchがインストールされていません " echo " インストール: brew install fswatch " return 1 fi # fswatchでビルドログディレクトリを監視(1回だけ) local event event = $( fswatch -1 -e " .* " -i " \\.xcactivitylog$ " " $BUILD_LOG_DIR " 2 >&1 ) local exit_code = $? # fswatchのエラーチェック if [ $exit_code -ne 0 ]; then echo " ❌ エラー: fswatch実行エラー (終了コード: $exit_code ) " echo " $event " return 1 fi # eventが空でないことを確認 if [ -z " $event " ]; then echo " ❌ エラー: ビルドログの検出に失敗しました " return 1 fi # ファイルが存在することを確認 if [ ! -f " $event " ]; then echo " ❌ エラー: 検出されたファイルが存在しません: $event " return 1 fi echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ " echo " 🔔 新しいビルドログを検出しました " echo " 📄 ファイル: $( basename " $event " ) " echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ " echo "" # エラー解析を実行 analyze_build_errors " $event " } # ビルドエラーを解析 analyze_build_errors() { local log_file =" $1 " echo " === Xcodeビルドエラー解析 === " echo "" # ログファイルが指定されていない場合は最新を検索 if [ -z " $log_file " ]; then if [ ! -d " $BUILD_LOG_DIR " ]; then echo " ❌ エラー: ビルドログディレクトリが見つかりません " echo " パス: $BUILD_LOG_DIR " return 1 fi log_file = $( ls -t " $BUILD_LOG_DIR " /*.xcactivitylog 2 > /dev/null | head -1 ) if [ -z " $log_file " ]; then echo " ❌ エラー: ビルドログが見つかりません " return 1 fi else # ファイルの存在確認 if [ ! -f " $log_file " ]; then echo " ❌ エラー: 指定されたログファイルが存在しません " echo " パス: $log_file " return 1 fi fi echo " 解析中: $( basename " $log_file " ) " echo "" # 必要なコマンドの確認 if ! command -v gunzip &> /dev/null || ! command -v strings &> /dev/null ; then echo " ❌ エラー: 必要なコマンド(gunzip/strings)が見つかりません " return 1 fi # ログを解凍 if ! gunzip -c " $log_file " > " $OUTPUT_FILE " 2 > /dev/null ; then echo " ❌ エラー: ログファイルの解凍に失敗しました " return 1 fi # エラーを抽出 local errors errors = $( strings " $OUTPUT_FILE " 2 > /dev/null | \ grep -oE ' /Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]* ' ) if [ -z " $errors " ]; then echo " ✅ エラーは見つかりませんでした " return 0 fi echo " ❌ ビルドエラーが見つかりました: " echo "" echo " $errors " echo "" # エラーを一時ファイルに保存 echo " $errors " > " $ERROR_TEMP_FILE " # インタラクティブプロンプト(オプション) echo -n " Cursorチャットに送信しますか? [Y/n]: " read -r response case " $response " in [ yY ] | "") send_errors_to_cursor ;; [ nN ] ) echo " スキップしました " ;; * ) echo " 無効な入力です。スキップします。 " ;; esac return 0 # エラーは見つかったが、処理は正常に完了 } # Cursorチャットにエラーを送信(オプション) send_errors_to_cursor() { if [ ! -f " $ERROR_TEMP_FILE " ]; then echo " ❌ エラー: エラーファイルが見つかりません " echo " パス: $ERROR_TEMP_FILE " return 1 fi local errors errors = $( cat " $ERROR_TEMP_FILE " ) if [ -z " $errors " ]; then echo " ❌ エラー: エラー内容が空です " return 1 fi # エラー数をカウント local error_count error_count = $( echo " $errors " | wc -l | tr -d ' ' ) echo " 📊 検出されたエラー数: $error_count " # エラーメッセージを整形してクリップボードにコピー local error_text =" 以下のエラーを修正してください: $errors " if ! echo " $error_text " | pbcopy ; then echo " ❌ エラー: クリップボードへのコピーに失敗しました " return 1 fi echo " 📋 エラーをクリップボードにコピーしました " echo " 💬 Cursorチャットを開いています... " # Cursorのチャットを開く if osascript \ -e ' tell application "Cursor" to activate ' \ -e ' delay 0.3 ' \ -e ' tell application "System Events" to keystroke "l" using {command down} ' \ -e ' delay 0.3 ' \ -e ' tell application "System Events" to keystroke "v" using {command down} ' 2 > /dev/null ; then # 通知を表示 osascript -e ' display notification "Cursorチャットにエラーをペーストしました" with title "ビルドエラー" ' 2 > /dev/null echo " ✅ 完了しました " return 0 else echo " ⚠️ 警告: Cursorの操作に失敗しました(エラーはクリップボードにコピー済み) " return 1 fi } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # メイン処理 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ main() { # Xcodeでビルド実行+監視 run_xcode_build || exit 1 watch_build_logs local exit_code = $? echo "" if [ $exit_code -eq 0 ]; then echo " ✅ 処理が完了しました " else echo " ⚠️ スクリプトがエラーで終了しました (終了コード: $exit_code ) " fi return $exit_code } # スクリプト実行 main 解説 スクリプトの内容について、順を追って解説していきます。 事前準備 まず、このスクリプトを動作させるために必要な準備について説明します。 1. fswatchのインストール ビルドログの監視に fswatch というツールを使用します。Homebrewでインストールできますので、以下のコマンドを実行してください。 brew install fswatch fswatch はファイルシステムの変更を監視するツールで、今回は新しい .xcactivitylog ファイルが作成されたタイミングを検知するために使用します。 2. ビルドログディレクトリのパスを設定 スクリプト内の BUILD_LOG_DIR のプレースホルダー( <ユーザー名> 、 <プロジェクト名> 、 <ハッシュ> )を、ご自身の環境に合わせて実際の値に置き換えてください。 BUILD_LOG_DIR = " /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build " また、DerivedDataの場所は設定によって変わるので確認してください。 3. 実行権限を付与 スクリプトファイルに実行権限を付与します。 chmod +x xcode_build_watch.sh これで準備は完了です!それでは、スクリプトがどのように動作するのか見ていきましょう。 全体の流れ スクリプトのエントリーポイントは main 関数です。この関数が各ステップを順番に呼び出していきます。 main() { # Xcodeでビルド実行+監視 run_xcode_build || exit 1 watch_build_logs local exit_code = $? echo "" if [ $exit_code -eq 0 ]; then echo " ✅ 処理が完了しました " else echo " ⚠️ スクリプトがエラーで終了しました (終了コード: $exit_code ) " fi return $exit_code } main 関数では、まず run_xcode_build でXcodeのビルドを開始します。もしここで失敗したら exit 1 でスクリプト全体を終了します。成功したら次の watch_build_logs に進み、新しいビルドログの作成を待ち受けます。 このスクリプトは 4つのステップ で動作します。各関数が次の関数を順番に呼び出していく構造になっています。 1. Xcodeでビルド開始 (run_xcode_build) ↓ 2. ビルドログを監視 (watch_build_logs) ↓ 新しいログを検出したら 3. エラーを解析 (analyze_build_errors) ↓ 4. エラーを出力 ↓ オプション: ユーザーが承認したら、Cursorに入力 (send_errors_to_cursor) それぞれのステップを詳しく見ていきましょう。 ステップ1: Xcodeでビルド開始 最初のステップでは、 run_xcode_build関数 でXcodeをアクティブにし、Cmd+Rでビルドを開始し、Cursorに戻ります。 なお、Cmd+Rのキーストロークが確実に実行されるよう、0.5秒の待機時間を設けてからCursorに戻るようにしています。 osascript \ -e ' tell application "Xcode" to activate ' \ -e ' tell application "System Events" to keystroke "r" using {command down} ' \ -e ' delay 0.5 ' \ -e ' tell application "Cursor" to activate ' 各コマンドの説明: tell application "Xcode" to activate :Xcodeをアクティブにする keystroke "r" using {command down} :Cmd+R(ビルド実行)を送信 delay 0.5 :0.5秒待機 tell application "Cursor" to activate :Cursorに戻る この処理により、Xcodeに切り替えることなくビルドが開始され、すぐにCursorでの作業に戻れます。 ステップ2: ビルドログを監視 次に、 watch_build_logs関数 が fswatch を使って新しいビルドログの作成を待ち受けます。 fswatch -1 -e " .* " -i " \\.xcactivitylog$ " " $BUILD_LOG_DIR " 各オプションの説明: -1 :1回だけイベントを検知したら終了 -e ".*" :いったんすべてのファイルを除外(デフォルトではすべての変更を検知してしまうため) -i "\\.xcactivitylog$" :その上で、 .xcactivitylog で終わるファイルだけを監視対象に含める $BUILD_LOG_DIR :ビルドログが保存されるディレクトリ Buildディレクトリ内で .xcactivitylog ファイルだけを監視するための設定をしています。 Xcodeでビルドが完了すると、以下の場所に新しい .xcactivitylog ファイルが作成されます。 /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build/*.xcactivitylog このファイルの作成を検知して、次のステップに進みます。 ステップ3: エラーを解析 3つ目のステップでは、 analyze_build_errors関数 がビルドログからエラーを抽出します。 3-1. ログを解凍 .xcactivitylog ファイルはgzip形式で圧縮されているため、まず解凍します。 gunzip -c " $log_file " > " $OUTPUT_FILE " 3-2. エラーを抽出 解凍したログから、エラー行だけを抽出する処理を行います。 解凍したログは以下のようなイメージになっています。(加工して、抜粋しています) from project 'Feature')f06c6d4c1b47c741^705ce8521b47c741^1(2@2#32"com.apple.dt.IDE.BuildLogSection38"Compile 376 Swift source files (arm64)70"CompileSwift normal arm64 (in target 'Feature' from project 'Feature')0e86764c1b47c741^158fe3521b47c741^-42213"/Users/username/Projects/MyApp/Sources/MyFile.swift:96:27: error: expected expression in list of expressions type: , /Users/username/Projects/MyApp/Sources/MyFile.swift:61:17: error: expected expression after 'await' strings "$OUTPUT_FILE" | \ grep -oE '/Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]*' 各コマンドの役割: strings :バイナリから読み取り可能な文字列を抽出 grep -oE '/Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]*' :拡張正規表現を使ってエラーメッセージを抽出 -o :マッチした部分だけを出力 -E :拡張正規表現を使用 /Users[^"]+ : /Users から始まり、 " 以外のすべての文字が1文字以上続く(ファイルパスを抽出) \.swift:[0-9]+:[0-9]+ : .swift ファイル、行番号、列番号 : error:[^"]* : : error: に続く、 " 以外のすべての文字(エラーメッセージ本文を抽出) この処理により、以下のような形式でエラーが抽出されます。 /Users/username/Projects/MyApp/Sources/MyFile.swift:96:27: error: expected expression in list of expressions type: , /Users/username/Projects/MyApp/Sources/MyFile.swift:61:17: error: expected expression after 'await' ステップ4: エラーを出力 4つ目のステップでは、 analyze_build_errors関数 が抽出したエラーをターミナルに出力します。 echo "❌ ビルドエラーが見つかりました:" echo "" echo "$errors" 基本的な機能はここまでです。エラーがターミナルに表示されるので、それを確認してコードを修正できます。 オプション: Cursorのチャットに自動入力 さらに便利にするために、エラーをCursorのチャットに自動入力する機能も用意しています。この機能は オプション なので、スクリプトから削除しても問題ありません。 まず、ユーザーに確認プロンプトを表示します。 echo -n "Cursorチャットに送信しますか? [Y/n]: " read -r response Yまたはエンターキーを押すと、次の処理が実行されます。 まず、エラーメッセージを整形してクリップボードにコピーします。 echo "以下のエラーを修正してください: $errors" | pbcopy 次に、Cursorのチャットを開いてエラーを貼り付けます。 osascript \ -e 'tell application "Cursor" to activate' \ -e 'delay 0.3' \ -e 'tell application "System Events" to keystroke "l" using {command down}' \ -e 'delay 0.3' \ -e 'tell application "System Events" to keystroke "v" using {command down}' 各コマンドの説明: tell application "Cursor" to activate :Cursorをアクティブにする keystroke "l" using {command down} :Cmd+Lでチャットを開く keystroke "v" using {command down} :Cmd+Vでコピーしたエラーを貼り付け これで、エラーが自動的にCursorのチャットに入力され、すぐにAIに質問できる状態になります! まとめ 今回は、XcodeのビルドエラーをCursorに自動的に取り込むスクリプトをご紹介しました。 前回の記事と組み合わせることで、CursorでのiOS開発環境がさらに充実したものになったと感じています。 少し設定は必要ですが、一度設定してしまえば快適に開発できるようになりますので、ぜひお試しください!
アバター
目次 はじめに SQLBoilerのコード生成フローをおさらい 調査のきっかけになったAIレビュー columnsWithoutDefault が示すもの 実際の挙動を確かめる まとめ — AIレビューとの付き合い方 はじめに こんにちは。開発本部開発1部デリッシュキッチンMS2に所属している惟高です。 私が現在関わっているプロジェクトでは、SQLBoiler という ORM を使って既存の MySQL スキーマから Go のモデルコードを自動生成しています。 普段は SQLBoiler が生成してくれるモデルを中身を覗かず「ブラックボックス」として使っていますが、ある日の AI コードレビューからのコメントをきっかけに、その挙動を丁寧に追うことにしました。 この記事では、SQLBoiler がテーブル定義をどのように読み取りモデルを生成するのかをたどりながら、普段は意識していなかった内部構造を改めて整理していきます。 Note: SQLBoiler は現在メンテナンスモードのため、将来的に別 ORM への切り替えを検討する可能性があります。この記事は現行運用の振り返りとしてご覧ください。 SQLBoilerのコード生成フローをおさらい SQLBoiler の CLI は、Cobra ベースのコマンドからドライバーとテンプレートを組み合わせてモデルを出力します。 実装 では boilingcore.New が呼ばれ、設定を元にスキーマを読み取りテンプレートを準備します。 大まかな流れは以下のとおりです。 スキーマ情報の取得 : DB ドライバーが drivers.Table と drivers.Column 構造体にメタデータを詰めます。 ここで列ごとの Default や Nullable の情報が集約されます。 列のカテゴリー分け : 取得したカラムは FilterColumnsByDefault や FilterColumnsByAuto などの関数で種類ごとに分類されます。 たとえば Default 文字列が空なら「デフォルトなし」、 AutoGenerated が true なら「DB が自動で値を生成してくれる列」といった具合にルール化されます。 テンプレート生成 : 上記の結果がテンプレートに渡され、 models パッケージのコードとして出力されます。 テンプレートコード の先頭で ColumnsWithoutDefault や ColumnsWithDefault の配列が生成されるのがその一例です。 調査のきっかけになったAIレビュー あるテーブルに nullable な group_id カラムを追加したところ、自動生成コードにおける columnsWithoutDefault にもその列が追加され、レビューボットから「ここに載っているなら必須では?」という指摘を受けました。 今回のマイグレーション ALTER TABLE schema_version_cv_invalid_conditions ADD COLUMN group_id INT NULL DEFAULT NULL ; AIレビューでの指摘 SQLBoiler が出力した差分は次のようなものでした。 var schemaVersionCVInvalidConditionColumnsWithoutDefault = [] string { "schema_version_id" , "target_column" , "condition_type" , "group_id" // 今回追加された箇所 } 「NULL を許す設計だったはずなのに必須扱い?」と違和感を覚え、SQLBoiler が生成したモデルコードを確認してみました。 columnsWithoutDefault が示すもの 今回は AI レビューで指摘があった columnsWithoutDefault について調査した結果をまとめていきます。 結論から言うと、 ColumnsWithoutDefault は「DB が自動補完しない列の一覧」であり、必須かどうかを判定する仕組みではありませんでした。 テンプレート を辿ると、 FilterColumnsByDefault(false, columns) の結果がそのまま ColumnsWithoutDefault として出力されていることが分かります。 実装 は Column.Default の文字列が空かどうかを確認しているだけです。 Note: SQLBoiler の MySQL ドライバーでは column_default が SQL の NULL のとき、 *string にスキャンした値が nil になるため Column.Default は空文字のままです。 DEFAULT NULL を宣言した列もこのパターンに当たるので、 ColumnsWithoutDefault 側に分類されます。( 該当コード ) columnsWithoutDefault の使用用途は以下があります。 INSERT クエリの生成 wl, _ := columns.InsertColumnSet( {{$alias.DownSingular}}AllColumns, {{$alias.DownSingular}}ColumnsWithDefault, {{$alias.DownSingular}}ColumnsWithoutDefault, nzDefaults, ) ここでは ColumnsWithoutDefault をベースに、モデル側で値が設定された ColumnsWithDefault を足し合わせたうえで INSERT に載せる列( wl )を決めています。 テストでのダミーデータ投入 randomize.Struct(seed, &a, {{$ltable.DownSingular}}DBTypes, false, strmangle.SetComplement({{$ltable.DownSingular}}PrimaryKeyColumns, {{$ltable.DownSingular}}ColumnsWithoutDefault)...) テストコードでも ColumnsWithoutDefault を補助配列として利用できます。 例えば「親レコードに子レコードをひも付けるテスト」( SetChild などの関連付け処理)では、DB が補ってくれない列だけを取り出してランダムなダミーデータで埋め、その状態で関連付けメソッドが意図どおり機能するかを確認する用途に使っています。 実際の挙動を確かめる 生成されたモデル構造体では、該当する列は null.Int のような nullable 型で表現されます。 type SchemaVersionCVInvalidCondition struct { // 今回追加された部分のみを表示 GroupID null.Int `boil:"group_id" json:"group_id,omitempty" toml:"group_id" yaml:"group_id,omitempty"` } null.Int{Valid:false} のまま Insert() するとクエリには列が含まれず、MySQL 側では NULL が保存されます。 まとめ — AIレビューとの付き合い方 今回、AI コードレビューの「必須では?」という指摘をきっかけに、SQLBoiler の内部構造について学ぶことができ、特にcolumnsWithoutDefault がどのように生成・利用されているかを改めて確認できました。 あわせて、columnsWithoutDefault のような自動生成コードは仕様を知らないと誤検知を招きやすいので、AI レビューではレビュー対象外にするなど運用でノイズを抑える工夫も必要だと感じました。 これから AI レビューを使う機会は増えていくはずです。 だからこそ、指摘を鵜呑みにせず該当コードや生成ロジックを辿って本当に正しいかどうか確かめる姿勢を持っていたいと思います。 少しでも参考になれば幸いです。最後まで読んでいただき、ありがとうございました。
アバター
目次 はじめに セッション・ワークショップ紹介 今日から始めるpprof(ymotongpooさん) Goを使ってTDDを体験しよう!!(chihiroさん) Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する(Takeru Hayasakaさん) 0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践(OPTiM 上原さん) エブリーエンジニアのセッション エブリースポンサーブース ノベルティ アンケート フォトブース 各社スポンサーブース Resilireさん オプティムさん ナレッジワークさん まとめ 非公式アフターイベント Go Bash vol.2 のお知らせ 最後に はじめに 去年に引き続き、2025 年 9 月 27 日(土)、28 日(日)に開催された「Go Conference 2025」に参加させていただきました。 今回も参加レポートとして、会場の様子やセッションの感想についてお届けします! gocon.jp セッション・ワークショップ紹介 今日から始めるpprof(ymotongpooさん) こんにちは、開発1部の岩﨑です。 私は1日目のワークショップ「今日から始めるpprof」に参加しました。 gocon.jp ymotongpooさんをスピーカーとして、Goのプロファイリングツールであるpprofの基本的な使い方から実際にソースコードを計測して改善するまでをハンズオン形式で実施いただきました。 実施内容は下記の通りです。 プロファイルとは pprof の基本機能の説明 pprof をプログラムに組み込む pprof でプロファイルを取得する pprof でプロファイルを可視化する pprof の読み方を理解する pprof の結果に基づいて改善する 改善結果を pprof で確認する (ボーナス)実際の開発に活かせる継続的プロファイルの解説 Goにおけるプロファイルとは、パフォーマンスプロファイルのことを指します。 統計的にアプリケーションのパフォーマンスに関する情報を収集し、プログラムのボトルネックを特定するために使用されます。 pprofはGoの標準ライブラリに含まれており、プロファイルデータの取得から可視化までを簡単に行うことができます。 ワークショップ内では以下のリポジトリのソースコードを使用しました。 github.com 題材はcutコマンドを実装したCLIツールだったので、 runtime/pprof パッケージを使用してpprofを計装しました。 実装はシンプルで、計測したいコードに下記の実装を追加するだけです。 import ( "os" "runtime/pprof" ) func main() { report, _ := os.Create( "cpu.prof" ) defer report.Close() _ = pprof.StartCPUProfile(report) defer pprof.StopCPUProfile() // アプリケーションのメインロジック // ... } ビルドしたバイナリを実行すると、指定したファイル名(今回は cpu.prof )でプロファイルデータが生成されます。 次に、生成されたプロファイルデータをpprofツールで解析します。 go tool pprof -http :9999 cpu.prof -http オプションを指定することで、Webインターフェースでプロファイルデータを可視化でき、指定しない場合はインタラクティブなCLIでプロファイルデータを解析できます。 Webインターフェースでは、以下のような画面が表示されます。 画像にあるグラフはFlame GraphというViewの概念で、関数の呼び出し階層と各関数のCPU使用率を視覚的に表現したものです。 このFlame Graph が長い場合はリソースを多く消費していることを示しており、ボトルネックとなっている可能性が高いです。 また、Sourceではソースコードのどこで時間が消費されているかを行単位で確認することができます。 画像では _, err := f.Read(buf[:]) でCum が510ms消費されていることがわかります。 ここでFlatはその関数が消費した時間を指し、Cumはその関数とその関数から呼び出される関数で消費した時間を表しています。 つまりFlatが遅い場合はアルゴリズム自体に問題がある可能性が高く、Cumが遅い場合は呼び出している関数に問題がある可能性が高いです。 ただ標準パッケージや著名なパッケージは最適化されていることが多いので、基本的には自分の実装を疑うのが良いとのことでした。 よってこの行でループごとにReadを呼び出していることが原因である可能性が高いということがわかります。 ワークショップ内でこのボトルネックを解消する改善を試み、改善後のProfileデータの変化を以下のコマンドで確認することで、計測によってパフォーマンスが改善されることを体感できました。 $ go tool pprof -http :9999 -base cpu.prof cpu_optimized.prof 推測するな計測せよ、のポリシーに従ってパフォーマンス改善を行うことを学ぶことができ、とても有意義なワークショップになりました。 Goを使ってTDDを体験しよう!!(chihiroさん) デリッシュキッチンでバックエンドエンジニアをしている秋山です。 私はこれまでTDDについて少し学んだことはあったものの、実際に取り組んだことはなかったのでこちらのワークショップに参加しました。 gocon.jp ワークショップは主に下記の流れで進みました。 自己紹介 TDD(テスト駆動開発)の概要 FizzBuzzを例としたTDDのデモ お題に基づいてTDDを実践 全体振り返り ここでは特にデモについて紹介できればと思います。 FizzBuzzを題材として、下記のような流れでデモを行なっていただきました。 ① TODOリスト(テストリスト)を作成する まずは実装前に、TODOリストを箇条書きで整理しました。 例えば、 3の倍数の場合はFizzを返す 5の倍数の場合はBuzzを返す 3と5の倍数の場合はFizzBuzzを返す のように具体的なTODOを書いていきます。 ② TODOリストに基づくテストの作成 作成したTODOリストすべてに対して一気にテストを書くのではなく、まずは1つの項目に絞ってテストを作成しました。 最初は「3の倍数の場合はFizzを返す」のテストを作成しました。 func TestFizzBuzz(t *testing.T) { got := FizzBuzz( 3 ) if got != "Fizz" { t.Errorf( "FizzBuzz(%d) = %v, want %v" , 3 , got, "Fizz" ) } } この段階ではまだFizzBuzzの実装をしていないため、テストは失敗します。 ③ テストを満たすように実装 次に、②で作成したテストを通すための最低限の実装を行います。 例えば、 func FizzBuzz(n int ) string { return "Fizz" } というように、ロジックは深く考えずに、まずはシンプルにテストを通すことを優先しました。 実装後、テストを実行すると無事テストが通ります。 これでテストが通ったので今度は TODOリストにある次の項目のテストコードを作成 テストを満たすように実装 という具合で繰り返すように細かく実装していきました。 実装途中で足りていなかったTODOに気づくこともあるので、その都度TODOを追加して進めていきました。 デモの後は、与えられたお題に対してペアプロでTDDを実践していきました。 個人的には最初のTODOリストを作る作業が難しかったです。 chihiroさんからのご説明の中で「集中」というワードが出てたのですが、 デモや実践を体験する中で「TDDを行うことで一つのことに集中することができ、余計なことを考えなくて済む」ということを実感できました! 全体を通して、TDDの流れを一定理解できたかなと思うので、今後はTDDの理解を深めながら業務でも実践していきたいと思いました! Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する(Takeru Hayasakaさん) こんにちは、開発1部の黒髙です。 私からは、2日目のセッション「Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する」について紹介します。 gocon.jp こちらでは、Go 1.24 からデフォルトで有効化された Multipath TCP(MPTCP)の仕組みと、その Go における活用方法が解説されました。資料は以下で公開されています。 speakerdeck.com MPTCP は、既存の TCP を拡張して 1 つの通信セッションの下で複数の TCP コネクション(subflow)を同時に扱えるようにしたプロトコル です。これにより、例えばスマートフォンの Wi-Fi と 4G 回線を束ねて 1 本のセッションとして利用し、 帯域幅をまとめて高速化したり、片方の回線が切れても通信を継続したりできる のが大きな特徴です。アプリケーションから見ると通常の TCP と同じインターフェースで使えるため、下層のネットワークが複数回線を意識して処理を行います。 発表ではまず、Go 1.21 で MPTCP サポートが入り、1.24 からは net.Listen が自動的に MPTCP を有効化するようになった経緯が紹介されました。既存のコードを大きく書き換えることなく MPTCP の恩恵を受けられる一方で、 ListenConfig.SetMultipathTCP(false) で明示的に無効化することも可能であり、SocketOption の互換性に問題がある場合の回避策として説明されていました。 今回のアップデートにより、クライアントが MPTCP を使って接続してきた場合でも特別な対応をしなくても自動的に利用でき、問題があれば従来通り TCP にフォールバックするため、既存のアプリケーションにも導入しやすいことが分かりました。 Go1.24の仕様についてはもちろんですが、初めて触れたMPTCPプロトコルの概念をしっかり理解することができ、大変勉強になりました。 0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践(OPTiM 上原さん) 開発1部の きょー です! 僕の方からはOPTiM 上原さんの 「0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践」のセッションについて共有しようと思います。 gocon.jp このセッションでは以下のような流れで進んでいきました。 Package by Layer 構成で開発した際に感じた課題の紹介 1で感じた課題の解決策となる、機能ごとにレイヤーを分離するという Package by Feature 構成についての紹介 Package by Feature 構成で得られた効果 パッケージ戦略の比較 Package by Layer 構成(レイヤードアーキテクチャのようなものとして認識しています。)で開発した際に感じた課題の紹介では、開発効率と保守性の低下が取り上げられていました。開発人数が多くなるにつれ発生するコンフリクト頻度の増加や、プロジェクト拡大に伴う機能間の結合度の増大。また増大した結果コードを変更しようとした際の影響範囲の増加に悩まされていたようでした。 これらの課題を解決したく取り入れたのが以下のような Package by Feature という構成です。 Package by Feature 構成を取り入れた結果以下のような効果を得られたようでした。 【開発効率】機能別の並行開発によるコンフリクト発生率を 1/3 に削減 【開発効率】機能協会の明確化によりAIコーディングとの親和性が向上 【保守性】機能単位でアーキテクチャ変更・分割が容易 【保守性】ディレクトリ構造と依存関係の可視化により設計理解が促進 【保守性】機能別テスト環境の構築・管理が効率化 speakerdeck.com 簡単なまとめになりますが、今後チームが成長し開発規模が拡大した際に取り入れてみたいと思いました! エブリーエンジニアのセッション エブリーからは、開発1部 デリッシュキッチンAWG の本丸から、「10年もののAPIサーバーにおけるCI/CDの改善の奮闘」というタイトルでスポンサーセッションをさせていただきました。 speakerdeck.com gocon.jp エブリーのメインサービス「デリッシュキッチン」のAPIサーバーは、Go・echoで構成されており、約10年の歴史の中で肥大化していくつも技術的な負債を抱えています。今回のセッションではCI/CDに焦点を当て、Goのコードを中心としたCI/CD高速化の取り組みについて解説しました。 聞いていただいた皆様、ありがとうございました! エブリースポンサーブース エブリーは昨年に引き続きスポンサーブースを出させていただきました。 弊社サービスであるデリッシュキッチンをイメージした黄色基調のブースとなっています。 足を運んでいただいた皆様、本当にありがとうございました! ノベルティ 今回は以下のようなノベルティを用意させていただきました。 ドリップバッグコーヒー 会社・サービスのステッカー キッチングッズ キッチングッズは鍋、計量スプーン、まな板、しゃもじ、お箸を用意し、Xフォローで引けるくじでプレゼントさせていただきました。 たくさんのご参加、ありがとうございました! アンケート 参加者の方々がコミュニケーションを取れるようなきっかけを作りたく、アンケートボードを用意させていただきました。お題は、「Go歴」と「気になるトピック」です。 回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです…! 0~5年から、15年以上の大ベテランまで、とても幅広い歴のGopherにご回答いただけています! かなりバラけましたが、8月にリリースされたGo 1.25、GoとAIコーディング、他社のGoの知見は特に気になる方が多かったようです! フォトブース 皆さんにGo Conferenceをもっと楽しんでいただけるよう、今回新たな取り組みとして、フォトブースを設置させていただきました! パネルは、デリッシュキッチンエプロンを着たGopher、デリッシュキッチンアイコン、エブリーロゴをご用意しました。 ブースにいらっしゃった多くのGopherの皆様に、"Gopher"と一緒に写真を撮っていただきました! 楽しんでいただけていたら幸いです! 各社スポンサーブース 他社さんのスポンサーブースにもたくさん訪問させていただきました! 各社趣向を凝らしたブースや様々な企画が展開され、会場全体がとても賑わっていました。 いくつかご紹介させていただきます。 Resilireさん Resilireさんは、自社サービスで提供しているサプライチェーンのリスク監視について、クイズを交えながらわかりやすく解説していただきました!! エンジニアのイベントだと、リスクを「単一障害点」に例えるとエンジニアに理解してもらえる、というお話が印象的でした。サプライチェーンとシステム設計には、共通する部分があることを理解できました…! オプティムさん オプティムさんのブースでは、コードの一部を見てどのGoのどのパッケージかを当てる、「Go Package Guessr」にチャレンジしました! 最初わからなくても、10秒後からヒントが出るのでなんとか答えることができました。普段使っていないパッケージを知る良い機会になりました!無事ノベルティもゲットしました! ナレッジワークさん ナレッジワークさんでは、Goのコードをレビューする、「ENABLE THIS CODE by your REVIEW」に参加しました! 2日間で全4問あり、自分は2日目の朝イチ、第3問をレビューさせていただきました。ちゃんと想定していた部分は突っ込めたみたいでよかったです!ノベルティは K のキーキャップをいただきました! まとめ Go Conference 2025は、Goに関する最新の情報や活用事例、アイディアなどを学べ、Go コミュニティの盛り上がりを感じられる、とても素敵なイベントでした。 今後もGoのコミュニティ、Go Conferenceがより一層発展していくことを期待しています。 今回の参加レポートが、Goを学びたい、活用したい方の参考になれば幸いです。 運営の皆さん、カンファレンスを開催していただきありがとうございました!! 非公式アフターイベント Go Bash vol.2 のお知らせ Go Conference 2025 にスポンサー参加した LayerX、ANDPAD、OPTiM、Resilire、エブリー の Gopher たちが Go Conference 2025 に刺激を受け、トークや感想戦を繰り広げ、 Beer ではなく Go で盛り上がるイベントを開催します! https://layerx.connpass.com/event/367057/ layerx.connpass.com Go Conferenceに参加された方も、されなかった方も、ぜひご参加ください! 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター