TECH PLAY

株式会社エブリー

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

385

プロダクションで稼働しているAI機能のフレームワークをLangGraphに完全移行しました この記事は every Tech Blog Advent Calendar 2025 の 13 日目の記事です。 背景 課題解決のために 明確な責務分離が可能 ワークフローの変更柔軟性、拡張性 プロバイダに依存しない プロバイダの置き換えやすさ 型制約(Structured Outputのため) テストのしやすさ LangGraphの採用 ディレクトリ構成 Stateの定義 Node の定義 サブワークフローの定義 メインワークフローの定義 アーキテクチャの責務分離 おわりに こんにちは、開発1部に所属している25卒の岩﨑です。 本記事では、プロダクションで稼働しているAI機能のフレームワークをLangGraphに完全移行したことについてお話しします。 背景 デリッシュキッチンでは、会話形式でレシピ検索できるデリッシュAIという機能を提供しています。 推論にはWorkflow型を採用しており、LLMのAPIをフルスクラッチの関数パイプラインから呼び出すような実装となっています。 機能を公開して1年以上が経過し、様々な機能追加や改善サイクルが回された結果、ワークフローが複雑になりいくつかの問題が発生しました。 1つはアーキテクチャの責務が混同している問題があります。 コアとなるビジネスロジックやLLMの呼び出し処理は、再利用可能な形で関数化されています。 しかしビジネスロジックを呼び出すワークフローや、処理の過程で変化するデータ構造の状態管理の責務が明確に分かれていなかったため、追加・修正実装の負荷が高い状態に陥っていました。 特にワークフローに関してはレスポンス速度の向上のために処理を並列化していたりするので、流れが特に追いにくい状態でした。 またユーザ入力や処理の出力に対して分岐処理をする実装もあり、ワークフロー自体がロジックを持ってしまうことも問題としてありました。 2つ目は、変更の柔軟性がないことです。 アーキテクチャが明確に責務分けされていないことによって、特定の処理順序を置き換える場合や並列化を行う場合に、どの状態をどのように置き換えるべきかがわかりづらい状態になっていました。 新しい施策や精度改善のために頻繁にワークフローを変更する場面が増えてきたこともあってこの辺りの変更負荷が高いことは実装する上でもストレスに感じるようになりました。 したがって課題をひとことでまとめると、フルスクラッチで実装する限界がきたということになります。 今後、WorkflowとAgentの混合型を実装するであったり、コンテキストエンジニアリングに関わる実装を追加する場合に、現状の実装では複雑になりすぎてリリースとともに開発スピードが低下していくことは自明でした。 これらの課題に対処するためにワークフローを可視化したものをドキュメントとして用意していましたが、精度を改善する過程では様々な角度で試してよかったものを採用するようなサイクルを回すことが多い都合上、更新のたびにワークフローが変化していき、ちょっとでもドキュメントを放置したら陳腐化してしまうという課題もありました。 課題解決のために これらの課題解決を考える上で、技術観点はもちろんですが、それだけでなくプロダクト観点でも長期的に運用できるようなアーキテクチャであることが理想です。 ここからは先ほどあげた課題を解決するための要素をいくつか並べていきます。 明確な責務分離が可能 課題から、ワークフロー・状態管理・ビジネスロジックの責務を分離できるような仕組みにする必要がありました。 責務が明確に分かれることで変更に強いアーキテクチャになると考えたためです。 ワークフローの変更柔軟性、拡張性 責務分離の話と重複しますが、ワークフローの責務が明確化されることによって状態を意識することなく(ワークフローの処理動作の依存関係は考慮する必要があります)順序の組み替え、並列化などに対応できることが望ましいです。 また、ReActであったりWorkflowとAgentの混合型、コンテキストエンジニアリングといった追加要素に関する要件が出てきた場合も、各処理が疎結合であることによって容易に拡張できることも期待しています。 プロバイダに依存しない 今日最高性能だったモデルが数日後にはそうではなくなっている時代で、特定のプロバイダに依存するような仕組みはできるだけ避けたいところです。 フレームワークによっては、ある機能が特定のサービスプロバイダに依存している実装など見られたため、次に示す置き換えやすさという観点でも依存しない仕組みを採用することが長期的に運用する上で大事なのではないかと思います。 プロバイダの置き換えやすさ ここはどちらかというと必須というよりは理想に近い話です。 プロバイダ依存せずとも置き換えが困難だった場合、置き換えやすくするにはプロバイダに依存しないインターフェースを別で定義しなければなりません。 しかしながらここは独自にインターフェースを定義すれば済む話ではあるので必須とはしませんでした。 型制約(Structured Outputのため) 構造化データをLLMに出力させるためには型制約が必須です。 しかしながら、型制約はOpenAIやGoogleが提供するAPIではかなり前から対応されています。 Anthropicにおいても2025-11-14にClaudeのStructured Outputに対応したため、主要プロバイダが提供するAPIにおいてはPydanticと併用することによって型安全な出力が可能となりました。 www.claude.com 移行前の実装でもPydanticを用いて型安全な出力になるよう実装していたため、ここは引き続き必須要件となります。 テストのしやすさ LLMによる出力は決定論的に評価できないため、別途LLM as a Judgeによる品質評価やプロダクションから構築したデータセットを用いた評価・改善のループを回す必要があります。 しかしLLMのテストに関してはソースコードとは切り離して考える必要があると思うので、LLMに関わる処理をMock化することでLLM以外の処理に関してはテスト可能となります。 責務を明確に分け、LLMをMock化することでルールベースの処理や状態の流れなどに対するユニットテストが比較的簡単に書けるようになるため、テストのしやすさも要素として取り入れました。 LangGraphの採用 現時点において、これらの全ての要素にマッチするのがLangChainおよびLangGraphでした。 2025-10-22のタイミングでv1.0がリリースされ、マイナーバージョンやパッチバージョンでは破壊的変更が行われないことが明記されていることも決め手の1つです。 blog.langchain.com docs.langchain.com ディレクトリ構成 ここからはLangGraph適用後のディレクトリ構成について紹介します。 / ├── docs ├── src │   ├── const │   ├── nodes │   ├── schema │   │   └── filters │   ├── types │   │   └── state │   ├── utils │   ├── workflows │   │   ├── main_workflow.py │   │   └── sub_workflows │   └── evaluation └── tests ※ 一部わかりやすさのために省略している部分があります ここではworkflows, nodes, types/state の3つについて取り扱います。 LangGraphでは、LLMの処理を表すNodeとLLMの処理同士をどう繋ぐかを表すEdgeで整理され、大域的に扱う変数をStateとして定義することで、Workflowを組むことができます。 Node, Edge, Stateの関係 Stateの定義 Stateはワークフロー全体で共有される状態を表しています。 TypedDictを継承して定義することで、型安全な状態管理が可能になります。 例えば、入出力のStateは以下のように定義できます。 # types/state/input_output.py from typing import TypedDict class InputState (TypedDict, total= False ): """入力スキーマ""" user_input: str options: dict class OutputState (TypedDict, total= False ): """出力スキーマ""" result: str metadata: dict Node の定義 Nodeは単一責務の処理ロジックを担当します。 ビジネスロジックは基本的にこのNodeに記述するようにしています。 class NodeA : """ノードA - 特定の処理を担当""" def __init__ (self): self.llm = ChatOpenAI(model= "gpt-5-mini" , timeout= 5.0 , max_retries= 2 ) def execute (self, input_data: str ) -> dict : """処理を実行""" processed = input_data.upper() return { "field_a" : processed} サブワークフローの定義 サブワークフローは関連するNodeをまとめたグラフです。 処理の論理的なまとまりごとにサブワークフローを作成することで、再利用性とテスト容易性が向上します。 from langgraph.graph import END, START, StateGraph from langgraph.graph.state import CompiledStateGraph from my_app.nodes.phase1 import NodeA, NodeB from my_app.types.state import State class Phase1Workflow : """Phase1サブワークフロー""" def __init__ (self): self.node_a = NodeA() self.node_b = NodeB() def step_a (self, state: State) -> dict : """ステップA""" user_input = state[ "user_input" ] result = self.node_a.execute(user_input) return { "field_a" : result[ "field_a" ]} def step_b (self, state: State) -> dict : """ステップB""" user_input = state[ "user_input" ] result = self.node_b.execute(user_input) return { "field_b" : result} def build_graph (self) -> CompiledStateGraph: """グラフを構築""" g = StateGraph(State) g.add_node( "step_a" , self.step_a) g.add_node( "step_b" , self.step_b) # 並列実行 g.add_edge(START, "step_a" ) g.add_edge(START, "step_b" ) g.add_edge( "step_a" , END) g.add_edge( "step_b" , END) return g.compile() 各stepは状態管理を責務としてもち、build_graph メソッドがワークフローの責務を持っています。 build_graphでは、StateGraph を使ってノードを追加し、add_edge でノード間の依存関係を定義しています。 上記の例では START から step_a と step_b の両方にエッジを作成することで、2つの処理が並列実行されます。 このように、並列化は単にエッジの向き先を変えるだけで実現できるところも魅力的な部分です。 メインワークフローの定義 メインワークフローは複数のサブワークフローを組み合わせることで作成されるエンドツーエンドのパイプラインです。 # workflows/main_workflow.py from langgraph.graph import END, START, StateGraph from langgraph.graph.state import CompiledStateGraph from my_app.types.state import InputState, State from my_app.workflows.sub_workflows import ( Phase1Workflow, Phase2Workflow, Phase3Workflow, ) class MainWorkflow : """メインワークフロー""" def __init__ (self): self.phase1 = Phase1Workflow() self.phase2 = Phase2Workflow() self.phase3 = Phase3Workflow() def build_graph (self) -> CompiledStateGraph: """メインワークフローを構築""" g = StateGraph(State, input =InputState) # サブワークフローをノードとして追加 g.add_node( "phase1" , self.phase1.build_graph()) g.add_node( "phase2" , self.phase2.build_graph()) g.add_node( "phase3" , self.phase3.build_graph()) # 順次実行: phase1 → phase2 → phase3 g.add_edge(START, "phase1" ) g.add_edge( "phase1" , "phase2" ) g.add_edge( "phase2" , "phase3" ) g.add_edge( "phase3" , END) return g.compile() def run (self, user_input: str , options: dict = None ) -> dict : """ワークフローを実行""" initial_state: State = { "user_input" : user_input, "options" : options or {}, "field_a" : "" , "field_b" : [], "field_c" : [], "field_d" : 0.0 , "field_e" : "" , "result" : "" , "metadata" : {}, } graph = self.build_graph() return graph.invoke(initial_state) StateGraph に input=InputState を渡すことで、外部から渡される入力の型を制限できます。 また、サブワークフローの build_graph() の戻り値をそのままノードとして追加できるため、メインのワークフローが肥大化することなく実装できます。 アーキテクチャの責務分離 以上のような設計により、以下のように責務を分離することができました。 層 責務 types/state/ 状態の型定義 nodes/ ビジネスロジック workflows/sub_workflows/ サブワークフローのグルーピング workflows/main_workflow.py 全体のフロー制御 ワークフローの順序変更や並列化は workflows/ 内のエッジの向き先を変えるだけで完結し、ビジネスロジック(nodes/)に影響を与えません。 逆に、特定の nodes/ の処理内容を変更しても、workflows/ には影響しません。 以上により、明確な責務の分離と変更にも耐えうる柔軟なコードベースが構築できたのではないかと思います。 またプロバイダの変更においても、LangChainではParter packagesとしてプロバイダごとのpackageを公開しているため、適用したい箇所のライブラリを変更するだけで置き換えが可能です。 OpenAI:langchain-openai Anthropic:langchain-anthropic Google:langchain-google-genai おわりに 本記事ではフルスクラッチの関数型パイプラインによって稼働していたコードベースをLangChain, LangGraphに移行する意思決定の過程について説明しました。 最初からLangGraphにすべきだったかという問いに対しては必ずしもそうとは言えません。 フレームワークは実際に使ってみないと知見が蓄積しないので試す姿勢は大事だと思います。 とはいえ内部処理をちゃんと理解しないままフレームワークに依存するのは危険なので、どのようなフレームワーク思想の元で、なにを解決したいのかを考えるべきなのかなと今回のリファクタリングを通して思いました。 エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
この記事は every Tech Blog Advent Calendar 2025 の 12日目の記事です。 はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 日々、GeminiやClaudeCodeに支えられて業務を行っているのですが、利用する中でチャットのような双方向の通信について気になりました。双方向通信にはいくつか種類がありますが、今回は双方向通信の1つであるWebTransportについてまとめていければと思います。 WebTransportとは WebTransportは、QUIC(HTTP/3)プロトコルをベースとしたクライアントとサーバー間の双方向通信を実現するAPIです。 WebTransportには以下のような特徴があります。 QUIC基盤 : UDP上で動作するQUICプロトコルを使用 多重ストリーミング : 単一接続で複数のストリームを並列処理 ヘッドオブライン阻害回避 : QUICプロトコルを利用するため、パケット損失で他のパケットを待たせることがない 接続移行 : QUICプロトコルを利用するため、ネットワーク変更時も接続継続が可能 信頼性/非信頼性の選択 : ストリーム(信頼性あり)・データグラム(信頼性なし)の使い分けができる WebTransportの通信フロー WebTransportの通信は以下のフェーズに分かれて動作します。 セッション確立の詳細 QUIC接続確立 UDP上でQUICプロトコルによる接続確立 TLS 1.3による暗号化ハンドシェイク WebTransportセッション開始 HTTP/3のCONNECTメソッドを使用 protocol="webtransport" ヘッダーで識別 サーバーが200 OKで応答すればセッション確立 データ通信 Reliable Streams : 順序保証・到達保証あり Unreliable Datagrams : 低遅延優先・順序保証なし 双方向通信の種類 WebTransportが出現するまでにも双方向通信を実現する技術は存在しており、そちらについても軽く触れていけばと思います。一部、サーバーからクライアントへの単方向通信も含みます。 HTTP Long Polling 従来のHTTPではクライアントからのリクエストに対してサーバーからレスポンス返すため、サーバーから能動的にデータを送信することができませんでした。 あらかじめクライアントからサーバーにリクエストを送信しておき、サーバーが任意のタイミングでレスポンスを返せるようにすることで、双方向通信を実現するのがLong pollingです。 Server-Sent Events (SSE) HTTPプロトコルを利用して、サーバーからクライアントに向けての単方向通信を実現します。レスポンスのMIMEタイプに text/event-stream を指定することで接続を維持します。 WebSocket Long PollingやSSEでも双方向通信を実現することはできますが、それらに利用されているHTTPが双方向通信を目的として作成されたプロトコルではなかったため、双方向通信で利用しやすい形で新しく設計されたのがWebSocketです。 WebSocketはTCP上で動くプロトコルなので、WebTransportのヘッドオブライン阻害回避などのQUIC上で動くメリットは持ち合わせていません。 Web Transportのgolangでの実装 webtransport-go を利用して実装したサンプルを載せておきます。 ローカルでチャットアプリを作って動作確認をしたのですが、WebTransportの処理に関わる箇所だけ抜粋しています。 また、下記には注意していただけると助かります。 - 一部コードの抜粋のためサンプルコードだけでは動作しない - ローカルでの動作確認だけのため、実際のネットワーク環境で動くかまでは未確認 Server import ( "context" "encoding/json" "net/http" "time" "github.com/quic-go/quic-go/http3" "github.com/quic-go/webtransport-go" ) func StartWebTransportServer() { // WebTransportハンドラーを作成 mux := http.NewServeMux() // ①QUIC/HTTP3接続確立 // WebTransportServer自体をHTTP/3サーバーとして使用 server := &webtransport.Server{ H3: http3.Server{ Handler: mux, Addr: ":8443" , }, CheckOrigin: func (r *http.Request) bool { return true }, } // WebTransportハンドラーを登録 mux.HandleFunc( "/" , func (w http.ResponseWriter, r *http.Request) { // ②WebTransport セッション確立 // Upgrade()の中でmethodやprotocolが正しいかのチェックなども行っている // WebTransportセッションにアップグレード session, err := server.Upgrade(w, r) if err != nil { return } // ③双方向ストリーミング通信 // 双方向ストリームセッション処理を開始 go handleBidirectionalSession(session) }) // WebTransportサーバーを起動 go func () { err := server.ListenAndServeTLS( "cert.pem" , "key.pem" ) if err != nil { return } }() } // WebTransport用メッセージ構造体 type WebTransportMessage struct { Content string `json:"content"` Timestamp time.Time `json:"timestamp"` Type string `json:"type"` } // 双方向ストリームでの受信処理 func handleBidirectionalReceive(stream webtransport.Stream) { buf := make ([] byte , 4096 ) for { n, err := stream.Read(buf) if err != nil { return } var msg WebTransportMessage if err := json.Unmarshal(buf[:n], &msg); err != nil { continue } // 応答送信(同じ双方向ストリーム) response := map [ string ] interface {}{ "status" : "message_received" , } responseData, _ := json.Marshal(response) if _, err := stream.Write(responseData); err != nil { return } } } // 双方向ストリームでの送信処理 func sendToBidirectionalStream(stream webtransport.Stream, data [] byte ) error { _, err := stream.Write(data) if err != nil { return err } return nil } // 双方向ストリームベースのセッション処理 func handleBidirectionalSession(session *webtransport.Session) { defer session.CloseWithError( 0 , "bidirectional session ended" ) ctx, cancel := context.WithCancel(session.Context()) defer cancel() // クライアントからの双方向ストリームを待機 stream, err := session.AcceptStream(ctx) if err != nil { return } // 双方向ストリームでの受信と送信を並行処理 go handleBidirectionalReceive(stream) } Client import ( "context" "crypto/tls" "net/http" "net/url" "time" "github.com/quic-go/quic-go" "github.com/quic-go/webtransport-go" ) func main() { u, err := url.Parse(serverURL) if err != nil { return } u.Path = "/" // // WebTransport Dialer設定 dialer := &webtransport.Dialer{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true , NextProtos: [] string { "h3" }, ServerName: "localhost" , }, QUICConfig: &quic.Config{ MaxIdleTimeout: 30 * time.Second, HandshakeIdleTimeout: 10 * time.Second, KeepAlivePeriod: 15 * time.Second, EnableDatagrams: true , // WebTransport requires datagram support }, // StreamReorderingTimeoutを設定 StreamReorderingTimeout: 10 * time.Second, } ctx := context.Background() // WebTransportリクエストヘッダーを設定 reqHeader := make (http.Header) reqHeader.Set( "Sec-WebTransport-Http3-Draft" , "draft02" ) reqHeader.Set( "Origin" , serverURL) // より長いタイムアウト付きコンテキスト dialCtx, cancel := context.WithTimeout(ctx, 30 *time.Second) defer cancel() // Dial()でWebTransportプロトコルでのセッションを開始する httpResp, session, err := dialer.Dial(dialCtx, u.String(), reqHeader) if err != nil { return } defer session.CloseWithError( 0 , "client disconnecting" ) if httpResp.StatusCode != 200 { return } // 双方向ストリームを作成して永続的に接続 biStream, err := session.OpenStream() if err != nil { return } defer biStream.Close() // 双方向ストリームでの受信処理(サーバー応答用) go handleBidirectionalReceive(biStream) // 以降の処理はbiStreamを利用してserverと同じような処理になる } AWS上で利用する上での注意点 弊社のシステムはAWS上で動作しているものが多く、ロードバランサーとしてはALBを利用していることが多いのですが、WebTransport(の基盤技術であるQUIC)がALBに対応していないようです。 ドキュメントによるとALBでは、HTTP/2とWebSocketに対応しているようです。 docs.aws.amazon.com NLBはQUICに対応しているとのことなので、WebTransportを利用したい場合はNLBを検討することになりそうです。 docs.aws.amazon.com まとめ 今回はWebTransportを中心とした双方向通信について学んだことをまとめてみました。WebTransportはQUICベースの比較的新しい技術であり、従来のWebSocketなどの技術の問題を改善していることがわかりました。 一方で、AWS環境ではまだALBがHTTP/3やQUICに対応しておらず、WebTransportを実用するにはいくつかの技術的なハードルがあることも確認できました。現時点では従来のWebSocketやSSEといった技術を組み合わせることが現実的な選択肢となりそうです。 参考文献 IETF Draft - WebTransport over HTTP/3 quic-go/webtransport-go - GitHub AWS Application Load Balancer Documentation AWS Network Load Balancer Documentation
アバター
ヘルシカiOSアプリのアーキテクチャについて この記事は every Tech Blog Advent Calendar 2025 の 11 日目の記事です。 はじめに こんにちは。開発部でiOSエンジニアをしている野口です。 ヘルシカiOSアプリの開発を担当しています。今回はヘルシカiOSアプリの設計で採用しているクリーンアーキテクチャについてご紹介します。 この記事では、以下の内容を解説します。 クリーンアーキテクチャの各層(Feature/Usecase/Repository/Infra/Model)の役割 SPMを用いたマルチモジュール構成と依存関係の管理方法 実際のコード例を通じた実装パターンの紹介 DIコンテナの設計と実装 AIによるコーディングを意識した設計 iOSアプリのアーキテクチャ設計に興味がある方や、クリーンアーキテクチャの導入を検討されている方の参考になれば幸いです。 アーキテクチャについて ヘルシカiOSアプリではクリーンアーキテクチャを採用しています。 構成は以下のようになっています。 各層の役割 Feature(Presentation)層 Feature層は一般的なMVVMの構成でViewで画面を構築し、ViewModelで状態を管理します。 UsecaseとModelに依存しています。 Usecase層 Usecase層はビジネスロジックを定義する層です。 Feature層のViewModelから呼び出され、ビジネスロジックを記載します。 RepositoryとModelに依存しています。 Repository層 Repository層は、Infra層のDataStoreを呼び出してデータの取得・保存を行う層です。また、メモリ上でデータを一時的にキャッシュする処理も担当します。 Modelに依存しています。 Infra層 Infra層は、外部APIやLocalStorage、KeyChainなど外部リソースへのアクセスを担当する層です。 DataStoreの具体的な実装はInfra層に配置し、そのインターフェース(Protocol)はRepository層で定義しています。これにより依存性逆転の原則を適用し、Repository層がInfra層の実装詳細に依存しない設計を実現しています。テスト時のモック差し替えが容易になり、外部サービスの変更影響を局所化できます。 RepositoryとModelに依存しています。 Model層 Model層はドメインモデルを定義する層で、どの層からも参照されます。 クリーンアーキテクチャでは、Data層にEntityを、Domain層にModelを定義し、両者を分離する設計もあります。しかし本プロジェクトでは、バックエンドAPIがドメインモデルに沿った構造でレスポンスを返すため、EntityとModelを分けずに共通のモデルを使用しています。これにより、同じような構造のモデルを重複して定義する必要がなくなり、シンプルな設計になります。 画面都合で別モデルを使用したい場合は、Feature層にModelを定義しています。 SPMを用いたマルチモジュール構成 ヘルシカiOSアプリではSPM(Swift Package Manager)を用いたモジュール構成を採用しています。 SampleApp/ ├── App/ # アプリケーションのルートディレクトリ │ ├── Dependency.swift # 依存関係の注入 │ └── ViewModelProvider.swift # ViewModelの注入 ├── Packages/ # ローカルパッケージ群 │ ├── Feature/ │ ├── Usecase/ │ ├── Repository/ │ ├── Infra/ │ └── Model/ パッケージファイルで依存関係を明示する パッケージファイルでは依存するパッケージを指定します。 依存関係をパッケージファイルで明示的に定義することで、ViewModelがRepositoryを直接参照するといったアーキテクチャ違反を防ぎ、依存関係を適切に管理できます。 具体的には以下のようなパッケージファイルを作成します。 dependencies に依存するパッケージを指定します。 なお、本記事で紹介するコードはStrict Concurrency Checkingには対応していません。Swift 6への移行は今後の課題として取り組む予定です。 // Packages/Feature/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Feature" , products : [ .library ( name: "Feature" , targets: [ "Feature" ]) ] , dependencies : [ .package ( name: "Usecase" , path: "../Usecase" ) , .package ( name: "Model" , path: "../Model" ) ] ) // Packages/Usecase/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Usecase" , products : [ .library ( name: "Usecase" , targets: [ "Usecase" ]) ] , dependencies : [ .package ( name: "Repository" , path: "../Repository" ) , .package ( name: "Model" , path: "../Model" ) ] ) // Packages/Repository/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Repository" , products : [ .library ( name: "Repository" , targets: [ "Repository" ]) ] , dependencies : [ .package ( name: "Model" , path: "../Model" ) ] ) 前述の通り、Repository層はModel層にのみ依存しています。 // Packages/Infra/Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name : "Infra" , products : [ .library ( name: "Infra" , targets: [ "Infra" ]) ] , dependencies : [ .package ( name: "Repository" , path: "../Repository" ) , .package ( name: "Model" , path: "../Model" ) ] ) 誤って意図しない依存関係のパッケージを使用した際には、このファイルに依存関係が追加されるのでレビューの際に変更に気づくことが容易にできます。依存関係を明確に定義することでAIが安全にコードを生成できるようになります。 実装例 ユーザー情報の取得を例にして実装例を紹介します。 Feature ViewModelは ObservableObject に準拠し @Published で状態管理します。UseCaseをDIで注入し、UseCaseの結果をViewModelの状態にバインドします。 protocol UserViewModel : ObservableObject { var user : User? { get } var isLoading : Bool { get } func onAppear () } @MainActor public final class UserViewModelImpl : UserViewModel { @Published private ( set ) var user : User? @Published private ( set ) var isLoading : Bool = false private let fetchUserUseCase : FetchUserUseCase public init (fetchUserUseCase : FetchUserUseCase ) { self .fetchUserUseCase = fetchUserUseCase } func onAppear () { Task { isLoading = true let result = await fetchUserUseCase.execute() switch result { case .success( let user ) : self.user = user case .failure( let error ) : print (error) } isLoading = false } } } Usecase UseCaseはビジネスロジックを実装し、Repositoryを呼び出してデータを取得します。 この例ではシンプルにRepositoryを呼び出すだけですが、実際のプロダクトコードでは複数のRepositoryからデータを取得して結合したり、バリデーション処理を行うなど、より複雑なビジネスロジックを実装します。 public protocol FetchUserUseCase { func execute () async -> Result < User , UseCaseError > } public final class FetchUserUseCaseImpl : FetchUserUseCase { private let userRepository : UserRepository public init (userRepository : UserRepository ) { self .userRepository = userRepository } public func execute () async -> Result < User , UseCaseError > { let result = await userRepository.fetchUser() switch result { case .success( let user ) : return .success(user) case .failure( let error ) : return .failure(.repositoryError(error.localizedDescription)) } } } Repository RepositoryはDataSourceを呼び出しデータ取得・キャッシュ管理を行います。 DataSource Protocolは Repository層で定義 し、依存性逆転を実現します。 // Packages/Repository/Sources/Repository/DataSource/UserDataSource.swift public protocol UserDataSource { func fetchUser () async -> Result < User , RepositoryError > } // Packages/Repository/Sources/Repository/UserRepository.swift public protocol UserRepository { func fetchUser () async -> Result < User , RepositoryError > } public final class UserRepositoryImpl : UserRepository { private let userDataSource : UserDataSource private var cachedUser : User? // キャッシュ public init (userDataSource : UserDataSource ) { self .userDataSource = userDataSource } public func fetchUser () async -> Result < User , RepositoryError > { let result = await userDataSource.fetchUser() if case .success( let user ) = result { cachedUser = user } return result } } Infra Repository層のProtocolを実装し、APIClientを使用してデータ取得します。 public final class UserDataSourceImpl : UserDataSource { private let apiClient : APIClient public init (apiClient : APIClient ) { self .apiClient = apiClient } public func fetchUser () async -> Result < User , RepositoryError > { let result = await apiClient.request(FetchUserRequest()) switch result { case .success( let response ) : return .success(response.user) case .failure( let error ) : return .failure(.apiError(error.localizedDescription)) } } } Model Modelはシンプルな struct でプロパティと計算プロパティを定義します。 Decodable はInfra層で拡張することで、Model層の独立性を保ちます。 public struct User { public let name : String public let weight : Float public let height : Float } // Packages/Infra/Sources/Infra/Decodable/User+Decodable.swift extension User : Decodable { enum CodingKeys : String , CodingKey { case name case weight case height } public init (from decoder : Decoder ) throws { let container = try decoder.container(keyedBy : CodingKeys.self ) name = try container.decode(String. self , forKey : .name) weight = try container.decode(Float. self , forKey : .weight) height = try container.decode(Float. self , forKey : .height) self . init ( name : name , weight : weight , height : height ) } } DIについて DIはUIKitとSwiftUIが混在しているため、グローバルアクセス可能なシングルトンとしてDependencyクラスを定義し、アプリ起動時にViewModelProviderを設定しています。SwiftUIのみであれば、Environmentに置き換えることも可能です。 具体的には、AppDelegateの application(_:didFinishLaunchingWithOptions:) で Dependency.shared.set(ViewModelProvider()) を呼び出して初期化します。 // AppDelegate.swift func application (_ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ?) -> Bool { let viewModelProvider = ViewModelProvider() Dependency.shared. set (viewModelProvider) return true } // App/Dependency.swift public final class Dependency { public static let shared = Dependency() private var viewModelProvider : ViewModelProvidable? public var viewModel : ViewModelProvidable { guard let viewModelProvider else { preconditionFailure( "アプリ起動時に `set(_:)` してから利用してください." ) } return viewModelProvider } private init () {} public func set (_ viewModelProvider : ViewModelProvidable ) { self .viewModelProvider = viewModelProvider } } // App/ViewModelProvider.swift final class ViewModelProvider : ViewModelProvidable { func userViewModel () -> UserViewModelImpl { UserViewModelImpl( fetchUserUseCase : fetchUserUseCase ) } private lazy var fetchUserUseCase : FetchUserUseCaseImpl = { FetchUserUseCaseImpl( userRepository : userRepository ) }() private lazy var userRepository : UserRepositoryImpl = { UserRepositoryImpl( userDataSource : userDataSource ) }() private lazy var userDataSource : UserDataSourceImpl = { UserDataSourceImpl( apiClient : apiClient // APIClientもDIで注入しています。 ) }() } DIしたものを利用するために以下のようなProtocolを定義します。これによって、ViewModelProviderを利用する側では、ViewModelの具体的な実装を知らなくても、ViewModelProviderを通じてViewModelを利用することができます。 // Packages/Feature/Sources/Feature/ViewModelProvidable.swift public protocol ViewModelProvidable { func userViewModel () -> UserViewModelImpl } まとめ ヘルシカiOSアプリで採用しているクリーンアーキテクチャについてご紹介しました。 本記事のポイントをまとめます: 各層の責務分離 : Feature層(UI/状態管理)、Usecase層(ビジネスロジック)、Repository層(データアクセス/キャッシュ)、Infra層(外部リソースアクセス)、Model層(ドメインモデル)と明確に責務を分離しています SPMによるモジュール管理 : パッケージファイルで依存関係を明示することで、アーキテクチャ違反を防ぎ、レビュー時に変更を検知しやすくなります 依存性逆転の原則 : DataSourceのProtocolをRepository層で定義することで、Repository層がInfra層の実装詳細に依存しない設計を実現しています DI(依存性注入) : シングルトンのDependencyクラスを通じて依存関係を一元管理し、ViewModelProviderで各層のインスタンスを生成・注入しています AIがコードを書く時代では、依存関係を明確に定義し、責務を分離することでAIにとってもわかりやすく、安全にコード生成することが可能になると考えています。 実際の開発ではCursorを活用しています。Usecase、Repository、Infra、Model層は構造化されたパターンが多いため、AIによるコード生成との相性が良く、ほぼAIに任せることができています。 一方、Feature層は画面ごとに実装方針にばらつきがあり、完全にAIへ委ねるのは難しい印象です。また、簡単なロジックをViewModelに持たせるかUseCaseに切り出すかは状況に応じた判断が必要であり、この辺りは引き続き人間が担う領域だと感じています。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 10 日目の記事です。 開発2部の内原です。 今回は、Go 1.26で追加される予定の slog.MultiHandler について調べてみたので書いてみます。 概要 Go 1.21で導入された log/slog は構造化ログを扱えるため便利なのですが、複数の出力先(標準出力とファイル、標準出力とFluentdなど)に異なる設定でログを出力したい場合、 io.MultiWriter を使うか、サードパーティのライブラリに頼る必要がありました。 Go 1.26では、この問題を解決するために NewMultiHandler 関数が追加されます。これにより、複数のハンドラーを同時に利用できるようになり、出力先ごとに異なるログレベルやフォーマットを設定することが可能になります。 この記事では、 slog.MultiHandler の基本的な使い方と、実際のユースケースを想定した実装例を紹介します。 Go 1.26のインストール方法 Go 1.26はまだ正式リリースされていないため、開発版をビルドする必要があります。以下の手順でソースからビルドしました。 $ git clone https://go.googlesource.com/go $ cd go/src $ ./make.bash $ export GOROOT=$(pwd)/.. $ export PATH=$GOROOT/bin:$PATH $ go version go version go1.26-devel_f22d37d574 Mon Dec 1 14:59:40 2025 -0800 darwin/arm64 slog.MultiHandlerとは NewMultiHandler 関数は、複数のハンドラーを受け取り、それらすべてにログを送信する MultiHandler を返します。以下の関数シグネチャになっています。 func NewMultiHandler(handlers ...Handler) *MultiHandler 従来の io.MultiWriter との違いは、各ハンドラーに対して異なる設定(ログレベル、フォーマットなど)を適用できる点です。例えば、標準出力にはすべてのログを出力し、ファイルには重要なレベルのログのみを出力する、といった柔軟な設定が可能になります。 slog.MultiHandlerの想定ユースケース 以下では、実際のアプリケーションで利用されそうなユースケースと、その実装を書いてみます。 標準出力とログファイル 開発環境では人間が読みやすい形式で出力し、本番環境ではファイルにも記録を残したいというケースです。 環境別に実装を分けるのも手ですが、両方出力すればよいので簡単に思いました。 実装例 package main import ( "log/slog" "os" ) func main() { // 標準出力用(テキスト形式、DEBUG以上) stdoutHandler := slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }, ) // ファイル用(JSON形式、INFO以上) logFile, err := os.OpenFile( "app.log" , os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666 ) if err != nil { panic (err) } defer logFile.Close() fileHandler := slog.NewJSONHandler( logFile, &slog.HandlerOptions{ Level: slog.LevelInfo, }, ) multiHandler := slog.NewMultiHandler(stdoutHandler, fileHandler) logger := slog.New(multiHandler) slog.SetDefault(logger) slog.Debug( "debug (stdout only)" ) slog.Info( "info (stdout & file)" ) slog.Warn( "warn (stdout & file)" ) slog.Error( "error (stdout & file)" ) } この実装により以下のような運用になります。 標準出力にはDEBUG以上のログがテキスト形式で表示される ファイルにはINFO以上のログがJSON形式で記録される 開発時は標準出力を確認、本番環境ではファイルを解析 標準出力とFluentd ログをFluentdなどのログ収集システムに送信しつつ、標準出力には人間が読みやすい形式で出力するケースです。 Dockerなどのコンテナ環境であればログ転送機構(docker logging driverなど)を用いることでアプリケーションは標準出力に送信するだけで外部ロギング機構に対応することはできますが、その場合すべてのログが転送されてしまうため、細かいコントロールはできなくなります。 実装例 fluent.conf は以下を想定。 <source> @type forward port 24224 bind 0.0.0.0 </source> package main import ( "context" "log/slog" "os" "github.com/fluent/fluent-logger-golang/fluent" ) type FluentdHandler struct { logger *fluent.Fluent tag string level slog.Level } func NewFluentdHandler(tag string , opts *slog.HandlerOptions) (*FluentdHandler, error ) { logger, err := fluent.New(fluent.Config{ FluentHost: "127.0.0.1" , FluentPort: 24224 , FluentNetwork: "tcp" , MarshalAsJSON: true , }) if err != nil { return nil , err } level := slog.LevelInfo if opts != nil && opts.Level != nil { level = opts.Level.Level() } return &FluentdHandler{ logger: logger, tag: tag, level: level, }, nil } func (h *FluentdHandler) Enabled(ctx context.Context, level slog.Level) bool { return level >= h.level } func (h *FluentdHandler) Handle(ctx context.Context, r slog.Record) error { data := make ( map [ string ] interface {}) data[ "level" ] = r.Level.String() data[ "msg" ] = r.Message data[ "time" ] = r.Time r.Attrs( func (a slog.Attr) bool { data[a.Key] = a.Value.Any() return true }) return h.logger.PostWithTime(h.tag, r.Time, data) } func (h *FluentdHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } func (h *FluentdHandler) WithGroup(name string ) slog.Handler { return h } func (h *FluentdHandler) Close() error { if h.logger != nil { return h.logger.Close() } return nil } func main() { // 標準出力用(テキスト形式、DEBUG以上) stdoutHandler := slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }, ) // Fluentd用(INFO以上) fluentdHandler, err := NewFluentdHandler( "app" , &slog.HandlerOptions{ Level: slog.LevelInfo, }, ) if err != nil { panic (err) } defer fluentdHandler.Close() multiHandler := slog.NewMultiHandler(stdoutHandler, fluentdHandler) logger := slog.New(multiHandler) slog.SetDefault(logger) slog.Info( "app started" , "version" , "1.0.0" ) slog.Warn( "high resource usage" , "cpu" , 85.5 , "memory" , 90.2 ) slog.Error( "failed to connect database" , "error" , "connection timeout" ) } この実装により以下が可能になります。 標準出力には開発者が確認しやすい形式でログが表示される Fluentdには構造化されたJSON形式でログが送信され、ログ分析システムで処理可能になる 各ハンドラーで異なるログレベルを設定できるため、標準出力にはすべてのログ、Fluentdには重要なログのみを送信、といった制御が可能になる 環境ごとのログレベル設定 開発環境ではすべてのログを標準出力に、本番環境では重要なログのみをファイルに記録する、といった環境に応じた設定の分岐をするケースです。handlersの内容を変化させるだけなので分かりやすいと感じました。 実装例 func setupLogger(env string ) { var handlers []slog.Handler if env == "development" { // 開発環境用 標準出力 DEBUG以上 handlers = append (handlers, slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}, )) } if env == "production" { // 本番環境 ファイル INFO以上 logFile, _ := os.OpenFile( "app.log" , os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666 ) handlers = append (handlers, slog.NewJSONHandler( logFile, &slog.HandlerOptions{Level: slog.LevelError}, )) } multiHandler := slog.NewMultiHandler(handlers...) slog.SetDefault(slog.New(multiHandler)) } まとめ Go 1.26で追加される slog.MultiHandler により、複数の出力先に対して異なる設定でログを出力できるようになります。これにより、以下のようなメリットが得られます。 出力先ごとに異なるログレベルやフォーマットを設定可能 サードパーティのライブラリに依存せずに実装可能 開発時は標準出力で確認し、本番環境ではファイルやログ収集システムに送信する、といった使い分けが容易 適切なユースケースがあれば使っていきたいと考えています。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 9 日目の記事です。 はじめに こんにちは。リテールハブ開発部小売アプリチームの池です。 Flutter で開発しているアプリの中で、Email のワンタイムパスワード(OTP)を利用した認証機能を検証する機会がありました。 iOS には、SMS やメールで届いた認証コードをクイックタイプバーと呼ばれるキーボード上部に候補として表示し、タップするだけで入力できる自動入力(AutoFill)機能があります。 次の画像は OTP 自動入力が動作した際の UI イメージです。 OTP AutoFill 動作時の見た目 ユーザーの手入力やコピー&ペーストの手間を無くすことができるため、認証フローのユーザー体験にとって大切な機能だと考えています。 しかし、Apple 公式ドキュメントには SMS を前提とした記述が中心で、Email 経由の OTP 自動入力については情報が多くありませんでした。 どのような条件で動作するのか把握しづらいため、本記事では実機を用いて Email 経由の OTP 自動入力の挙動について検証します。 なお、本記事では iOS のみを検証対象としており、Android は対象外としています。 公式ドキュメントからわかること 私が確認した範囲にはなりますが、まずは、Apple および Flutter の公式ドキュメントから把握できる内容を整理します。 Apple 公式ドキュメントでわかること Apple の公式ドキュメント( One-time codes 、 About the Password AutoFill workflow 、 Enabling Password AutoFill on a text input view 、 WWDC18 Session 204 )から確認できる主なポイントは以下のとおりです。 AutoFill は Apple 純正の Messages(SMS)および Mail アプリに対応 UITextContentType.oneTimeCode を設定することで AutoFill 対象の入力欄と認識される SMS には「code」または「passcode」というキーワードがコード文字列の近くに含まれている必要がある SMS から認証コードをパースできた場合、受信後最大 3 分間 QuickType バーに表示される 認証コード入力欄にカスタム入力ビューを使用すると、AutoFill の UI が表示されない Flutter 公式ドキュメントでわかること Flutter の公式ドキュメント( AutofillHints.oneTimeCode )から、以下の内容が確認できます。 SMS ワンタイムコードの入力フィールドであることを示すヒント iOS で UITextContentType.oneTimeCode にマッピングされる つまり、Flutter では AutofillHints.oneTimeCode を設定することで、iOS ネイティブの UITextContentType.oneTimeCode と同等の動作が期待されます。 公式ドキュメントから明確に確認できないこと 一方で、以下の点については公式ドキュメントに明記されていません。 Gmail などサードパーティメールアプリでの挙動 通知設定が挙動に影響するかどうか Email 本文にも SMS 同様「フォーマット要件」があるか AutofillHints.oneTimeCode 設定有無による挙動差分(設定しない場合でも AutoFill が動作していたため) これらの点について実機を用いて検証を行いました。 検証内容 検証環境 項目 バージョン iOS 26.1 Flutter 3.35.6 検証パターン Email 経由の OTP 自動入力について、以下の 3 つの軸の組み合わせを検証しました。 軸 条件 メールアプリ Apple 純正 Mail / Gmail Flutter の AutofillHints.oneTimeCode 設定 あり / なし 通知設定 ON / OFF さらに、Email 本文にもフォーマット要件(SMS の「code」相当)があるかどうかについても検証対象としました。 検証に使用した実装 検証には以下の 2 パターンの TextFormField 実装を使用しました(OTP 自動入力に関連する箇所のみ抜粋)。 パターン 1: AutofillHints.oneTimeCode なし TextFormField( keyboardType: TextInputType.number, ... ) パターン 2: AutofillHints.oneTimeCode あり TextFormField( keyboardType: TextInputType.number, autofillHints: const [AutofillHints.oneTimeCode], ... ) 検証結果 結果 ①:Mail と Gmail の挙動の違い 結論から言うと、Apple Mail はどのパターンも動作したが、Gmail は通知設定が必須です。 oneTimeCode 設定 メールアプリ 通知設定 結果 なし Mail ON 動作する なし Mail OFF 動作する なし Gmail ON 動作する なし Gmail OFF 動作しない あり Mail ON 動作する あり Mail OFF 動作する あり Gmail ON 動作する あり Gmail OFF 動作しない 画像では正確な挙動が分かりづらいですが、以下が純正 Mail アプリおよび Gmail で通知 ON/OFF における AutoFill の挙動の画像になります。 Mail では通知 OFF でもクイックタイプバーに認証コードが表示されています。 Mail アプリ 通知 ON Mail アプリ 通知 OFF 一方で、Gmail では通知 OFF にするとクイックタイプバーに認証コードが表示されませんでした。 Gmail 通知 ON Gmail 通知 OFF この結果から、Mail ではメール本文を頼りに認証コードが検出され、Gmail では通知本文を頼りに認証コードを抽出されていると推測できます。 追加検証:プッシュ通知(FCM)からの OTP も検出されるか Gmail で通知設定によって挙動が変わることから、「サードパーティメールアプリの場合、通知を頼りに iOS が認証コードを抽出しているのでは?」という仮説が生まれたので、FCM 経由で認証コードを含む通知を送信して検証しました。 次の画像の内容で通知を送ります。 結果、FCM からのプッシュ通知でも、クイックタイプバーに認証コードが表示されました。 つまり、iOS はメールアプリに限らず、通知に含まれる認証コードも AutoFill の対象としていることが確認できました。 結果 ②:Email のフォーマット要件 SMS では「code」または「passcode」というキーワードが必要という要件がありますが、Email についても同様の要件があるか検証しました。 以下の 3 パターンのメール本文で OTP 自動入力が動作するか確認しました。 パターン メール本文例 結果 数字のみ 123456 動作しない 「認証」キーワード 認証:123456 動作しない 「コード」キーワード コード:123456 動作する 数字のみ 認証:123456 コード:123456 結果、「コード」というキーワードが含まれている場合のみ OTP AutoFill が動作しました。これは SMS と同様に、Email でも「code」に相当するキーワード(日本語の「コード」を含む)が必要であることを示唆しています。 検証結果のまとめ ここまでの事実を整理すると、以下になります。 iOS が OTP を検出する経路は 2 種類ある Apple 純正アプリ(Mail / Messages) → 本文へ直接アクセス サードパーティアプリ → 通知本文のみ参照 そのため、Gmail は通知 OFF で AutoFill が動作しないという挙動になる。 Email にも「キーワード要件」がある 「コード」「code」「passcode」など、コード番号の意味を示す語が必要 補足 AutofillHints.oneTimeCode の設定について 今回の検証では AutofillHints.oneTimeCode の設定有無で OTP 自動入力の挙動に違いは見られませんでした。しかし、Apple 公式ドキュメントでは UITextContentType.oneTimeCode の設定が必要とされています。設定なしで動作したのはあくまで検証時の結果であり、この挙動が保証されているわけではありません。 また、Apple は Associated Domains と組み合わせた domain-bound OTP(OTP を特定ドメインに紐づけるセキュリティ機能)をサポートしており、この機能にも UITextContentType.oneTimeCode の設定が必要です。 今回の検証では AutoFill に限り動作はしたものの保証されているものではなく、domain-bound OTP 機能もあるので、公式ドキュメントに則り AutofillHints.oneTimeCode は設定しておくことを推奨します。 終わりに Email 経由の OTP AutoFill の挙動は、SMS に比べて公式情報が乏しくブラックボックスな部分が多いです。今回の検証により実践的な知見を得ることができました。 また、AutoFill による入力はもちろん大切ですが、ユーザーの利用しているメールアプリや設定次第では AutoFill が効かないことがわかったため、手動入力も意識した UI 設計を心がける必要があると学びました。 Email 経由の OTP AutoFill の挙動は iOS のアップデートにより変化する可能性がありますが、本記事が少しでも参考になれば幸いです。
アバター
目次 はじめに Step Functions とは 突然のエラー発生 Step Functions のペイロードサイズ制限 制限の概要 なぜこの制限があるのか 問題のワークフロー構成 修正前の定義(抜粋) 解決策: ResultWriter と ItemReader の活用 ResultWriter とは ItemReader とは 修正後のワークフロー構成 修正後の定義(抜粋) まとめ この記事は every Tech Blog Advent Calendar 2025 の 8 日目の記事です。 はじめに こんにちは。開発本部開発1部デリッシュキッチンMS2に所属している惟高です。 私が担当しているプロジェクトでは、AWS Step Functions を使って Athena でデータを集計し、結果をエクスポートするバッチ処理を実行しています。ある日、処理対象のデータ量が増えたタイミングで突然ワークフローが停止し、調査の結果 Step Functions のペイロードサイズ制限に起因する問題だと判明しました。 この記事では、問題の原因と Step Functions の制限について調査した内容、そして ResultWriter と ItemReader を使った解決方法を紹介します。 Step Functions とは AWS Step Functions は、複数の AWS サービスをワークフローとして連携させるサーバーレスのオーケストレーションサービスです。JSON ベースの Amazon States Language(ASL) でワークフローを定義し、 Lambda 関数や Athena クエリなどを順次・並列に実行できます。 今回のワークフローでは、 Map ステート を使って複数のアイテムを並列処理しています。 Map ステート には以下の2つのモードがあります。 モード 特徴 インライン Map 状態を含むワークフローのコンテキストで実行。最大 40 の同時反復 分散 子ワークフローとして分散実行。10,000 を超える同時反復が可能 今回問題が発生したワークフローでは、大量のデータを処理するために分散モードを使用していました。 突然のエラー発生 ある日、定期実行していたワークフローが以下のエラーで停止しました。 The state/task 'Map' returned a result with a size exceeding the maximum number of bytes service limit. エラーメッセージを見ると、 Map ステートの出力サイズが上限を超えたことが原因のようです。処理対象のデータ量が増えたことで、分散 Map で並列処理した結果が蓄積され、次のステートに渡せなくなっていました。 Step Functions のペイロードサイズ制限 調査したところ、Step Functions には 256KB のペイロードサイズ制限 があることがわかりました。 制限の概要 AWS の公式ドキュメント によると、Step Functions では以下の制限があります。 ステート間で受け渡しできるデータの最大サイズは 256KB この制限は入力・出力の両方に適用される 制限を超えると States.DataLimitExceeded エラーが発生する なぜこの制限があるのか Step Functions はサーバーレスのワークフローサービスであり、ステート間のデータはサービス内部で管理されます。 AWS のベストプラクティス では、256KB を超える可能性があるデータは S3 に保存することが推奨されています。 問題のワークフロー構成 エラーが発生していた当時のワークフローは、以下のような構成でした。 Map(分散モード)─── S3から入力データを読み込み(ItemReader) └── Map(1)(インラインモード)─── 各クエリを実行 └── SQL生成 → 実行 → 結果取得 ↓ 結果を収集 ↓ 【ここで 256KB を超過】 ↓ Map(2)(インラインモード) └── エクスポート処理 ※ Athena のクエリ結果そのものではなく、結果が格納された S3 パスのみを受け渡していましたが、処理対象のアイテム数が増えるとメタデータが蓄積され、256KB を超えてしまいました。 修正前の定義(抜粋) { " Map ": { " Type ": " Map ", " Next ": " Map (2) ", " Iterator ": { " StartAt ": " Map (1) ", " States ": { " Map (1) ": { " Type ": " Map ", " ItemsPath ": " $.queries ", " Iterator ": { // ... Athena クエリ実行処理 } } } , " ProcessorConfig ": { " Mode ": " DISTRIBUTED ", " ExecutionType ": " STANDARD " } } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON " } , " Parameters ": { " Bucket.$ ": " $.target.bucket ", " Key.$ ": " $.target.key " } } // ResultWriter がない → 結果が直接次のステートへ } , " Map (2) ": { " Type ": " Map ", " ItemProcessor ": { " ProcessorConfig ": { " Mode ": " INLINE " } // ... エクスポート処理 } } } 解決策: ResultWriter と ItemReader の活用 Step Functions の分散 Map には、大きな結果を S3 に書き出す ResultWriter という機能があります。これと ItemReader を組み合わせることで、256KB の制限を回避できます。 ResultWriter とは ResultWriter は、分散 Map の実行結果を S3 に書き出す機能です。設定すると、以下のような構造でファイルが出力されます。 指定したS3プレフィックス/ └── {実行ID}/ ├── manifest.json # マニフェストファイル ├── SUCCEEDED_0.json # 成功した子ワークフローの結果 ├── SUCCEEDED_1.json # (5GB を超える場合は分割される) ├── FAILED_0.json # 失敗した子ワークフローの結果 └── PENDING_0.json # 未実行の子ワークフローの情報 マニフェストファイルには、エクスポート場所やマップ実行 ARN、各結果ファイルへの参照などのメタデータが含まれています。 ItemReader とは ItemReader は、分散 Map の入力データを S3 から読み込む機能です。通常、Map ステートは ItemsPath で指定した配列をループしますが、 ItemReader を使うと S3 に保存された JSON や CSV ファイルから直接データを読み込めます。 これにより、以下のメリットがあります。 大量データの処理 : 256KB の入力制限を気にせず、S3 上の大きなデータセットを直接処理できる 柔軟なデータソース : JSON、CSV、マニフェストファイルなど様々な形式に対応 ポインタ指定 : ItemsPointer を使って、ファイル内の特定のパスにあるデータを抽出できる 今回のワークフローでは、 ResultWriter で書き出したマニフェストファイルを ItemReader で読み込み、結果ファイルの一覧を取得するために使用しています。 修正後のワークフロー構成 Map(分散モード)─── S3から入力データを読み込み(ItemReader) └── Map(1)(インラインモード)─── 各クエリを実行 └── SQL生成 → 実行 → 結果取得 ↓ ResultWriter で S3 に結果を書き出し ← 【ポイント】 ↓ 【S3 経由でデータを受け渡し】 ↓ 「S3に保存した処理取得」Map(分散モード)─── マニフェストから結果ファイル一覧を取得(ItemReader) └── Map(2)(分散モード)─── 各結果ファイルを読み込み(ItemReader) └── エクスポート処理 ※ マニフェストには複数の結果ファイル(SUCCEEDED_0.json, SUCCEEDED_1.json など)への参照が含まれるため、それぞれを並列処理するために Map を使用しています。 ポイントは、最初の Map に ResultWriter を追加して結果を S3 に書き出し、後続の処理では ItemReader を使って S3 から読み込むようにした点です。 修正後の定義(抜粋) 1. ResultWriter の追加 最初の Map に ResultWriter を追加し、結果を S3 に書き出すようにしました。 { " Map ": { " Type ": " Map ", " Next ": " S3に保存した処理を取得 ", " Iterator ": { // ... 既存の処理 } , " ResultWriter ": { " Resource ": " arn:aws:states:::s3:putObject ", " Parameters ": { " Bucket ": " your-bucket-name ", " Prefix ": " workflow-results/ " } , " WriterConfig ": { " OutputType ": " JSON ", " Transformation ": " FLATTEN " } } } } 2. マニフェストから結果ファイルを読み込む Map の追加 ResultWriter の出力には ResultWriterDetails というフィールドが含まれ、マニフェストファイルの場所が記載されています。これを ItemReader で読み込みます。 { " S3に保存した処理を取得 ": { " Type ": " Map ", " ItemProcessor ": { " ProcessorConfig ": { " Mode ": " DISTRIBUTED ", " ExecutionType ": " STANDARD " } , " StartAt ": " Bucket付与 ", " States ": { " Bucket付与 ": { " Type ": " Pass ", " Next ": " Map (2) ", " Parameters ": { " Bucket ": " your-bucket-name ", " Key.$ ": " $.Key " } } , " Map (2) ": { // ... 後続の処理 } } } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON ", " ItemsPointer ": " /ResultFiles/SUCCEEDED " } , " Parameters ": { " Bucket.$ ": " $.ResultWriterDetails.Bucket ", " Key.$ ": " $.ResultWriterDetails.Key " } } } } ポイントは ItemsPointer: "/ResultFiles/SUCCEEDED" の部分です。マニフェストファイル内の成功した結果ファイル一覧を直接参照し、それぞれを並列処理のアイテムとして扱います。 3. 各結果ファイルを読み込んで処理 最後に、各結果ファイルを ItemReader で読み込み、エクスポート処理を実行します。 { " Map (2) ": { " Type ": " Map ", " ItemProcessor ": { // ... エクスポート処理 } , " ItemReader ": { " Resource ": " arn:aws:states:::s3:getObject ", " ReaderConfig ": { " InputType ": " JSON " } , " Parameters ": { " Bucket.$ ": " $.Bucket ", " Key.$ ": " $.Key " } } } } まとめ Step Functions の 256KB ペイロードサイズ制限に遭遇した際の解決方法を紹介しました。 ポイント: Step Functions のステート間で受け渡せるデータは 256KB まで 大きなデータを扱う場合は S3 を経由 する設計が必要 分散 Map の ResultWriter で結果を S3 に書き出せる ItemReader の ItemsPointer でマニフェストから必要な情報を抽出できる 今回のエラーをきっかけに Step Functions の制限と、それを回避するための機能について理解を深めることができました。同様の問題に遭遇した方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。
アバター
この記事は every Tech Blog Advent Calendar 2025 の7日目の記事です。 はじめに デリッシュキッチンのiOSアプリを開発している成田です。 iOSアプリを開発していると、アイコンやロゴなどの画像アセットを扱う場面が必ずあります。 アイコンやロゴなどのベクター画像を扱う際、 PDF と SVG の2つの形式が候補として出てくるかと思いますが、どちらを使えば良いか迷ったことはないでしょうか。 今回はPDFとSVGをいくつかの観点から比較し、どちらを選ぶべきか考えてみたいと思います。 PDFとSVGはそもそも何が違うのか まず、両者の設計目的の違いを見てみましょう。 項目 SVG PDF 設計目的 Web用ベクター画像 環境に依存しない文書形式 ファイル形式 テキスト(XML) バイナリ 開発元 W3C Adobe SVGは「画像」のための形式 SVGは「Scalable Vector Graphics」の略で、Web上でベクター画像を扱うために設計された形式です。 テキスト(XML)形式で、描画データだけを持つシンプルな構造になっています。 <svg viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" fill="#FF6B00"/> </svg> 例えば上記は円を描画するものですが、XML形式なので人間にも読み取ることができます。 PDFは「文書」のための形式 PDFは「Portable Document Format(ポータブル・ドキュメント・フォーマット)」の略で、1993年にAdobeが開発したファイル形式です。 Adobe公式 によると、PDFの設計目的は「どのコンピューターやソフトウェアで文書を作成しても、作成時の見た目のまま表示・印刷できること」でした。 PDFはベクター画像やラスター画像も含められる汎用的なコンテナ形式です。 PDFは文書形式として設計されているため、描画データ(ベクター・ラスター画像)の他にも、メタデータ(作成者、日時)やフォント情報、ページ構造、印刷設定など様々な情報が含まれるとみられます。 アイコンやロゴなどの用途で必要なのは描画データだけですが、PDFではそれ以外の情報も含まれるため、その分オーバーヘッドが生じます。 一方でSVGは最初からベクター画像のために設計されているため、アイコン用途にはより適していると言えそうです。 iOSのサポート 形式 サポート開始 PDF iOS 11(2017年) SVG iOS 13(2019年) iOSではPDFの方が古くからサポートされています。iOS12以下をサポートする必要がある場合はPDFを選ぶ必要がありますがありますが、2025年12月現在では大体のアプリはiOS12以下はサポートしていないと思うので基本的にはどちらを使っても特に不具合等は起こらないとみられます。 Appleの公式ドキュメントを読んでもどちらを推奨するなどの旨の記述は見つかりませんでした。 ファイルサイズ シンプルなアイコンの場合 シンプルなアイコンの場合、一般的にSVGはPDFより軽くなります。 実際に上のようなシンプルなアイコンをPDFとSVGで書き出して比較したところ、ファイルサイズは以下のようになりました。 形式 サイズ SVG 約0.8KB PDF 約5KB 複雑な画像の場合 ただし、複雑なパスやグラデーションが多いような画像では、PDFの方が軽くなる可能性もありそうです。 SVGはテキスト形式のため、複雑な曲線を表現するには大量の座標データをそのまま保持する必要がありますが、一方でPDFはバイナリ圧縮されるため、ある種の複雑なベクターデータでは圧縮効率が高くなる場合があると考えられます。 Git上の管理 PDFはバイナリ形式なのでGitで差分を確認することができませんが、SVGはテキスト(XML)形式なので、Gitで差分を確認することができます。 PRのレビューで変更内容を確認できることは大きなメリットなのでGit管理のしやすさにおいてはSVGの方が適しています。 レンダリングの品質 iOSアプリでSVGとPDFを使った際のレンダリング品質の差については、調べた限りでは明確な違いは見つかりませんでした。 どちらもベクター形式なので拡大縮小しても劣化せず、レンダリング品質・機能面ではどちらを選んでも問題なさそうです。 結論 ここまでいくつかの観点で比較してきましたが、 iOS 13以降をサポートするアプリであればSVGを使うのが良さそう です。 SVGを選ぶ理由 アイコンは「画像」なので、画像用に設計されたSVGが適切 シンプルなアイコンならファイルサイズが小さい Gitで差分が見える (PRレビューがしやすい) おわりに 今回はiOSアプリにおけるベクター画像の形式としてSVGとPDFを比較しました。 PDFは長くiOS開発で使われてきた実績がありますが、iOS13でSVGがサポートされた今、アイコン用途ではSVGを選ぶメリットの方が大きいと感じます。 特にGitで差分が見えるようになるのは、チーム開発において地味ながら嬉しいポイントです。
アバター
この記事は every Tech Blog Advent Calendar 2025 の 6 日目の記事です。 こんにちは、株式会社エブリーで Android アプリ開発を担当している岡田です。 弊社では開発スピード向上のための選択として、UseCase を削るアーキテクチャ改修を行いました。 こちらについて、少しお話しさせていただければと思います。 概要: 従来のアーキテクチャの紹介 弊社では Google Developers が提唱している、レイヤードアーキテクチャ を採用しています。 optional として紹介されている ドメイン層 も採用しています。ドメイン層 には主に UseCase を記述しています。 従来のアーキテクチャでは ViewModel が Repository を参照する場合、UseCase を介するような設計になっていました。 UI レイヤー が データ層 を直接参照してはいけないという、教科書的で厳格な「Clean Architecture」の解釈に沿った設計です。 一見するとこれは確からしい素敵なアーキテクチャに思えますが、実は Android アプリを開発する上では大きな課題を抱えています。 課題:冗長な UseCase の量産 従来のアーキテクチャでは、たとえ単純なデータ取得であっても、必ずUseCaseを作成していました。 例えば、ユーザープロフィールを表示するだけの機能でも、以下のようなコードが必要でした。 /** * 従来の UseCase */ class GetUserProfileUseCase @Inject constructor ( private val userRepository: UserRepository ) { // Repositoryを呼ぶだけ(パススルー) suspend operator fun invoke(userId: String ): User { return userRepository.getUser(userId) } } /** * 呼び出される側の Repository (インターフェース) */ interface UserRepository { // 戻り値も引数も UseCase と全く同じ suspend fun getUser(userId: String ): User } ご覧の通り、 GetUserProfileUseCase の実装は、 UserRepository のメソッドを右から左へ受け流すだけのパススルーな処理です。 アプリの機能を拡張するたびに、このような冗長な UseCase を作成しなければならないのは、単なる手間の問題にとどまりません。 クラスが増えれば、それに付随する Unit Test の記述も必要となり、プロジェクト全体のコード量は肥大化します。 特にマルチモジュール構成を採用している場合、こうしたファイル数の増加はビルド時間の悪化に直結します。 さらに、Pull Request の差分が本質的ではないコードで埋め尽くされることは、レビュワーの認知的負荷を高め、開発効率を低下させるという悪循環に陥っていました。 弊社のアプリはサーバーがビジネスロジックを持つケースが多く、上記の問題が顕在化していました。 この件については、 Google Developers のドキュメント: ドメイン層 > データ層のアクセス制限 でも触れられています。 ドメイン層を実装する際のもう 1 つの考慮事項は、UI レイヤーからデータ層への直接アクセスを許可するか、すべてをドメイン層経由で強制するかです。 この制限を設ける利点は、たとえばデータ層への各アクセス要求で分析ログを実行している場合など、UI がドメイン層 ロジックをバイパスするのを防ぐことができることです。 ただし、潜在的に重大な欠点は、データ層への単純な関数呼び出しであってもユース ケースを追加する必要があり、メリットがほとんどないにもかかわらず複雑さが増す可能性があることです。 必要な場合にのみユースケースを追加するのが良いアプローチです。UIレイヤーがほぼユースケースを通じてのみデータにアクセスしていることがわかった場合は、この方法でのみデータにアクセスするのが合理的かもしれません。 最終的に、データ層へのアクセスを制限するかどうかの決定は、個々のコードベースと、厳格なルールを好むか、より柔軟なアプローチを好むかによって決まります。 Google Developers としても、アプリによって使い分けた方が良いという見解のようです。 解決策:ドメイン層(UseCase)の使用をアプリがビジネスロジックを持つ場合にのみ限定する 最終的に、ドメイン層(UseCase)の使用をアプリがビジネスロジックを持つ場合にのみ限定することに決めました。 ビジネスロジックを持たない場合は、 ViewModel から Repository を直接参照することで、冗長な記述を排除できます。 このアーキテクチャは、 Now In Android と同様のものです。 docs/ArchitectureLearningJourney.md を見ると、以下のような図があります。 UI レイヤー が データ層 を直接参照することを許容しています。 実際のコード MainActivityViewModel.kt を見ても、直接 Repository を Inject しています。 @HiltViewModel class MainActivityViewModel @Inject constructor( userDataRepository: UserDataRepository, // <= ここ ) : ViewModel() { val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map { Success(it) }.stateIn( scope = viewModelScope, initialValue = Loading, started = SharingStarted.WhileSubscribed(5_000), ) } 弊社のコードベースも上記のアーキテクチャを採用し、大幅なコード削減を達成しました。 おまけ: 公式 Android ガイダンスが提唱するアーキテクチャと、クリーンアーキテクチャの違い 公式 Android ガイダンスが提唱するアーキテクチャとクリーンアーキテクチャには違いが多いです。 Now In Android には過去このような Discussions があり、ここでクリーンアーキテクチャとの違いについて議論されています。 github.com 簡単にまとめると、以下になります。 依存関係の方向(Dependency Direction) クリーンアーキテクチャ 公式 Android ガイダンス データ層 が ドメイン層 に依存 ドメイン層 が データ層 に依存 ドメイン層の扱い クリーンアーキテクチャ 公式 Android ガイダンス 必須 任意(Optional) クリーンアーキテクチャでは ドメイン層 を中心として設計 されていますが、 公式 Android ガイダンス では データ層 を中心として設計 する形になっています。 これは大抵の Android アプリはビジネスロジックをサーバーに任せるケースが多いためです。 まとめ 「Clean Architecture の純粋さ」を守ることよりも、「チームの開発生産性」と「コードの実用性」を優先する選択をしました。 最初は「レイヤーを飛ばすこと」に抵抗がありましたが、Google のガイドラインという後ろ盾と、実際のコードのスッキリ具合を見て、今ではチーム全体がこの変更をポジティブに捉えています。 もし、「UseCase を書くのが面倒だ」「コードが無駄に多い」と感じているなら、一度 「その UseCase は本当に必要か?」 をチームで話し合ってみてはいかがでしょうか。
アバター
【実践】RDS for MySQL 8.4アップグレード Blue/Green Deploymentsを添えて この記事は every Tech Blog Advent Calendar 2025 の 5 日目の記事です。 背景 バージョン8.0と8.4の変更点と対応について 1. パラメータグループの作成 2. デフォルト値が変更されたパラメータ innodb_purge_threads group_replication_exit_state_action binlog_format innodb_change_buffering innodb_buffer_pool_instances 3. アップグレード前に必須の対応事項 FLOAT/DOUBLE列でのAUTO_INCREMENTの廃止 パーティショニングキーでのインデックスプレフィックスの禁止 4. 認証プラグインに関する重要な変更 5. TLSバージョンのサポート変更 6. 廃止された機能とユーティリティ memcachedインターフェース 削除されたユーティリティ その他の削除された機能 7. アップグレード前の事前チェック 8. 推奨される対応手順 変更点のまとめ 【実践1】ローカル開発環境でテスト 目的 手順 結論 【実践2】AWS上でテスト環境構築&QA 目的 手順 結論 【実践3】TerraformでBlue/Green Deployments 目的 手順1:Backupsを有効にする 問題1 解決策1 手順2:engine_version の変更適用 Blue/Green Deployments実行ログ(一部抜粋) まとめ 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  昨年に引き続きMySQL8.4についての記事を書くことにしました。続編という位置づけで実際に私の所属チームで運用しているAmazon RDS for MySQL 8.0が2026年7月31日には標準サポート終了予定になってしまうので今年のうちに8.4にアップグレードをすることになったというのが背景です。 tech.every.tv  とはいえ、どうせアップグレードするならただin-place upgradeするより新しい試みをしてみたいと思い、公式DOCを拝読していたらAmazonのサービス機能のひとつとしてフルマネージドなBlue/Green Deployments(2022年12月GA)というものが存在していることに気がついたのでこれを使って簡単low downtimeでアップグレードしたい!となりました。  以降、前半部分で8.0と8.4の違いについて説明します。後半部分で実践して起きた問題点なども踏まえながら話させていただきます。 バージョン8.0と8.4の変更点と対応について  RDS for MySQL 8.0から8.4へのアップグレードにおいて、DBパラメータの設定について確認すべき重要な変更点があります。デフォルトのパラメータ設定のままでも基本的には動作しますが、以下の点を理解し、必要に応じて調整することを推奨します。 1. パラメータグループの作成  メジャーバージョンアップグレードを行う際は、既存のカスタムパラメータグループが新しいバージョンと互換性がない場合があります。MySQL 8.4用の新しいパラメータグループを事前に作成し、アップグレード時に適用することが推奨されます。デフォルトのパラメータグループを使用している場合でも、アップグレード前に8.4用のパラメータグループに切り替えることで、アップグレード時のエラーを防ぐことができます。 参考: Amazon RDS for MySQL のメジャーバージョンアップグレードの概要 2. デフォルト値が変更されたパラメータ  MySQL 8.4では、以下のパラメータのデフォルト値が変更されています。これらの変更が既存のアプリケーションのパフォーマンスや動作に影響を与える可能性があるため、事前に確認することが重要です。 innodb_purge_threads 新デフォルト値 : LEAST({DBInstanceVCPU/2},4) 影響 : InnoDBの履歴リストの長さが大きくなりすぎないように自動調整されます。高負荷環境では、この設定により purge スレッド数が適切に調整されます。 group_replication_exit_state_action 新デフォルト値 : OFFLINE_MODE 影響 : Group Replicationを使用している場合に影響します。通常のRDS for MySQLでは直接的な影響はありません。 binlog_format 新デフォルト値 : ROW 影響 : バイナリログのフォーマットがROW形式になります。レプリケーションを使用している場合、データの一貫性が向上しますが、ログサイズが増加する可能性があります。 innodb_change_buffering 新デフォルト値 : none 旧デフォルト値 : all 影響 : チェンジバッファリングが無効化されます。これにより、セカンダリインデックスへの挿入・更新操作のパフォーマンスに影響を与える可能性があります。 innodb_buffer_pool_instances 新デフォルト値 : innodb_buffer_pool_size とCPU数に基づいて動的に計算 旧デフォルト値 : 8 (または innodb_buffer_pool_size < 1 GiBの場合は 1 ) 影響 : バッファプールインスタンス数が自動的に最適化されます。 参考: MySQL 8.4 What Is New - InnoDB system variable default value changes 3. アップグレード前に必須の対応事項  以下の変更は、アップグレード前に対応しないとアップグレードが失敗する可能性があります。 FLOAT/DOUBLE列でのAUTO_INCREMENTの廃止  MySQL 8.4では、 FLOAT または DOUBLE 型の列に AUTO_INCREMENT を使用することができなくなりました。この組み合わせを使用しているテーブルが存在する場合、 アップグレード前に必ず修正する必要があります 。そうしないとアップグレードが失敗します。 対応方法: -- 既存のAUTO_INCREMENTを使用しているFLOAT/DOUBLE列を確認 SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE EXTRA LIKE ' %auto_increment% ' AND DATA_TYPE IN ( ' float ' , ' double ' ); -- 該当する列をINTやBIGINTに変更 ALTER TABLE table_name MODIFY column_name BIGINT AUTO_INCREMENT; パーティショニングキーでのインデックスプレフィックスの禁止  MySQL 8.4では、パーティショニングキーにインデックスプレフィックス(例: KEY (column_name(10)) )を持つ列を使用できなくなりました。該当するテーブルがある場合は、アップグレード前にパーティショニング定義を変更する必要があります。 参考: MySQL 8.4 What Is New - Features Removed 4. 認証プラグインに関する重要な変更  MySQL 8.0では既に caching_sha2_password がデフォルトの認証プラグインとなっていますが、 MySQL 8.4では mysql_native_password プラグインが無効化 されています。これにより、以下の点を確認する必要があります: 明示的に mysql_native_password を使用しているユーザーアカウントがある場合、アップグレード後に接続できなくなる 既存のユーザーアカウントの認証方式を事前に確認し、必要に応じて caching_sha2_password に変更する アプリケーションが caching_sha2_password 認証方式に対応していることを確認する 参考: MySQL 8.4 What Is New - Native password authentication 5. TLSバージョンのサポート変更  MySQL 8.4では、 TLS 1.2およびTLS 1.3のみがサポート されています。古いTLSバージョン(TLS 1.0、TLS 1.1)を使用しているクライアントは接続できなくなります。アップグレード前に、以下を確認してください: アプリケーションおよびクライアントがTLS 1.2以上に対応しているか SSL/TLS接続設定を使用している場合、接続が正常に行えるかテストする 参考: MySQL 8.4 What Is New - TLS protocol support 6. 廃止された機能とユーティリティ  MySQL 8.4では、以下の機能とユーティリティが廃止されています。 memcachedインターフェース   memcachedインターフェースのサポートが廃止 されています。この機能を使用している場合は、代替手段への移行が必要です。 削除されたユーティリティ 以下のユーティリティがMySQL 8.4で削除されました: mysql_upgrade : MySQL 8.0.16で非推奨となり、8.4で削除されました。アップグレード処理は自動的に実行されます mysqlpump : MySQL 8.0.34で非推奨となり、8.4で削除されました。代わりに mysqldump またはMySQL Shellのダンプユーティリティを使用してください mysql_ssl_rsa_setup : MySQL 8.0.34で非推奨となり、8.4で削除されました。MySQLサーバが起動時に自動的にSSL/RSA証明書を生成します その他の削除された機能 INFORMATION_SCHEMA.TABLESPACES テーブル DROP TABLESPACE と ALTER TABLESPACE の ENGINE 句 LOCK TABLES ... WRITE の LOW_PRIORITY 句 参考: MySQL 8.4 What Is New - Features Removed 7. アップグレード前の事前チェック  Amazon RDSは、アップグレード前に自動的に事前チェックを実行し、非互換性を検出します。事前チェックで問題が検出された場合、アップグレードは自動的にキャンセルされ、 PrePatchCompatibility.log に詳細が記録されます。このログを確認し、必要な修正を行ってから再度アップグレードを試みることができます。 参考: Amazon RDS for MySQL のメジャーバージョンアップグレードの概要 8. 推奨される対応手順  安全にアップグレードを実施するため、以下の手順を推奨します: 破壊的変更の事前確認 : FLOAT/DOUBLE列でのAUTO_INCREMENT使用状況、パーティショニングキーでのインデックスプレフィックス使用状況を確認し、該当する場合は事前に修正 テスト環境での検証 : 本番環境でのアップグレード前に、テスト環境で同様のアップグレードを実施し、アプリケーションの動作を確認 パラメータグループの作成 : MySQL 8.4用の新しいパラメータグループを作成し、必要な設定を反映 スナップショットの取得 : アップグレード前に最新のスナップショットを取得 接続設定の確認 : TLS 1.2以上のサポート、認証プラグインの対応を確認 パフォーマンスモニタリング : アップグレード後、CloudWatch メトリクスやスロークエリログを監視し、パフォーマンスの変化を確認(特に innodb_change_buffering のデフォルト値変更の影響を確認) 変更点のまとめ  RDS for MySQL 8.0から8.4へのアップグレードにおいて、デフォルトのDBパラメータ設定のままでも基本的には動作しますが、以下の点に特に注意が必要です: 必須対応事項 : - FLOAT/DOUBLE列でのAUTO_INCREMENT使用の確認と修正(アップグレード失敗の原因になる) - パーティショニングキーでのインデックスプレフィックスの確認と修正 接続に影響する変更 : - mysql_native_password の無効化(既存ユーザーの認証方式確認が必要) - TLS 1.0/1.1のサポート終了(TLS 1.2以上への対応が必須) パフォーマンスに影響する可能性のある変更 : - innodb_change_buffering のデフォルト値が none に変更 - その他複数のInnoDBパラメータのデフォルト値変更  事前にテスト環境で検証を行い、特に必須対応事項については本番環境のアップグレード前に必ず対応してください。 【実践1】ローカル開発環境でテスト 目的  8.0から8.4への仕様変更に伴うアプリケーションサーバの既存実装の改修要否を知ること 手順  目的を達成するためにローカルPCのDocker上にmysqlコンテナを起動させてアプリケーションコンテナからAPIの動作確認をしました。 1. 8.0のDBバックアップと8.4へのリストア # on MySQL 8.0 $ mysqldump --single-transaction --skip-lock-tables -p -h 127. 0 . 0 . 1 -P 3306 -uappuser -B app_test > app_test_dump20251201.sql # on MySQL 8.4 $ mysql -p -h 127. 0 . 0 . 1 -P 3308 -uappuser < app_test_dump20251201.sql 2. ユーザの作成&権限付与  ローカル環境は、今回8.4用の動作検証を新しく行うために完全新規の接続ユーザを作成したので、CREATE USER文と権限付与を行いました。元々ローンチしたときはMySQL5.7で運用していたので現在のMySQL8.0既存ユーザの認証プラグインも mysql_native_password を設定しているので8.4でもその認証プラグインを使うようにしておけばそれでよいです。 mysql> CREATE USER ' appuser ' @ ' % ' identified WITH mysql_native_password BY ' [パスワード] ' ; + ---------+------+-----------------------+ | user | host | plugin | + ---------+------+-----------------------+ | appuser | % | mysql_native_password | + ---------+------+-----------------------+ 1 row in set ( 0 . 00 sec) mysql> FLUSH PRIVILEGES ; # 権限付与が足りない場合は指定データベースの全テーブルに対して全権限を付与 mysql> SHOW GRANTS FOR `appuser`@`%`; GRANT ALL PRIVILEGES ON `apptest`.* TO `appuser`@`%` 3. API実行  すべてのAPIをリクエスト送信して正常レスポンスが返ってくることを確認しました。 結論  動作確認結果から、MySQL8.4に対して既存実装の改修は不要という結論に至りました。 【実践2】AWS上でテスト環境構築&QA 目的  既存のDB InstanceはTerraform管理下にあるため最終的にはTerraformでBlue/Green Deploymentsしたいのですがそれを実行すると良くも悪くもフルマネージドなためテストする暇もなくapply途中で旧Blue環境が削除されてしまいます。  よって、目的はterraform applyする前に別途新しいDB Instanceを作成してアプリケーションテスト(QA)を実施して問題なく動作すること 手順 コンソールで既存DB InstanceのSnapshot作成 手順1:スナップショット作成 SnapshotをMySQL8.4.7にUpgradeさせる 手順2:スナップショットのアップグレード QA用のParameter GroupをMySQL8.4ベースのもので作成して、設定変更をさせておく UpgradeさせたSnapshotを元にRestore AWS Cloud Mapでリストアした新しいDB InstanceをサービスにRegisterしてCNAMEを紐づけ直す AWS Cloud Mapでサービス登録した既存DB InstanceをDeregister アプリケーションのQA実施 結論  手順4では、userの権限や認証プラグインはそのままリストアされているので問題ありませんでした。AWS上でのQAも正常な結果でしたので問題なく動作することが確認取れました。よって、このままBlue/Green Deploymentsを実施可能という判断になりました。 【実践3】TerraformでBlue/Green Deployments 目的   terraform apply コマンド実行し全自動でmajor version を8.0から8.4にアップグレードすること 手順1:Backupsを有効にする  既存DB Instanceの設定は自動バックアップを無効化にしていました。AWS公式のDocumentsによれば、Blue/Green Deploymentsを実行する際には自動バックアップを有効化にしなければならない(must)という文言の記載がありましたのでまず有効化にすることにしました。 [Preparing an RDS for MySQL or RDS for MariaDB DB instance for a blue/green deployment] Before you create a blue/green deployment for an RDS for MySQL or RDS for MariaDB DB instance, you must enable automated backups. For instructions, see Enabling automated backups. resource "aws_db_instance" "app_db" { engine_version = "8.0" backup_retention_period = 1 blue_green_update { enabled = false } } 問題1 backup_retention_period=1で自動バックアップ保持期間を最短の1日にして一旦有効化しようとしたら、以下のようにapply直後にすぐ差分が出てしまうことが判明しました。 $ terraform apply ... module.rds.aws_db_instance.app_db: Modifications complete after 1m20s [ id = db -XXXXXXXX ] Apply complete ! Resources: 0 added, 1 changed, 0 destroyed. $ terraform plan ... ~ resource " aws_db_instance " " app_db " { ~ backup_retention_period = 0 - > 1 id = " db-XXXXXXXX " tags = {} # (55 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy.  原因は、apply_immediately argument値をtrueにしないと即時反映されないからのようです。つまり、DB Instance再起動を伴う変更ということがわかりました。AWS公式DOCにも以下のような注意書きがありました。 ⚠️Important An outage occurs if you change the backup retention period of a DB instance from 0 to a nonzero value or from a nonzero value to 0. 解決策1  DBインスタンス再起動を実施することによってダウンタイムが発生してしまうというのは避けたかったので強引に自動バックアップ有効化の変更をBlue/Green Deploymentsの対象としてSwitchoverさせればこのダウンタイムが最小化するかもと思い試してみました。方法は、以下の変更を terraform apply 1回で同時適用させることです。 resource "aws_db_instance" "app_db" { engine_version = "8.0" backup_retention_period = 1 blue_green_update { enabled = true } }  結果は、Blue/Green Deployments自体は発動しましたが、Green環境が作成される前にBlue環境のDB Instanceが結局再起動して自動バックアップを有効化にしていたので無駄に終わってしまいました。。したがって、再起動ダウンタイムはどうしても発生してしまうということになりますのでご注意ください。 自動バックアップ有効化実施時のログ 手順2:engine_version の変更適用  以下の変更を terraform apply 実行で適用させました。DBパラメータグループは使用中の8.0用のものを更新してしまうとSnapshot作成やBlue環境で使用するParameter groupsが存在しなくなってエラーを引き起こしそうだったので新規で8.4用のリソースを作成しました。 resource "aws_db_instance" "app_db" { engine_version = "8.4" parameter_group_name = aws_db_parameter_group.mysql_8_4.name backup_retention_period = 1 blue_green_update { enabled = true } } resource "aws_db_parameter_group" "mysql_8_4" { name = "mysql-8-4" family = "mysql8.4" } $ terraform apply ... ... module.rds.aws_db_instance.app_db: Modifications complete after 30m57s [ id = db -XXXXX ] Apply complete ! Resources: 1 added, 1 changed, 0 destroyed.  このフルマネージドな実行時間31分の間にどのようなことが行われているのかを知るためにコンソール上でUIの変更やLogs&eventsタブのログがどのような出力が出るかなどを監視していました。 BlueがModifyingのとき(Backupを作成中)、接続は成功していました Blue,GreenどちらもAvailableになるのは18分30秒くらいでした Switchover開始前にNew Blueインスタンスの方のイベントログにTerminate connectionと表示されて terraform apply 実行前から敢えてDBに接続し続けていたセッションが切れていることを確認取れています。レプリケーション整合性の担保のための挙動のようです。その後すぐに新規接続を試みれば成功はしました。おそらくこの瞬間が公式DOCにある The switchover typically takes under a minute with no data loss and no need for application changes. に相当する時間なのかと思われます。 $ aws ssm start-session --target ecs: < cluster_name > _ < task_id > _ < Container runtime ID > --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters ' {"host":["<endpoint_name>"],"portNumber":["3306"], "localPortNumber":["3306"]} ' Connection accepted for session [ XXXXX ] Connection to destination port failed, check SSM Agent logs. $ aws ssm start-session --target ecs: < cluster_name > _ < task_id > _ < Container runtime ID > --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters ' {"host":["<endpoint_name>"],"portNumber":["3306"], "localPortNumber":["3306"]} ' Connection accepted for session [ xxxxx ] 元々Deletion protectionがEnabledの設定をしていたのですが、旧Blue環境のDB Instance自動削除は無事に成功していました! Blue/Green Deployments実行ログ(一部抜粋) リソース名 タイプ 日時 イベント内容 bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:02 (UTC+09:00) Your blue/green deployment bgd-9h8u8mpxqbfcl8ho will create a read replica of app_db with storage type gp3, and allocated storage 20. app_db (New Blue) Primary November 20, 2025, 19:04 (UTC+09:00) Backing up DB instance app_db (New Blue) Primary November 20, 2025, 19:06 (UTC+09:00) Finished DB Instance backup bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:24 (UTC+09:00) Blue/green deployment tasks completed. You can make more modifications to the green environment databases or switch over the deployment. bgd-9h8u8mpxqbfcl8ho Blue/green deployment November 20, 2025, 19:29 (UTC+09:00) Switchover started on blue/green deployment app_db. app_db (New Blue) Primary November 20, 2025, 19:29 (UTC+09:00) Switchover from primary app_db to app_db-green-lqjrxz started. app_db (New Blue) Primary November 20, 2025, 19:29 (UTC+09:00) The primary app_db-green-lqjrxz environment is now accepting read and write operations at the database level. The write downtime during the switchover lasted approximately 2 seconds. DNS propagation might take additional time to complete. まとめ  本記事では、Amazon RDS for MySQL 8.4へアップグレードするための方法としてフルマネージドBlue/Green DeploymentsをTerraformから実行したときのベストプラクティスを紹介いたしました。既存運用中のDB Instanceが元から自動バックアップ有効化にしている場合にはダウンタイムは何も気にせずに完全自動化に任せて簡単にアップグレードができることがわかりました。Switchover時に発生した接続断の許容ができるレベルのサービスであればメンテナンス中の作業にするまでもなくオンラインで実施可能という実感でした。 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
この記事は every Tech Blog Advent Calendar 2025 の 4 日目の記事です。 開発1部でデリッシュキッチンのバックエンドをメインに担当している塚田です。 はじめに 弊社では デリッシュリサーチ というサービスのビジュアライズにAWSが提供するQuickSightを活用していました。 AWSが先日発表した「QuickSuite」は、生成AIで開発・業務・運用の作業をまとめて手助けし、仕事の効率を上げるためのツール集です。 本記事では、従来の関連サービスからの変更点、実運用での活用を想定したパターンを検討したのでその内容を記事にさせていただきます。 QuickSuiteの位置づけと構成の全体像 主なできること 開発を楽にする: コード提案、リファクタ、テスト作成、PR作成/説明 調べ物を速くする: 社内ドキュメントやデータを安全に検索・要約 定型作業を自動化する: チケット対応や運用手順を手順どおり支援 セキュリティと管理 社内データについては権限に合わせて検索・利用 QuickSightのからの主な変更点 開発体験の統合や企業内ナレッジ活用 既存のファイルストア、ナレッジベース、データウェアハウス(DWH)に対するRAGの組込み 検索・回答のガードレール、出典提示、ポリシー準拠/監査性の強化 代表ユースケース データ分析のセルフサービス化 例: 自然言語から各種データソース向けSQL生成、ダッシュボード雛形まで自動作成。データガバナンスに沿ったアクセス制御 異常値が発生した際の対応の高速化 例: アラートから発火→メトリクス/ログ/トレースを収集→暫定原因と影響範囲を生成→対応策の検討 サポート対応の効率化 例: 顧客問い合わせをチケット化、類似事例/FAQ/手順を提案、回答案と影響範囲の注意点を自動添付 アーキテクチャ・パターン セキュアRAG基盤 企業データソース(DWHやオブジェクトストレージなど)→ インデクシング/埋め込み → ポリシー付き検索 → 生成 ポイント: VPCエンドポイント、KMS暗号化、監査(CloudTrail/CloudWatch)、PIIマスキング 開発ワークフロー統合 GitHub/Jira/CI/CD と連携した「チケット駆動の自動修正→PR→レビュー補助」 変更影響分析(関数/モジュール依存、テストカバレッジ)を組み込み 導入ステップ 新規で導入する場合を考えた時に以下のようなフローや対応が必要に感じました。 対象業務の選定 「効果が定量化しやすい/安全に実験できる」ユースケースを検討(例: ドキュメントQA、テスト生成、自動要約) データ基盤の整備 データカタログ、スキーマ管理、アクセス境界、メタデータの整備 セキュリティ/ガバナンス設計 IAMロール/ポリシー、監査ログ、プロンプトや出力内容の検討 PoC設計と評価指標 品質(正答率/再現性)、効率、安全性 段階的ロールアウト 先行チーム→横展開。トレーニングとプロンプトスタイルガイドの整備 運用・継続改善 フィードバックループ(曖昧質問の定義、ナレッジの鮮度管理)、コスト監視 コスト ユーザー課金という部分は変わらずユーザー単価の変更 コスト予想が行いやすく導入後のイメージが見通せる リスク 生成AIを活用しているため必ずしも正しい内容を提示できるわけではない ガードレールをプロダクトとして提示する必要あり データ漏洩や必要以上の回答をする可能性がある /越権参照: 厳格なIAM・ネットワーク分離・プロンプト抑止語(秘密、顧客情報)・DLP バージョン差異の発生で想定していない出力が発生する可能性 以前のAWS関連サービスとの比較 従来 社内で開発・運用しているQuickSightなどのBIツールのみでの分析や評価を実施 生成AIのRAGやエージェント構築は設計と統合作業が重く、運用ガードレールを自作 QuickSuite 開発/検索/自動化を「業務フロー」として束ね、エンドツーエンドに管理・監査 エージェントの実行制御をあらかじめ検討可能 まとめ 弊社ではデータ基盤としてDatabricksを活用しており、社内へ展開するメリットはあまり感じられませんでした。 ただ、すでにプロダクトでQuickSightを利用している場合、QuickSightからあったBIの導入のしやすさに加え、AI(エージェント)の導入・統合がしやすくなったと感じています。 今までBIとAI関連のプロダクトをシームレスに提供することに課題を感じていたためこのような機能を活用しながらユーザーが活用しやすいプロダクトとして活用していきたいと感じました。
アバター
Swift Observationフレームワークの利点と動作 この記事は every Tech Blog Advent Calendar 2025 の 3日目の記事です。 こんにちは、デリッシュキッチンでiOSエンジニアをしている谷口恭一です。 デリッシュキッチンのiOSでは現在、状態の変更通知の仕組みとして主にCombineを使用しています。最低互換のiOSバージョンをiOS16としているため、まだObservationフレームワークの導入はできていません。 しかし、おそらく来年にはiOS17+になると予想され、SwiftUIではObservationを使用したモダンな状態管理システムで新規画面は開発するかもしれません。 AppleはObservationフレームワークを、 ObservableObject から非常に簡単かつ安全に移行できるように設計しているため、既存画面改修などの際には、少しずつObservationに移行していく可能性もあります。 よって、Observation自体の仕組みや、他の状態の変更通知システムに対する利点をある程度は理解する事が重要であると考えています。 そこで、フレームワークの調査や学習を行い、そのアウトプットとしてObservationについて解説していくというのがこの記事の目的です。 まず、Observationフレームワークとは何かについて説明し、既存のCombineの ObservableObject を用いた方法との違いと利点を説明し、最後にObservation自体の動作の概要について説明します。 Observationフレームワークとは Appleによると、Observationは以下の3つの機能を提供するフレームワークです。 Marking a type as observable( 型を観測可能にする ) Tracking changes within an instance of an observable type( 観測可能な型のインスタンス内の変更を追跡する ) Observing and utilizing those changes elsewhere, such as in an app’s user interface( アプリのUIのようなどのような場所からもこれらの変更を観測し活用できる ) https://developer.apple.com/documentation/Observation よって、値の変更を観測可能にする機能と、それを通知する機能であると言えます。SwiftUI側はObservationの機能を内部的に利用して、値の変更をUI再描画に活用しています。 しかしObservationの値の変更の活用自体はSwiftUIのスコープに限らず、どのような場所からもできるという汎用的な機能であることがわかります。 とはいえ、大部分のユースケースはSwiftUIのUIシステムに対するデータバインディングだと思います。 例えば、以下のようなSwiftUIの画面実装で役に立ちます import SwiftUI @Observable class Car { var name : String = "" var needsRepairs : Bool = false } struct ContentView : View { @State private var car1 = Car() var body : some View { VStack(spacing : 20 ) { Text(car1.name.isEmpty ? "No name" : car1.name ) TextField( "Name" , text : $car1 .name) .textFieldStyle(.roundedBorder) Toggle( "Needs repairs" , isOn : $car1 .needsRepairs) } .padding() } } #Preview { ContentView() } この実装では、車の名前と、修理が必要かどうかという2つの値がユーザのインタラクションによって変更される可能性があり、それらの変更をUI上に即座に反映させたいという要件があります。 このようなときに、監視対象のクラスに対して @Observable マクロを付けると、そのクラスのプロパティの値をそれぞれ監視/通知し、SwiftUI側が変更通知によって自動でUI更新までやってくれるというのが大まかな機能です。監視対象のクラスは Observable プロトコルに適合されます。 なお、 @State はViewのライフサイクルでインスタンスを破棄せず、この値がView階層内でSSoT(Single Source of Truth、信頼できる唯一の情報源)な値となるために引き続き付ける必要があります。しかし、このプロパティラッパーは、値の監視やObservationの機能とは全く異なるものです。 ここで、 Observable プロトコルと、 @Observable マクロという概念が登場しました Appleによると、 Observable は以下のようなプロトコルです。 A type that emits notifications to observers when underlying data changes. 内在するデータが変更されたときにオブザーバーに通知を送信する型 https://developer.apple.com/documentation/observation/observable 内在するデータとは、このプロトコルに準拠した型のプロパティのことだと思われます。このプロトコルは単にそれが監視ができるということを表すだけであるということに注意が必要です 次に、 @Observable マクロについては以下のような説明があります。 Defines and implements conformance of the Observable protocol. Observable プロトコルの準拠を定義および実装します。 https://developer.apple.com/documentation/observation/observable() よってこのマクロでは、2つのことを行っていることがわかります マクロを付けた型を Observable プロトコルに準拠させる マクロを付けた型に対して、 Observable プロトコルの準拠のための実装をする ここでのプロトコルは、概念や型を抽象化、共通化するような目的のプロトコルの使い方とは若干異なるような気がしました。プロトコルを特定の制約の明示に使っていて、その実装などはマクロのコード追加が担うというコンセプトのようです。 Observationフレームワークが提供する機能としてはこれでほぼすべてになります。非常にシンプルであることがわかります。 SwiftUI側は、 Observable に適合した型に対してUI更新などの特別な操作を内部的に仕込んでおくことによって、その変更を活用することができるという仕組みになっています。 ObservableObjectとの違いについて 現在でもSwiftUIでの状態管理にはCombineの ObservableObject プロトコルと @StateObject プロパティラッパーがよく使われていて、Observationによる実装はこちらとほぼ同等な機能を提供していると思います。 ではなぜObservationが必要なのでしょうか?また、 ObservableObject に対して、Observationフレームワークはどのような利点があるのでしょうか?私は、主に以下の4つあると考えています。 監視したい型のプロパティそれぞれに対して監視が行える ネストした型の子要素も監視できる SwiftUIのView階層で、監視したい値を伝播させる必要がない 監視対象の各プロパティに対して、監視することを明示する必要がない まず1つ目が、監視したい型のプロパティそれぞれに対して監視が行えるという点です。 ObservableObject の場合、このプロトコルに適合したクラスの @Published なプロパティの変更によって、そのクラスを使用するUI要素すべてが再描画されてしまうという問題がありました。しかし、監視対象のプロパティがたくさんある場合、そのプロパティに関係があるUI要素のみを再描画するほうが理にかなっています。Observationはこのような要件を満たすことができています。 次に、監視対象の型のネストについてです。 ObservableObject に適合したクラスの @Published なプロパティを ObservableObject に準拠させて監視しようとしても、ネストされた子オブジェクトの変更は自動では通知されません。これは単純な理由で、状態の変更通知はView側で ObservableObject プロトコルの objectWillChange() というメソッドによって行われるため、ネストした子オブジェクトの objectWillChange() は呼ばれないから通知されないという訳です。 よって、子オブジェクトの objectWillChange() が呼ばれると、親オブジェクトの objectWillChange() を連鎖的に呼ぶような実装を明示的にすればこの問題は解決できますが、クリーンな方法とは言えなそうです。 Observationでは、単にアクセスした値を自動的に監視するというコンセプトのため、その値を保持するデータ構造に依存しません。よって、ネストされていても問題なく変更通知を受け取ることができます。 3つめにSwiftUI上での値の伝播についてです。 SwiftUIでは、 @StateObject や @State で定義したViewのスコープのライフサイクルにおいて、この値の監視とUI更新を行います。よって、子Viewでの値の変更には対応していません。よって、 @Binding というプロパティラッパーを使用して、子Viewと値を共有するという仕組みを提供しています。よって、末端の子Viewで値が変更されると、その子Viewを含むView階層全体を更新します。データの伝播が仕組みとして複雑であるという点と、1つ目の問題と同様にパフォーマンス的な問題があります。しかし、Observationフレームワークでは、変更対象の値を持つ各Viewそれぞれが値を監視するというシンプルな仕組みのため、値の伝播のための複雑な機能を必要としません。 最後に、 ObservableObject の実装では、監視対象のプロパティに @Published というプロパティラッパーを付与する必要があるという点です。SwiftUIで使うクラス内のミュータブルな値は、基本的に監視したいというユースケースなはずです。よって、監視するという状況をデフォルトにして、監視したくないときにそれを明示するほうが自然であるという考え方もできると思います。Observationはこのような方法を取るため、よりスッキリとしたコードを書くことができます。これは書き心地の問題なので、これまでのものより些細な問題かもしれません。 Observationフレームワークの動作の概要 それではObservationフレームワークが提供する Observable マクロはどのような実装をしているのでしょうか?Swiftのマクロは展開することができるため、実装を確認することができます。実装を見れば大体何をしていそうかが推測できると思います。Xcode上ではマクロを表す部分( @Observable )を右クリックすると、Expand Macrosという選択肢が出てきます。 @Observable class Car : Observable { var name : String = "" var needsRepairs : Bool = false } この例のようなクラスのマクロをXcodeを使って展開すると以下のようになります。 @Observable class Car : Observable { @ObservationTracked var name : String = "" { @storageRestrictions ( initializes: _name ) init (initialValue) { _name = initialValue } get { access(keyPath : \.name) return _name } set { withMutation (keyPath : \.name) { _name = newValue } } _modify { access(keyPath : \.name) _ $observationRegistrar . willSet ( self , keyPath : \.name) defer { _ $observationRegistrar . didSet ( self , keyPath : \.name) } yield & _name } } @ObservationIgnored private var _name : String = "" @ObservationTracked var needsRepairs : Bool = false { @storageRestrictions ( initializes: _needsRepairs ) init (initialValue) { _needsRepairs = initialValue } get { access (keyPath : \.needsRepairs) return _needsRepairs } set { withMutation (keyPath : \. needsRepairs) { _needsRepairs = newValue } } _modify { access (keyPath : \.needsRepairs) _ $observationRegistrar willSet ( self , keyPath : \.needsRepairs) defer { _ $observationRegistrar didSet ( self , keyPath : \.needsRepairs) } yield & _needsRepairs } } @ObservationIgnored private var _needsRepairs : Bool = false @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access < Member > ( keyPath : KeyPath < Car , Member > { _ $observationRegistrar .access( self , keyPath : keyPath ) } internal nonisolated func withMutation < Member , MutationResult > ( keyPath : KeyPath < Car , Member > , _ mutation : () throws -> MutationResult ) rethrows -> MutationResult { try _ $observationRegistrar .withMutation(of : self , keyPath : keyPath , mutation) } } まず、Observationフレームワークの登場人物は以下になります ObservationRegistrar : どこでどの @ObservationTracked なプロパティを見ているかを保持する登録簿のようなものを管理する責務を持つ access(keyPath:) : プロパティが読まれたことをレジストラに伝える withMutation(keyPath:, mutation: ) : 値の変更を監視者全体に伝える @Observable マクロを付けると、定義したプロパティはすべて計算プロパティになります。SwiftのAttached Macroはコードの変更や削除はできず、追加することしかできないという制約があります。これはコードの安全性を保つためです。しかし、元の変数を _name のように別の変数によけて、本来の変数を計算プロパティにすると言う方法はAttached Macroのコンセプトの割には結構技巧的だなと個人的には思いました。 これによって、定義したプロパティの get や set で特定の処理を行うことができるようになっています。マクロを使用する部分の本質はここだと思っています。ここでしたい処理の殆どは ObservationRegistrar という値の監視/通知を司る構造体経由で行っていることがわかります。 ObservationRegistrar の access というメソッドでは監視対象の登録、 withMutation では変更の全体通知のような役割を持っています。 また、 ObservationRegistrar の定義の末尾に以下のようなグローバル関数が定義されています。 public func withObservationTracking < T > ( _ apply : () -> T , onChange : @autoclosure () -> @Sendable () -> Void ) -> T この関数は、 apply 内でレジストラに登録されたプロパティの変更を検出すると、 onChange 内のクロージャが実行されるというシンプルな関数です。 Appleのドキュメントでは以下のようなコード例が示されていました。 func render () { withObservationTracking { for car in cars { print(car.name) } } onChange : { print( "Schedule renderer." ) } } 一見するとこの関数は不思議に思うかもしれません。なぜなら、 apply 内では単に print 文を呼んでいるだけで、意味のないコードに見えるからです。しかし、 Observable マクロの仕組みを見るとわかるように、プロパティの get で access が呼ばれないと、監視対象に追加されないという設計のため、このプロパティを1度でも参照する必要があるということです。 SwiftUIのViewのbodyでは、このメソッドにUI再描画の必要があるプロパティを渡して監視する機能が内部で実装されているのであろうということが推測できます。これによって、必要なViewのスコープで必要なプロパティだけを監視して部分的に再描画するということが可能になっている技術的な理由がわかると思います。 まとめ Observationは以下の機能を提供するシンプルなフレームワークである 型を観測可能にする 観測可能な型のインスタンス内の変更を追跡する アプリのUIのような、どのような場所からもこれらの変更を観測し活用できる SwiftUIでの状態管理では、ObservationフレームワークはCombineの ObservableObject に対していくつかのメリットがある Observationフレームワークは、値の監視機能をマクロを使って実装されていて、 ObservationRegistrar 経由で監視と通知が行われている SwiftUIにおいてObservationフレームワークを使用したシンプルな状態管理はiOSアプリ開発におけるデファクトスタンダードになると私は思います。もちろんCombineなどを使用するべきユースケースもあると思います。デリッシュキッチンの最低互換バージョンが上がったら、実際に移行してみて試してみたいと思います。
アバター
はじめに 導入背景 バックエンドで直面した課題 RevenueCat の魅力 Webhook によるイベント通知 ダッシュボード A/B テスト基盤がある 実際に使って感じたメリット 工夫した点 サブスクリプションの有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成 まとめ 参考 この記事は every Tech Blog Advent Calendar 2025 の 2 日目の記事です。 はじめに こんにちは、トモニテで開発を担当している吉田です。 今年 6 月末、弊社サービス「トモニテ」でサブスクリプションサービスのトモニテプレミアムサービスをリリースしました(iOS のみ、2025/11/28 時点)。サブスクリプション機能を実装するにあたり、バックエンド側でレシート検証やステータス管理の実装に多くの工数がかかることが課題でした。 そこで、サブスクリプション管理に RevenueCat を導入しました。本記事では、バックエンドエンジニアの視点から、RevenueCat を運用してみて感じたことについて紹介します。 導入背景 バックエンドで直面した課題 iOS, Android で異なるところもありますがアプリからの購読登録のざっくりフローは以下の通りです。 アプリからストアへ購読の決済要求 ストアからレシートの発行 レシートをサーバーで検証 検証後アプリでユーザーのステータス更新 主にサーバー側で対応する必要があるのは 3,4 になります。 レシートについては Apple(App Store)と Google(Google Play Store)で形式や記載内容が異なるためそれぞれのプラットフォーム用に処理を用意する必要があります。 またトモニテプレミアムはサブスクリプションサービスなのでユーザーのサブスクリプションステータスの変更をモニタリングしステータスの変更処理もする必要があります。 両プラットフォームでサーバー通知があり、iOS では App Store Server Notifications を、Android では Real-time Developer Notifications(RTDN) を利用して変更を通知できます。 このサーバー通知を受け取るために iOS では ATS プロトコルを使用してサーバーと安全なネットワーク接続を確立する必要があります。Android は Google Cloud Pub/Sub が使用されるためそのセットアップが必要になります。(Pub/Sub に関しては必須ではありませんが、利用しない場合には Google Play Developer API をポーリングする必要があります) 加えて通知で受け取った情報と最新のレシートを照らし合わせてサブスクリプションステータスを検証する処理を準備する必要があり、それも両プラットフォーム用に作成する必要があります。 まとめると以下の対応を ×2 ずつする必要があります。(もし今後 web からの課金が増える場合にはその対応も増えることになる) 購読開始時のレシート検証 サブスクリプションのサーバー通知対応 1,2 の各種メンテナンス 上記経緯からレシート検証の自動化が実現できて直感的なダッシュボードが提供され、分析機能も充実している RevenueCat を導入することにしました。 RevenueCat の魅力 RevenueCat にはたくさんの機能が提供されていますが、個人的には以下が特にいいなと思っています。 Webhook によるイベント通知 RevenueCat にもプラットフォームと同様にサブスクリプションイベントに関するリアルタイム通知が存在します。イベント発生のたびに指定したエンドポイントにデータが送信されます。弊社では送られたデータをいくつかピックアップし自社サーバーに保存して社内の管理画面でユーザーのステータスを確認できるようにしています。また Webhook を利用しサブスクリプションのライフサイクルに基づいてワークフローを起動できたり、発生したイベントに対してユーザーに通知を実行することもできます(解約操作をしたユーザーにサブスクリプションサービスの魅力を提示するなど)。 また送信に失敗(サーバーからステータスコード 200 が返らなかった)しても実行間隔を延ばしながら 5 回リトライしてくれます。 実際に受け取ったリクエストボディは以下になります。(sandbox 環境下、2025/11/17 時点) app_user_id は独自に設定しています { " api_version ": " 1.0 ", " event ": { " aliases ": [ " $RCAnonymousID:<RevenueCatが生成したランダムなアプリユーザーID> ", " <customed_app_user_id> " ] , " app_id ": " <app_id> ", " app_user_id ": " <customed_app_user_id> ", " commission_percentage ": 0.1364 , " country_code ": " JP ", " currency ": " JPY ", " entitlement_id ": null , " entitlement_ids ": [ " Premium " ] , " environment ": " SANDBOX ", " event_timestamp_ms ": 1763356620218 , " expiration_at_ms ": 1763357148000 , " id ": " <id> ", " is_family_share ": false , " is_trial_conversion ": false , " metadata ": null , " offer_code ": null , " original_app_user_id ": " <customed_app_user_id> ", " original_transaction_id ": " <original_transaction_id> ", " period_type ": " NORMAL ", " presented_offering_id ": " paywall_v2 ", " price ": 41.399 , " price_in_purchased_currency ": 6400 , " product_id ": " PlanAnnually ", " purchased_at_ms ": 1763353548000 , " renewal_number ": 20 , " store ": " APP_STORE ", " subscriber_attributes ": { " $adjustId ": { " updated_at_ms ": 1750735215184 , " value ": " <adjustId> " } , " $attConsentStatus ": { " updated_at_ms ": 1749721029062 , " value ": " denied " } , " $deviceVersion ": { " updated_at_ms ": 1760408728747 , " value ": " iPhone12,1-iOS-Version 18.5 (Build 22F76) " } , " $firebaseAppInstanceId ": { " updated_at_ms ": 1762771906755 , " value ": " <firebaseAppInstanceId> " } , " $idfa ": { " updated_at_ms ": 1750735215021 , " value ": " <idfa> " } , " $idfv ": { " updated_at_ms ": 1750735215021 , " value ": " <idfv> " } , " $ip ": { " updated_at_ms ": 1763027995620 , " value ": " <ip_address> " } , " route ": { " updated_at_ms ": 1750323201312 , " value ": " mypage " } } , " takehome_percentage ": 0.85 , " tax_percentage ": 0.0909 , " transaction_id ": " <transaction_id> ", " type ": " RENEWAL " } } ダッシュボード RevenueCat のダッシュボードでは iOS, Android それぞれのデータを一元管理できる他、アプリの収益も確認できます。 Charts & Metrics セクションでは、収益、サブスクリプション、コホートと LTV、コンバージョンファネル、トライアル、解約と返金、未完了期間などの概要メトリクスを確認できます。またコード不要で後述の A/B テストの管理もできます。 A/B テスト基盤がある RevenueCat Experiments というモバイルサブスクリプションアプリビジネスの価格設定とペイウォール設計の最適化に特化した A/B テスト機能があります。こちらは offering(ペイウォールで提示する製品、トライアル期間、価格などのパッケージのこと)を定義し、新規ユーザーに対しコントロール群とトリートメント群の割り当てを行いユーザーに割り当てられたグループに応じたペイウォールが表示されます。出し分けるだけでなく実験開始から 24 時間以内に実験結果を確認することができます。 実際に使って感じたメリット 使ってみて、開発工数の削減が最も大きなメリットだと感じました。レシート検証やステータス管理の実装が不要になったため、プレミアムサービスの機能実装により多くの時間を割くことができました。 工夫した点 サブスクリプションの有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成 Webhook はリトライを 5 回までしてくれると前述しましたがそれでも絶対にデータを取りこぼすことがないとは言い切れません。もし定期購入していないにも関わらずプレミアムステータスのままのユーザーがいればそれは弊社の売り上げ棄損にも繋がります。 RevenueCat が提供している API ではサブスクリプションの有効期限切れのユーザーを洗い出すような機能は提供されていなかったため、自社サーバーに保存しているデータに対して有効期限が切れているにも関わらずプレミアムステータスのままのユーザーがいればステータスを切り替えるバッチを作成しました。 また Webhook は基本的には イベント後5~60 秒の間に通知がされるようになっていますがキャンセルイベントは最大 2 時間の遅延が発生するとなっていたのでバッチではこちらを考慮するようにしています。 遅延について Most webhooks are usually delivered within 5 to 60 seconds of the event occurring - cancellation events usually are delivered within 2hrs of the user cancelling their subscription. You should be aware of these delivery times when designing your app. まとめ RevenueCat を導入することで、サブスクリプション管理に関する開発工数を大幅に削減できました。これにより、プレミアムユーザーサービスの機能実装により多くの時間を割くことができ、6 月末のリリースに間に合わせることができました。RevenueCat がなければ、このタイミングでのリリースは難しかったと感じています。 導入から 5 ヶ月ほど経過していますが、RevenueCat 起因の問題は発生しておらず、安定して運用できています。 同様の課題を抱えている方の参考になれば幸いです。 参考 www.revenuecat.com developer.apple.com developer.android.com
アバター
目次 はじめに Go でのエラー構造 再帰的エラーハンドリング エラーハンドリングのパターン errors.As で値取り出してチェック errors.Is で値の一致 Go1.26 で追加予定の errors.AsType まとめ この記事は every Tech Blog Advent Calendar 2025 の 1 日目の記事です。 はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。アドベントカレンダートップバッターを務めさせていただきます! 今回はまだ時期尚早ですが Go1.26 で errors.AsType が導入されることが予定されており、それに伴うエラーの扱いについて振り返ってみたいと思います。 tip.golang.org ※ この記事は執筆時点で最新の Go1.25.4 をベースに書いています。 Go でのエラー構造 Go のエラーは単なる Error メソッドを持つだけのインターフェースです。 このインターフェースを担保した型は error 型として扱うことができます。 // The error built-in interface type is the conventional interface for // representing an error condition, with the nil value representing no error. type error interface { Error() string } github.com この状態でのエラーでは単純に Error メソッドを呼び出して文字列を取得するだけです。そのため、エラーの種類を識別するために文字列の比較を行うことになってしまいます。またそのままエラー同士の比較もできますが、これはエラーの値が完全に一致しない限り false になってしまいます。 そのため Go1.13 のタイミングで再帰的エラーハンドリングが導入されました。 go.dev 再帰的エラーハンドリング 発生したエラーに対して新たな情報を追加し、エラーチェーンを構築するアプローチです。この手法により、エラーが発生した元のコンテキストから、そのエラーをキャッチして処理した箇所までの全体像を把握することが可能になります。 実際に fmt.Errorf で %w 返しているエラーの型は以下のようになっています。 type wrapError struct { msg string // 全体のエラーメッセージ err error // ラップ元のエラー } func (e *wrapError) Error() string { return e.msg } func (e *wrapError) Unwrap() error { return e.err } github.com これによりラップする前のエラーを Unwrap メソッドで取得することができ、階層的な構造になっていてもエラーを辿ることができます。 エラーハンドリングのパターン errors.As で値取り出してチェック errors.As の実装は以下のようになっており、処理の流れをまとめるとこのようになります。 import ( "internal/reflectlite" ) func As(err error , target any) bool { if err == nil { return false } if target == nil { panic ( "errors: target cannot be nil" ) } val := reflectlite.ValueOf(target) typ := val.Type() if typ.Kind() != reflectlite.Ptr || val.IsNil() { panic ( "errors: target must be a non-nil pointer" ) } targetType := typ.Elem() if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) { panic ( "errors: *target must be interface or implement error" ) } return as(err, target, val, targetType) } func as(err error , target any, targetVal reflectlite.Value, targetType reflectlite.Type) bool { for { // 現在のエラー値が、ターゲットの型に代入可能かをチェック if reflectlite.TypeOf(err).AssignableTo(targetType) { targetVal.Elem().Set(reflectlite.ValueOf(err)) return true } // エラーが独自の As メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { As(any) bool }); ok && x.As(target) { return true } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return false } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if err == nil { continue } if as(err, target, targetVal, targetType) { return true } } return false default : return false } } } var errorType = reflectlite.TypeOf((* error )( nil )).Elem() github.com これにより比較対象のエラーの型に代入可能かをチェックし、可能であればそのエラーの値を取得することができます。 var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) { fmt.Println( "MySQL error occurred:" , mysqlErr.Number) } 細かいですが errors.As のターゲットは必ずポインタである必要があります。これは、Go が関数の引数を値渡しするため、エラーチェーン内の見つかったエラーをターゲット変数に実際に書き込む(代入する)ためには、呼び出し元で定義した変数のメモリアドレス(ポインタ)を渡す必要があるためです。 errors.Is で値の一致 errors.Is の実装は以下のようになっており、基本の流れは errors.As と同じですが、比較対象のエラーの型に代入可能かをチェックする代わりに、現在のエラー値が、比較対象のターゲットエラー(target)と厳密に等しいか(err == target)をチェックします。 func Is(err, target error ) bool { if err == nil || target == nil { return err == target } isComparable := reflectlite.TypeOf(target).Comparable() return is(err, target, isComparable) } func is(err, target error , targetComparable bool ) bool { for { // 現在のエラー値が、ターゲットエラーと厳密に等しいか(err == target)をチェック if targetComparable && err == target { return true } // エラーが独自の Is メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { Is( error ) bool }); ok && x.Is(target) { return true } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return false } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if is(err, target, targetComparable) { return true } } return false default : return false } } } github.com これにより比較対象のエラーと厳密に等しいか(含んでいるか)をチェックし、等しい場合は true を返します。 var ErrNotFound = errors.New( "not found" ) if errors.Is(err, ErrNotFound) { fmt.Println( "not found" ) } Go1.26 で追加予定の errors.AsType これまで errors.As では都度エラーを代入するためのポインタ変数を定義する必要がありましたが、Go1.26 では errors.AsType が追加されることで、エラーを代入するための変数を定義する必要がなくなります。呼び出し方からわかるように Go 1.18 で導入されたジェネリックを活用しています。 // ~ Go1.25 func FindMysqlErrorCode(err error ) ( bool , uint16 ) { var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) { return true , mysqlErr.Number } return false , 0 } // Go1.26 ~ func FindMysqlErrorCode(err error ) ( bool , uint16 ) { if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok { return true , mysqlErr.Number } return false , 0 } 実装の方も基本はこれまでの errors.As と同様になっており、処理の流れはこのようになります。 func AsType[E error ](err error ) (E, bool ) { if err == nil { var zero E return zero, false } var pe *E // lazily initialized return asType(err, &pe) } func asType[E error ](err error , ppe **E) (_ E, _ bool ) { for { // 現在のエラー値が、型パラメータ E の型に一致するかをチェック if e, ok := err.(E); ok { return e, true } // エラーが独自の As メソッドを実装している場合、それを呼び出してチェック if x, ok := err.( interface { As(any) bool }); ok { if *ppe == nil { *ppe = new (E) } if x.As(*ppe) { return **ppe, true } } // エラーチェーンを辿る: Unwrap() メソッドでラップされた下位のエラーを取得 switch x := err.( type ) { case interface { Unwrap() error }: // 単一のエラーをラップしている場合: アンラップして次のループで再チェック err = x.Unwrap() if err == nil { return } case interface { Unwrap() [] error }: // 複数のエラーをラップしている場合: それぞれに対して再帰的にチェック for _, err := range x.Unwrap() { if err == nil { continue } if x, ok := asType(err, ppe); ok { return x, true } } return default : return } } } github.com まとめ error を普段から使うことは多かったですが、改めて実装の中身を除いてみると、開発者がエラーの抽出の仕方をあまり意識しなくても済むようになっていることがわかりました。 Go1.26 では errors.AsType が追加されることで、エラーを代入するための変数を定義する必要がなくなります。これによりエラーの抽出の仕方をより簡潔に、柔軟にすることができると思います。 今後とも Go の進化に食らいついていきながら、より良いエラーハンドリングを実現していきたいと思います。
アバター
目次 はじめに every Tech Blog Advent Calendar 2025 の公開スケジュール 最後に はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 今年も残り 1 ヶ月ちょっととなり、年末の恒例イベント every Tech Blog Advent Calendar 2025 を開催します! このカレンダーでは、エブリーのエンジニアが日々の学びや実践的な技術ノウハウを発信していきます。 技術的な工夫や挑戦の裏側など、幅広いテーマでお届けしますので、ぜひチェックしてください! 過去のアドベントカレンダーはこちらからどうぞ! tech.every.tv tech.every.tv tech.every.tv every Tech Blog Advent Calendar 2025 の公開スケジュール アドベントカレンダーの記事は、11/27~12/25 の日程で順次公開していきます! tech.every.tv 最後に エブリーでは、新しい技術に挑戦しながら成長したい仲間を募集中です。 もし、このブログを読んで「もっと話を聞いてみたい」と感じていただけたら、ぜひカジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!🎅✨
アバター
Grafana LGTMスタックをローカルで検証してみた はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 オブザーバビリティの向上に向けてGrafanaやその関連ツールを検証する一環で、Grafana LGTMスタックをローカルに構築し実際に触ったので、そのあたりを紹介します。 オブザーバビリティについて 本題に入る前にオブザーバビリティについて簡単に説明できればと思います。 オブザーバビリティ とは、システムの内部で起きていることを外部から把握する能力のことです。 日々のパフォーマンス確認/改善やエラー発生時の調査などに役立ちます。 システムの複雑性が増す中で必要性が高まっています。 オブザーバビリティの主要シグナルとしてメトリクス、トレース、ログが存在します。 メトリクス システムの健康状態を数値で表すデータです。 CPU使用率 メモリ使用量 レイテンシー エラーレート など トレース 1つのリクエストに対する一連の処理を可視化するデータです。リクエストが通過するマイクロサービスやデータストアへのアクセスなど、処理の流れ全体を追跡できます。 ログ システムやアプリケーションが出力する記録です。 アプリケーションログ セキュリティログ システムログ 監査ログ など LGTMスタックとは LGTMスタック は、Grafana Labsが提供するオブザーバビリティ(可観測性)を実現するための統合スタックです。以下の4つのコンポーネントの頭文字から名付けられています: L (Loki) : ログを扱うツール G (Grafana) : メトリクス、ログ、トレースを統合的に可視化するダッシュボード T (Tempo) : 分散トレースを扱うツール M (Mimir+Prometheus) : メトリクスを扱うツール これら4つを組み合わせることで、オブザーバビリティの主要シグナルである メトリクス・トレース・ログ を統合的に扱うことができます。 https://grafana.com/docs/opentelemetry/docker-lgtm/ より 今回は、これらのツールをローカル環境で構築し、実際にどのように連携するのかを検証してみました。 Opentelemtryとは 検証では docker-otel-lgtm を使用するのですが、その中でOpenTelemetry Collectorを使用しているため、Opentelemtryについて先んじて簡単に説明させていただきます。 Opentelemtry とは、アプリケーションからメトリクス・ログ・トレースといったテレメトリー(観測)データを統一的に収集・送信するためのOSSです。 アプリケーションを言語、インフラ、ランタイム環境に関係なく簡単に計装できることを目的としています。 ベンダーに依存することなくテレメトリーデータを扱えるメリットがあります。データの送信先としてOTLP(OpenTelemetry Protocol)対応しているツールであれば連携が可能です 上で紹介した図中のOpenTelemetry collectorはアプリケーションからテレメトリーデータを受け取り、各ツールに送信する役割を担っています。 ローカル検証 docker-otel-lgtm を使って検証しました。 docker-otel-lgtmはLGTMスタック+OpenTelemetry collectorを1つのdockerにまとめてくれている公式のプロジェクトです。 そのため、このdocker imageを使って起動するだけで即座にLGTM+OpenTelemetry collectorの環境を用意し、各ツールの機能検証を簡単に開始することができます。 1 . LGTM+OpenTelemetry collectorを起動する 起動はとてもシンプルで、下記のコマンドを実行するだけです。 # Unix/Linuxの場合 ./run-lgtm.sh 2 . 起動したOtel collectorに向けて観測データを送信する docker-otel-lgtmが起動すると、OpenTelemetry Collectorがポート4317(gRPC)と4318(HTTP)でリクエストを受け付けます。 そのため、アプリケーションからのデータ送信はgRPCかHTTPのどちらかの通信方法を選択できますが、今回の検証ではHTTPを使っています。 exampleのサーバーを使ってテレメトリデータを送信する場合 Grafana全体の使用感をサクッと知りたい時は既に用意されたexampleのサーバーを使うことができます。 Go,Java,Pythonなどを使ったexampleがあったので、今回はGoで試してみました。 cd examples/go # 起動 ./run.sh サイコロを振るアプリケーションサーバーが起動します。 goの場合は8081ポートに立つので、 curl http://127.0.0.1:8081/rolldice のようにリクエストすれば、サイコロの数字が返ってきます。 リクエスト後、Grafana( http://127.0.0.1:3000 )にアクセスすると送られてきたテレメトリデータを使った情報を閲覧することができます。 デフォルトでは下記のようなダッシュボードがいくつか用意されていました。 ダッシュボード また、ExploreページからTempoを使ったトレースデータの確認などもできました。 Exploreページ 自前のアプリケーションサーバーを使って観測データを送信する場合 自分が普段触っているサーバーで検証したい事もあると思います。 その場合も、起動したOtel Collectorに向けてデータを送信するだけです。 既にOpenTelemetryを使用して計装を行っている場合は、 送信先をローカルで起動したOtel Collector(httpであれば http://localhost::4318 )に変更するだけで済みます。 未計装の場合、まずは公式の記事を参考に計装を進めていただければと思います。 Goの場合は下記の記事が参考になりますが、試してみたところ案外すんなり計装できました。 opentelemetry.io 下記は実際に開発しているアプリケーションのトレースをしてみた画面です。 実際のトレース画面 他マイクロサービスやDBへのアクセスも含めたトレースを確認できました。 所感 検証の環境について 簡単に検証するためのプロジェクトを公式が用意してくれているのは非常に助かりました。 Grafana LGTMスタックについて 今回使用したdocker-otel-lgtmは検証用の環境を作るものなので簡単に構築できましたが、実運用では可用性やセキュリティ面などを考慮したサーバー構成やツールの設定が必要です。 トレース・メトリクス・ログを統合的に扱うために複数のツールを導入する必要があることを踏まえると、全部自前で用意する場合運用の難しさがありそうだなと思いました。 また、ツールごとの使用方法も理解する必要があるため学習コストが気になりましたが、AIを使うことによって一定の負荷は軽減できそうでした。 下記はAIに作ってもらったダッシュボードの画像です。 ダッシュボードの内容をJSONとして定義できるため、AIの活用が簡単にできます。 ダッシュボード 参考文献 https://github.com/grafana/docker-otel-lgtm?tab=readme-ov-file https://grafana.com/blog/2024/03/13/an-opentelemetry-backend-in-a-docker-image-introducing-grafana/otel-lgtm/ 関連記事 tech.every.tv
アバター
はじめに こんにちは!株式会社エブリーで約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
アバター