TECH PLAY

株式会社エブリー

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

410

はじめに 株式会社エブリーでCTOをしている imakei です。 本日から弊社では多くの新卒メンバーに入社していただきました。 これから彼ら・彼女らとともにより強い開発組織を作っていきたいと思います。 すでに新卒メンバーからは学ぶことも多く、特にAIに関しては自分以上に使いこなしているところを数多くみています。 そんな彼らに感化された部分もあり、生成AIを使いながら開発していく上でのちょっとしたTipsを紹介できればと思います。 弊社では今積極的に生成AIを活用した開発を行っています。 AIAgentを利用した開発も積極的に試しているのですが、そんな中で、 なかなか思っているコマンドを実行してくれないことにストレスを感じていたので、 個人的にModel Context Protocol (MCP) の理解も兼ねてちょっとしたツールを開発してみたので紹介できればと思います。 ※あくまで個人のローカルでの開発を想定したツールです。パブリックに公開するなどは一切考えていないので、その辺はご了承ください。 今回はMakefileを元にMCPサーバーを作成して、それをAIAgentに利用してみたいと思います。 MakefileをMCPサーバー化して開発効率を上げる方法 はじめに 開発プロジェクトでMakefileを使用している方も多いのではないでしょうか。ビルド、テスト、デプロイなど、様々なタスクを効率的に実行できる便利なツールですが、 せっかく整備していてもAgentがそれをうまく使ってくれないということが度々あります。 MCPとは Model Context Protocol (MCP) は、AIアシスタントがローカル環境のツールを実行するためのプロトコルです。これを利用することで、自然言語でMakefileのターゲットを実行できるようになります。 例えば: 「テストを実行して」 「開発サーバーを起動して」 といった指示をAIアシスタントに伝えるだけで、対応するmakeコマンドを実行できます。 実装方法 必要な環境 Deno // 自分は雑なツールを作るときにDenoが好きで使っていますが、正直なんでも良いと思います。 MCP SDK コアとなる実装 MCPサーバーの実装 まず、MCPサーバーの実装です。 SDKが提供されているのでそれほど難しくはなく立ち上げられると思います。 script.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js" ; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ; // MCPサーバーのセットアップ const server = new Server( { name : "local_makefile_mcp" , version : "0.0.1" , } , { capabilities : { resources : {} , tools : { ...makeTools, // Makefileのターゲットから生成したツール群 } , } , } ); await server.connect( new StdioServerTransport()); ツールの生成 Makefileを読み込んで、その中にあるターゲットをツールとして登録しています。 自分用に作ったので、matchesで愚直にやっていますが、より良い方法はありそうです。 script.ts function getMakeCommands ( makefilePath? : string ): Promise < string []> { const cmd = [ "make" , "-n" ] ; if (makefilePath) { cmd. push ( "-f" , makefilePath); } // Makefileの内容を直接読み取る const makefileContent = makefilePath ? await Deno.readTextFile(makefilePath) : await Deno.readTextFile( "Makefile" ); // ターゲットを抽出 const matches = [ ...makefileContent. matchAll ( /^([a-zA-Z0-9_\-\.]+):/gm ) ] ; const targets = matches. map ( m => m[ 1 ]); return [ ... new Set (targets) ] . filter ( target => !target. startsWith ( '.' ) && ! [ 'Makefile' , 'makefile' , 'GNUmakefile' , 'DEFAULT' , 'SUFFIXES' ] . includes (target) ); } function generateMakeTools ( targets : string []): Record < string , Tool > { const makeTools: Record < string , Tool > = {} ; for ( const target of targets) { makeTools[ `make_ ${ target } ` ] = { name : `make_ ${ target } ` , description : `Run 'make ${ target } '` , inputSchema : { type : "object" , properties : {} , required : [] , } , } ; } return makeTools; } // makefileから取得したcommandsを `make_<command>` という名前でツールとして登録している const commands = getMakeCommands( 'path/to/Makefile' ); const makeTools = generateMakeTools(commands); ツールの実行ハンドラー 今回はmakefileから取得したcommandsを make_<command> という名前でツールとして登録しているので、 それを実行するハンドラーを作成します。 script.ts // ツールの実行ハンドラー server.setRequestHandler(CallToolRequestSchema, async ( request : CallToolRequest ) => { const name = request.params. name ; if ( name . startsWith ( "make_" )) { const target = name . replace ( "make_" , "" ); const cmd = [ "make" ] ; if (makefilePath) { cmd. push ( "-f" , makefilePath); } cmd. push (target); // Makefileのディレクトリパスを取得 const makefileDir = new URL ( '.' , 'file://' + makefilePath). pathname ; const command = new Deno.Command( "make" , { args : cmd. slice ( 1 ), // "make"を除いた引数を渡す cwd : makefileDir, stdout : "piped" , stderr : "piped" , } ); const { stdout , stderr } = await command.output(); const output = new TextDecoder (). decode (stdout); const error = new TextDecoder (). decode (stderr); // ... return { content : [ { type : "text" , text : output, } , ] , isError : false , } ; } // 知らないコマンドきた時のハンドリング } ); このコアとなる実装では、以下の重要な機能を提供しています: MCPサーバーの初期化 サーバー名とバージョンの設定 利用可能なツール(makeターゲット)の登録 Makefileターゲットの解析 Makefileの内容を読み取り 有効なターゲットの抽出 特殊ターゲットのフィルタリング ツール定義の生成 各makeターゲットに対応するツールの定義 ツール名とコマンドのマッピング ツール説明の生成 ツール実行ハンドラー makeコマンドの実行 標準出力とエラー出力の取得 実行結果のフォーマット化 これにより、AIアシスタントはMakefileのターゲットを安全に実行し、その結果を適切にユーザーに返すことができます。 使用方法(Clineでの利用) ClineのパネルのMCPタブを開き、Configure MCP Serversを押下すると、 cline_mcp_settings.json というファイルが開くので設定していきます。 cline_mcp_settings.json { " mcpServers ": { " local_makefile_mcp ": { " command ": " path/to/deno/command ", " args ": [ " run ", " -A ", " path/to/script.ts ", " path/to/Makefile " ] , ... } } } これで MCP Servers に local という名前でサーバーが追加されます。 local_makefile_mcpが読み込まれている様子 MCPサーバーを起動後、AIアシスタントに実際にTestをお願いすると、mcpサーバーからコマンドを見つけてくれます。 mcpサーバーからテストコマンドを見つけてくれた様子 無事にコマンドを見つけられていますね。 ただ正直このままですと、自然言語でコマンドを見つけてくれないこともあるので、 Makefileにコメント等をつけてそれをディスクリプションに登録するといいかもしれません。 まとめ このように、MCPサーバーを利用することで、自然言語でMakefileのターゲットを実行できるようになります。 細かい部分にはなりますが、これにより開発におけるAgent利用のストレスが少し減ったように思います。 今後もこういった細かいことから生成AIをもっとストレスなく活用して開発効率を上げていきたいと思っています。 そんな弊社は現在積極採用中です! こんな開発を普段から行なっている弊社で働きたいなと思った方はぜひ一度お話ししましょう! https://twitter.com/imakei_ https://corp.every.tv/recruits/engineer 今井
アバター
はじめに エブリーでデータサイエンティストをしている山西です。 今回は、社内で継続的に実施している数学勉強会について紹介します。 勉強会を続けるうえで工夫したポイントや、取り組みを続けての所感をお伝えします。 概要 エブリーのデータ&AIチームでは、「数式に向き合う習慣を維持する」目的で週に1回のペースで数学の勉強会を実施しています。 記事執筆時点では、データサイエンティスト(MLエンジニアを含む)3名で、『機械学習スタートアップシリーズ ベイズ推論による機械学習入門』を輪読形式で進めています※ www.kspub.co.jp 勉強会の進め方は、各回で担当メンバーが前回の続きから読み進め、数式を咀嚼しながら書き出していくスタイルです。 iPad上の共有ノート(GoodNotes 6の共同作業機能を活用)にリアルタイムで式を書き出しつつ、他のメンバーが質問したり、時には並列で式展開したりして文殊の知恵的に理解を深めています。 ノートの例 2022年初頭にスタートし、教材や進行方法を途中で変えつつも、約3年間継続という比較的歴の長い取り組みになっております。 ※ 過去には『統計的機械学習の数理100問 with Python』を扱っていました。今は2冊目になります。 www.kyoritsu-pub.co.jp 実施の背景 この勉強会は、自分が言い出しっぺとなってスタートさせました。 その背景には、「事業主体であることを言い訳にして、数式と向き合う時間をおろそかにしたくない」という思いがありました。 データサイエンス組織のあらまし 前提として、弊社のデータ組織はR&D寄りではなく、事業に寄り添ったビジネス要求対応の比重が高い体制です。 さらに、少数組織であることから、データサイエンティストも一定のデータエンジニアリング業務を兼務する構図となっています。 データサイエンススキルの活かし方 このような体制下では、生のビジネス現場でデータを活用する多くの実務的な学びが得られます。 一方、意識せずに「事業並走」や「データ基盤の整備」などの比重が増しすぎると、統計や機械学習と向き合うための“可処分データサイエンス時間”が減ってしまう傾向があります。 これは、いわゆる「データサイエンティストだけど、データの整備や可視化、簡単な記述的分析に留まってしまう」状態です。 とはいえ、実際の業務でも「その先の活用」が求められるシーンは多く存在します。 たとえば、統計的な予測や効果検証、プロダクトへの機械学習技術の応用などです。 いざそうした要求が出てきたときに力を発揮するためには、基礎的な理解&実行力が不可欠です。 「ライブラリに頼り切り」は良くないという話 近年は、ライブラリやLLMの発展により、分析の実行そのものは圧倒的に手軽になりました。 しかし、「なぜその手法が使えるのか」「どのような前提で動いているのか」といった背景を理解せずに使うことは、意思決定や改善において、再現性等々の危うさを孕みます。 だからこそ、数学の原理に立ち返り、技術の背後にあるロジックを丁寧に理解しておくことが大切だと考えました。 とどのつまり つまり、日々のビジネス要求にしっかり応えながらも、ここぞというシーンでデータサイエンティストとしての専門性を発揮するためには、継続的で幅広い数学の素養がベースになるということです。 裏を返せば、この視点を疎かにして業務のルーチンが固定化しすぎると、新たな発見や創造的なアプローチが生まれにくくなるリスクにもつながると考えます。 こうした課題感を持つ中で、同じ想いを抱くメンバーとともに、日常業務とは異なる角度でスキルを磨く場として勉強会を企画する運びとなりました。互いに刺激を受けながら学び合い、知識の定着や応用力の向上につなげていきたいという狙いもあります。 運営の工夫 勉強会を継続するために、以下のような工夫を取り入れています。 参加のハードルを下げる 「予習はしない(しても良いけど義務ではない)」「みんなでその場で考える」ベストエフォート式で進行しています。 事前準備の負担を極力抑えることで、忙しい業務の合間でも無理なく参加できるようにしています。 「継続は力なり」の精神で、少しずつでも約3年続けられたのは、このような実施のハードルの低さの寄与が大きいのではないかと思います。 みんなで悩む、考える 心理的安全性の面では「わからないは恥ではない」「みんなで悩もう」という意識で臨むようにしています。 結果として行き詰まってほとんどページが進まないこともありますが、その過程を許容しながら進めることを大切にしています。 一人での学習に比べ、チームで議論することで多様な視点を得られています。 業務に関連するテーマを中心に意見交換を行うため、知識が定着しやすく、互いに学びを高め合うことができています。 行間含め、数式に向き合う 「数学の基礎体力をつける」をモットーに、数式の行間や詳細な導出過程を(書籍で省略されている部分も含めて)追える限り丁寧に追っています。 例えば現在取り扱っているベイズ本では、事前分布から共役事前分布を経て事後分布を導出する流れを、噛み砕きながら丁寧に追っています。 プログラミング上では結果の式さえあれば実装できる(何ならライブラリを使えば式すらラップされて利用できる)部分ですが、この勉強会の時間ではあえてその背後の理論に向き合うことを大切にしています。 個人的には、この習慣の継続によって行列計算の苦手意識が薄まってきたことが一定の成果です。 他の書籍や技術記事で統計数理をキャッチアップする際の壁も、徐々に乗り越えられるようになってきました。 電子媒体(iPad & GoodNotes)の活用 当初はオフィスのフリースペースでホワイトボードを使っていましたが、現在はGoodNotes6アプリを利用したデジタルノートに移行しました(たまたまメンバー全員がiPadでそれを行える環境だったため)。 この移行により、板書を自動記録し、全員で共有できるようになりました。 過去の内容を容易に検索・参照できたり、必要な式をコピー&ペーストで再利用できたりと、クラウド時代ならではの恩恵を感じています。 過去の章で扱った式を今の計算のために引っ張ってきたいとき、コピペの要領でできるのが大変便利(赤枠部分 継続する中での課題 「実施のハードルの低さ」や「数式の細部に向き合うこと」による良い側面がある一方、それならではの課題感もあります。 例えば、予習が義務でないことで「前回の内容を思い出すのが難しい」ことがあります。 間が空くことで細部は思い出せても、全体的な流れや体系的な理解が薄れてしまうケースがあります。 また、「行間を埋める」目的で目の前の数式や概念に集中するあまり、「そもそもこのテーマは何のために学んでいるのか?」という全体像を見失いがちになることもあります。 そして、読み進める速度もどうしても遅くなりがちです(今の本も、1年数ヶ月続けているものの、進捗としては70%程度です)。 さらに、「みんながiPadを持っている前提」で進めているため、新しいメンバーを受け入れる際のハードルがあるという細かな悩みもあります笑。 このあたりは、メンバーそれぞれと実施目的や状況を振り返りつつ、形式の一長一短を吟味しながら適宜アップデートしていけると良いと考えています。 今後の展望: 実務への還元 この勉強会は、「あえて日常業務から離れ、基礎力を向上させる」という趣旨の取り組みである一方、具体的な実務貢献という形でも成果を還元できるとより理想的です。 その一歩として、現在「ベイジアンA/Bテスト基盤」の実装にチャレンジしています。 勉強会を通じてベイズ推論の理論的な理解が深まり、実際のプロダクトへ応用する準備が整ってきたためです。 もともと輪読の教材としてベイズの教科書を選んだ背景には、メンバーの多くが明示的にベイズの文脈での学習/活用経験が少なかったという事情がありました。 その意味でも、この勉強会が新たな技術的創発のきっかけとなり、実践に結びついていくことを期待しています。 おわりに この記事では、エブリー社内で継続して行っている数学勉強会の取り組みについて紹介しました。 「数式と向き合う習慣を維持する」というシンプルながら重要な目的のもと、少人数・低ハードル・実務と地続きという特徴を持った学びの場を育ててきました。 これからも、事業貢献と専門性向上のバランスを意識しながら、実践と理論の往復を大事にした取り組みを続けていければと思います。 そして、同様の課題意識を持つ方々にとって、少しでも参考になる内容であれば幸いです。
アバター
はじめに Biomeとは 導入方法 使い方 lint format check 設定ファイル 複数の設定ファイル extends vcs まとめ はじめに こんにちは、TIMELINE 開発部 Service Development をしている hond です! 普段からLinterやFormatterにはとてもお世話になっているのですが、いざ導入するとなると細かい設定などめんどくさいな、と友人に相談したらほぼ設定いらずかつ爆速な Biome というツールを教えてもらったので触ってみた感想について紹介しようと思います! Biomeとは Web開発のためのたった1つのツールチェーン フォーマット、リントなどが一瞬で完了します! Prettierのようにコードをフォーマット、しかも高速 Biomeは JavaScript、TypeScript、JSX、JSON、CSS そして GraphQL のための高速なフォーマッタ であり、Prettier と97%の互換性を持ち、CIと開発者の時間を節約します。 問題を修正し、ベストプラクティスを学ぶ Biomeは JavaScript、TypeScript、JSX、CSS そして GraphQL のための高性能なリンタ であり、ESLint、typescript-eslint、その他のソースに由来する 200以上のルール を備えています。 https://biomejs.dev/ja/ Biomeについて公式サイトのトップでは上記のようにformatやlintが一瞬で完了することや豊富なルールが紹介されています。 新規導入の際には会社の秘伝のタレ化したものやネットのベストプラクティスを紐解いて対象のルールの比較検討を行っていましたが、Biomeを用いると推奨のルールが設定されているためこれらの作業を省くことができます! 新規導入だけでなく既存でeslint,prettierの設定がある場合は後述するコマンドを用いることでそれらの設定を引き継いだ上で一瞬で移行することが可能です。 Biomeのサポートされている言語一覧は下記になります。 ✅: 対応済み 🚫: 進行中ではない ⌛️: 進行中 ⚠️: 一部サポート(いくつかの注意点あり) Language Parsing Formatting Linting JavaScript ✅ ✅ ✅ TypeScript ✅ ✅ ✅ JSX ✅ ✅ ✅ TSX ✅ ✅ ✅ JSON ✅ ✅ ✅ JSONC ✅ ✅ ✅ HTML ⌛️ ⌛️ 🚫 Vue ⚠️ ⚠️ ⚠️ Svelte ⚠️ ⚠️ ⚠️ Astro ⚠️ ⚠️ ⚠️ CSS ✅️ ✅️ ✅️ YAML ⌛️ 🚫 🚫 GraphQL ✅️ ✅️ ✅️ Markdown ⌛️ 🚫 🚫 https://biomejs.dev/ja/internals/language-support/ 導入方法 インストールに関してはNode.js v14.18以降の環境で下記コマンドを実行することで完了します。 yarn add --dev --exact @biomejs/biome インストールに関しては先ほどのコマンドのみで可能ですが、プロジェクトごとの設定を行うために下記コマンドを実行して設定ファイルを作成します。 yarn biome init 作成された設定ファイルはこちらになります { " $schema ": " https://biomejs.dev/schemas/1.9.4/schema.json ", " vcs ": { " enabled ": false , " clientKind ": " git ", " useIgnoreFile ": false } , " files ": { " ignoreUnknown ": false , " ignore ": [] } , " formatter ": { " enabled ": true , " indentStyle ": " tab " } , " organizeImports ": { " enabled ": true } , " linter ": { " enabled ": true , " rules ": { " recommended ": true } } , " javascript ": { " formatter ": { " quoteStyle ": " double " } } } 新規の導入に関してはこれにて完了です。既にeslintやprettierが導入されている場合は下記のコマンドを実行することで移行ができます。 .eslintrc.json , .eslintignore や .prettierrc.js を参照して移行してくれます! Flat Config対応された設定ファイルも移行可能です。 biome migrate eslint --write biome migrate prettier --write 手動で設定する際にはESLintとBiomeのルール名は命名規則が異なるので こちら のページを参考に行う必要があります。 また、 CI やLeftHook、pre-commitをはじめとした Git Hooks への導入の仕方も公式サイトでは紹介されています。 使い方 Biomeには既に紹介した init と migrate を含め14個のコマンドが存在しますがここではよく使用する lint , format , check について説明します。 lint biome lint ${destination} Linterを実行するコマンドになります。デフォルトではBiomeの推奨のルールが実行されます。推奨のルールは こちら のサイトから確認することができます。執筆時点では約100のルールが推奨ルールとして設定されていました。 コードのセマンティクスを変更しないことが保証されているものをレビューなしに適応する --write やセマンティクスを変更する可能性がある変更を手動でレビューする --write --unsafe 、 --write のaliasとして --fix がオプションとしてあります。 ESLintの修正で --fix を使っていたので使い慣れたオプションがそのまま準備されているのは個人的に嬉しいポイントでした。 format biome format ${destination} Formatterを実行するコマンドになります。前述した通りPrettierからの移行が可能ですがあくまでPrettierと近い哲学をもつBiome独自のFormatterになります。デフォルトでは indentStyleSection 、 indentWidthSection 、 lineEndingSection 、 lineWidth が設定されています。 indentStyleSection はインデントのスタイルを tab 、 indentWidthSection はインデントサイズを 2 、 lineEndingSection は改行を \n 、 lineWidthSection は一行当たりの最大文字数を 80 にそれぞれ設定しています。 format は lint 同様に --write などのオプションがあります。 check biome check ${destination} FormatterとLinterに加えimportのソートを行うコマンドになります。importは自然順にソートされます。 設定ファイル Biomeはオプショナルの機能として設定ファイルを提供しています。ここではおすすめの設定についてピックアップして説明していきます。 複数の設定ファイル Biomeは複数の設定ファイルを作成することが可能です。複数の設定ファイルがある場合実行時に作業ディレクトリから最も近い設定ファイルが参照されます。 そのため、モノレポなどでバックエンドとフロントエンドそれぞれに設定ファイルを作成することで独立した設定をすることが可能です。 extends 次に設定の共有についてです。 extends を用いることで extends リストに含まれるファイルかのオプションを適用することが可能です。 例えば下記のように実装することで biome.json でも biome.base.json でlinterを有効化し推奨設定をしているオプション適用できます。 biome.base.json { "linter": { "enabled": true, "rules": { "recommended": true } } } biome.json { "extends": ["./biome.base.json"] } vcs VCS(バージョン管理システム)に関する設定です。 下記の例ではVCSをgitとして有効化し、変更ファイルの評価元としてプロジェクトの作業ブランチ( develop )を指定しています。このように設定することで develop から生やした作業ブランチでの差分をBiome実行時の対象とすることが可能です。 { " vcs ": { " enabled ": false , " clientKind ": " git ", " defaultBranch ": " develop " } } まとめ 新規導入だけでなく既にESLintやPrettierが導入されている場合でもコマンドを用いて簡単に移行できる点が、重い腰を上げる後押しをしてくれるのでとても良いなと感じました。 実行速度に関しても紹介されている通り高速で弊社のプロダクトに導入してみたところ下記図のようにlintに関しては既存のESLintと比較して約20倍、formatに関しては既存のPrettierの約10倍のスピードが出ました! 既存のプロダクトではパフォーマンスの観点で、新規プロダクトではスピード感を求める中で細かい設定を行わないで済むという点で十分導入を検討する価値のあるツールだと感じました。 name before after Lint 6.37s 0.34s Format 4.56s 0.44s
アバター
目次 はじめに 管理画面について 複雑なリレーションをロードする ユースケースと実装例 詰まったポイント 1. 多方向のリレーションのロード 2. 特定の外部キーに一致するレコードのリレーションのロード 複雑なリレーションのロードをテストする おわりに はじめに こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司( ktanonymous )です。 みなさんは、Go の ORM ライブラリを使っていますか?どのようなライブラリを使っていますか? 弊社では、既存プロジェクト内での移行の容易さに惹かれ、 最近は sqlboiler という、"データベースファースト"な ORM ライブラリを使うプロジェクトが増えてきています。 プロジェクトは異なりますが、過去に sqlboiler を選定した際の記事も書いていますので、興味があれば是非ご覧ください。 現在、筆者はLP制作から応募データのETL、クライアントへのデータ送付までにまつわる処理群を非エンジニアの運用担当者が管理画面から自由に更新できるようにするための社内システムの開発を進めています。 今回の記事では、その中で複雑なリレーションに対して sqlboiler を有効に活用するために苦労した点について紹介したいと思います。 なお、本記事では2025年3月25日時点での情報を元に記述しています。 また、Go のバージョンは 1.24.0 、 sqlboiler のバージョンは v4.18.0 を使用しています。 管理画面について 最初に、現在進行している社内システム開発の背景についてお話ししたいと思います。 ちなみに、LPの制作に関しては、以前の記事でも紹介していますので是非見てみてください。 以前の記事でも説明をしていますが、弊社では、クライアントごとにカスタマイズされたLPを実装して公開しています。 また、LPから送信される案件に対する応募データのETLやクライアントへの送付フォーマットもクライアントごとに異なります。 そのため、都度クライアント-営業・運用チーム-エンジニアチーム間で擦り合わせの必要があります。 LP周辺の開発フロー概要 このように、1つのLPを制作するために何度もコミュニケーションが発生し、実動工数以上に時間を要してしまうという課題がありました。 そこで、クライアントごとのLPの制作や応募データのETL、クライアントへのデータ送付を非エンジニアの運用担当者が管理画面から自由に更新できるようにするプロジェクトを進めることとなりました。 複雑なリレーションをロードする sqlboiler では、外部キーに基づいて、リレーションを持つレコードに対して eager ローディングが可能となっています。 これによりパフォーマンス観点での恩恵を受けられます。 実装の記述も楽になるので積極的に使っていましたが、何度か使用感で詰まってしまったポイントを紹介したいと思います。 先に詰まったポイントを話してしまうと状況が見えにくくなってしまうので、 少々読みづらいですが、まずユースケースと実装例を紹介して、その後にどんなポイントで詰まったかを説明します。 ユースケースと実装例 初めに、以下のようなシンプルなテーブル構造を考えます。 シンプルなテーブル構造 この時、以下のような実装でユーザーのポストを取得することができます。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load(models.UserRels.Posts)) user, err := models.Users(mods...).One(ctx, db) これで、特定のユーザーに紐づく全てのポストを取得することができます。 次に、以下のような少し複雑なテーブル構造の場合を考えます。 複雑なテーブル構造 この時、ユーザーに紐づく情報を全て取得しようとすると、以下のような実装になります。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Likes, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Likes, ))) user, err := models.Users(mods...).One(ctx, db) これにより、特定のユーザーに紐づく全ての投稿/コメント/いいね、それらのそれぞれに紐づく全てのリレーションが取得できます。 先ほどと同じテーブル構造で、特定のユーザーの特定のポストに紐づく全ての情報を取得するケースを考えます。 この時、以下のような実装をすることになります。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load( qm.Rels(models.UserRels.Posts), models.PostWhere.ID.EQ(postID), )) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Likes, ))) user, err := models.Users(mods...).One(ctx, db) こうすることで特定のユーザー、かつ、特定のポストに紐づく全ての情報を取得することができます。 詰まったポイント sqlboiler を使っていて、上記のような実装をしている中で、筆者は以下のようなポイントで詰まってしまいました。 多方向のリレーションのロード 特定の外部キーに一致するレコードのリレーションのロード それぞれについて、詰まったポイントを説明します。 1. 多方向のリレーションのロード ここでいう「多方向のリレーションのロード」とは、「ユーザーに紐づくコメント/いいねを取得する」というようなケースを指します。 つまり、 users -> posts -> comments / users -> posts -> likes のようにリレーション先が分岐する場合を指します。 (「1方向」は users -> posts -> comments のように、外部キーを辿ることで再帰的にリレーションを参照できる場合を指します) これを実現するために、初めに以下のような実装を試みました。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, models.PostRels.Likes, ))) user, err := models.Users(mods...).One(ctx, db) 公式の GitHub リポジトリの Readme には、 Eager loading can be combined with other query mods, and it can also eager load recursively. と言及されています。 しかし、上記の実装では、ユーザーに紐づくポストからコメントといいねを並列で取得することが意図されています。 そのため、sqlboiler の想定される挙動と異なるため、正しくリレーションをロードすることができませんでした。 正しくリレーションをロードするためには、複雑なテーブル構造の例のように、以下のように実装する必要があります。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Likes, ))) user, err := models.Users(mods...).One(ctx, db) このように、リレーション先が分岐する場合は、1方向ずつ個別にロードの指定をする必要があります。 この時、具体的には以下のようなクエリが発行されます。 # `boil.DebugMode = true` でクエリログを出力 SELECT `users` .* FROM `users` WHERE ( `users`.`id` = ? ) ; [ 1 ] SELECT * FROM `posts` WHERE ( `posts`.`user_id` IN ( ? )) ; [ 1 ] SELECT * FROM `comments` WHERE ( `comments`.`post_id` IN ( ? )) ; [ 1 ] SELECT * FROM `likes` WHERE ( `likes`.`post_id` IN ( ? )) ; [ 1 ] 意図している取得ができない理由 sqlboiler では QueryMod というインターフェースでクエリの変更を管理します。 リレーションのロードでは loadQueryMod という構造体を利用してリレーションを管理しています。 // https://github.com/volatiletech/sqlboiler/blob/v4.18.0/queries/qm/query_mods.go#L10-L13 // QueryMod modifies a query object. type QueryMod interface { Apply(q *queries.Query) } // https://github.com/volatiletech/sqlboiler/blob/v4.18.0/queries/qm/query_mods.go#L63-L66 type loadQueryMod struct { relationship string mods []QueryMod } Load メソッドでは、ロードしたいリレーションを []string{"Relationship", "Relationship.NestedRelationship"} という形式の文字列で指定し、 1つの loadQueryMod 構造体の中で1連のリレーションを持ちます。 (e.g. Users.Posts.Comments ) リレーションを表現する文字列の . を起点として再帰的に外部キーカラムを辿ることでリレーションをロードしていきます。 そのため、1つの Load メソッドで並列したリレーションを指定してしまうと正しくリレーションを辿れなくなってしまうので、 リレーション先が分岐する場合は、1方向ずつ個別にロードの指定をする必要があります。 // https://github.com/volatiletech/sqlboiler/blob/master/queries/eager_load.go#L46-L68 // eagerLoad loads all of the model's relationships // // toLoad should look like: // []string{"Relationship", "Relationship.NestedRelationship"} ... etc // obj should be one of: // *[]*struct or *struct // bkind should reflect what kind of thing it is above func eagerLoad(ctx context.Context, exec boil.Executor, toLoad [] string , mods map [ string ]Applicator, obj interface {}, bkind bindKind) error { state := loadRelationshipState{ ctx: ctx, // defiant to the end, I know this is frowned upon exec: exec, loaded: map [ string ] struct {}{}, mods: mods, } for _, toLoad := range toLoad { state.toLoad = strings.Split(toLoad, "." ) if err := state.loadRelationships( 0 , obj, bkind); err != nil { return err } } return nil } 2. 特定の外部キーに一致するレコードのリレーションのロード ここでは、ユーザーAのあるポストPに紐づく全ての情報を抱き合わせてユーザーAの情報を取得するケースを考えます。 筆者の最初の実装は以下のようになっていました。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.InnerJoin( "posts ON posts.user_id = users.id" )) mods = append (mods, models.PostWhere.ID.EQ(postID)) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Likes, ))) // その他のリレーションのロード user, err := models.Users(mods...).One(ctx, db) この実装をした時、筆者は「ジョインしてIDを指定しているからリレーションもその条件を見てくれるはず!」と思っていました。 しかし、実際には、リレーションをロードする際に改めてクエリが発行されるので、メインのクエリで指定する条件は反映されません。 そのため、正しくは以下のように実装する必要があります。 var mods []qm.QueryMod mods = append (mods, models.UserWhere.ID.EQ(userID)) mods = append (mods, qm.Load( qm.Rels(models.UserRels.Posts), models.PostWhere.ID.EQ(postID), )) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Comments, ))) mods = append (mods, qm.Load(qm.Rels( models.UserRels.Posts, models.PostRels.Likes, ))) // その他のリレーションのロード user, err := models.Users(mods...).One(ctx, db) このように、リレーションをロードする際に、リレーション先の条件を指定する必要があります。 ちなみに、ドキュメントには以下のような記述があります。 // the query mods passed in below only affect the query for Toys // to use query mods against Pets itself, you must declare it separately この言及通り、同一のリレーション先に対するクエリは重複実行されないため、 先の実装の models.PostRels.Comments や models.PostRels.Likes のロード時、 comment.post_id / like.post_id の条件は自動的に models.PostWhere.ID.EQ(postID) で指定した条件が適用されます。 というよりは、先にロードしているレコードが絞り込まれているため、除外されたレコードのリレーションのロードは発生しないことになります。 実際に、以下のようなクエリが発行されていることからも確認できます。 # `boil.DebugMode = true` でクエリログを出力 SELECT `users` .* FROM `users` WHERE ( `users`.`id` = ? ) ; [ 1 ] SELECT * FROM `posts` WHERE ( `posts`.`user_id` IN ( ? )) AND ( `posts`.`id` = ? ) ; [ 1 ] SELECT * FROM `comments` WHERE ( `comments`.`post_id` IN ( ? )) ; [ 1 ] SELECT * FROM `likes` WHERE ( `likes`.`post_id` IN ( ? )) ; [ 1 ] このように、sqlboiler を使って複雑なリレーションをロードする際には、 リレーション先が分岐するケースやリレーション先の条件を指定したいケースに注意する必要があることを学びました。 複雑なリレーションのロードをテストする DB アクセスしてデータを取得する実装する時、当然テストも書くかと思います。 その際、テストデータを定義して実際にテスト用の DB にデータをインサートする必要があります。 今回、実際にテストデータを定義している際、以下のようなポイントで詰まってしまいました。 テストデータのリレーションの定義 テストデータのインサート時のカラム指定 1. テストデータのリレーションの定義 については、テストデータのリレーションを定義する方法の理解が不十分で意図通りのリレーションを定義できなかった話になります。 sqlboiler がスキーマを Go の構造体にマッピングして生成されるモデル定義では、 <モデル>.R.<リレーション> というように、 R というフィールドを介してリレーションが定義されます。 このフィールドは、型がプライベートに定義されていて直接初期化することはできませんが、 NewStruct というメソッドから初期化することができます。 例えば、以下のようにリレーションを定義することができます。 // 例: ユーザーに紐づく投稿のテストデータの作成 var user = models.User user.R.= user.R.NewStruct() user.R.Posts[ 0 ] = &models.Post{ // ポストの情報を定義 } また、テストデータのリレーションを定義する方法として、 公式が推奨している boilingfactory というパッケージを利用する方法があります。 このパッケージを利用することで、以下のような実装で、リレーションを同時に持たせながらテストデータを初期化することができます。 (詳細な説明は割愛しますが、sqlboiler と連携させることで factories というパッケージが生成されます) user, err := factories.createUser( // ユーザーの情報を定義 factories.UserWithPosts( // ポストの情報を定義 factories.PostWithComments( // コメントの情報を定義 ), factories.PostWithLikes( // いいねの情報を定義 ), ), ) 筆者は初めから boilingfactory を使ってテストデータを定義していたため、いざテストデータを定義する際、 リレーション定義の仕組みをあまり理解できておらず、テストが通らずに悩んでしまっていました。 ただし、 boilingfactory 自体の更新が3年前から止まっていることや、 テストデータ定義の際にエラーハンドリングが必要になることなど、それぞれのやり方にメリット・デメリットがあるため、 ユースケースに応じた使い分けを考える必要はあるかと思います。 2. テストデータのインサート時のカラム指定 については、テストデータをインサートする際に、カラムを指定する方法に関する話になります。 ( sqlboiler の運用で作成するテンプレートファイルの作り次第でもあるので一概には言えないことをご容赦ください) テストデータを DB にインサートする際、 models.User などのモデルを使って以下のようにインサートすることができます。 user := models.User{ Name: "test" , } err := user.Insert(ctx, db, boil.Infer()) この時、 Insert メソッドの第3引数に指定しているもの ( boil.Infer() )がインサートするカラムを指定するためのものになります。 詳細に関しては sqlboiler で生成されたコードを見るとわかりますが、カラム名の指定では以下のようなメソッドが利用できます。 - boil.Infer() : 非ゼロ値のデフォルト値を持つフィールド以外をインサート - boil.Whitelist("name") : 指定したカラムのみインサート - boil.Blacklist("name") : 指定したカラム以外をインサート 実装時、 boil.Infer() でインサートする場面が多かったため、テストデータが想定とは異なっているケースがありました。 具体的には、nullable なカラムにデフォルト値が設定されている場合やデフォルト値が false でゼロ値として認識されてしまう場合などに、 期待されるテスト結果と実際のテスト結果が異なる状況になってしまっていました。 そのため、テストデータをインサートする際には、 boil.Whitelist() や boil.Blacklist() を使って、 明示的にインサートするカラムを指定することも重要な時があると感じました。 (なお、この話はインサート処理に限らず、 sqlboiler のメソッドを使う場面ではどこでも発生し得る問題です) おわりに 今回の記事では、現在進行中の社内システム開発において、複雑なリレーションを扱うために sqlboiler を有効活用しようとして苦労した点について紹介しました。 社内システム開発はまだ途中ですが、しっかりとやり切れるように頑張りたいと思います。 また、今回の記事が、少しでも皆さんのお役に立てれば幸いです。 最後まで読んでいただき、ありがとうございました。
アバター
iOSのウィジェットは、iOSのアップデートに伴い配置場所と機能が拡充されてきました。ウィジェットを開発する上で適切な技術を選択するための情報として、その変遷と各OSバージョンにおいて利用可能な機能を整理しました。 レガシーなウィジェット TodayExtension (iOS 8〜iOS 17) 初期のウィジェットは、ホーム画面ではなく、通知センター(ホーム画面を右にスワイプして表示される画面)に配置されていました。制約が比較的少なく、アプリに依存せずウィジェット内で機能を完結させることもできました。 iOS 14でWidgetKit が導入された後も引き続きサポートされていましたが、iOS 18でサポートが終了しました。そのため、アプリにToday Extensionが含まれていても、iOS 18以降では利用できません。 ホーム画面ウィジェット(iOS 14〜) iOS 14で WidgetKit が提供され、ホーム画面に配置するウィジェットを作れるようになりました。 ウィジェットのUIはSwiftUIで作成します。 WidgetKitはウィジェットの管理と表示更新の仕組みを提供します。 表示更新の仕組み ウィジェットは、システムリソースの過剰消費やバッテリーの消耗を抑制し、デバイスのパフォーマンスを維持するため、いくつかの制約が設けられています。 ウィジェットに表示するデータの取得と表示内容の決定は、メインアプリケーションまたは専用のバックグラウンドプロセスで実行する必要があります。時刻に応じてウィジェットの表示を切り替える必要がある場合は、事前に更新日時と表示内容を計画したタイムラインを作成します。 タイムラインは、 TimelineProvider によって生成され、 TimelineEntry オブジェクトの配列と TimelineReloadPolicy で構成されます。各 TimelineEntry は、 WidgetKit がウィジェットの表示を更新する date を指定し、表示に必要な追加データを含めることができます。これにより、ウィジェットをいつ、どのようなデータで更新するかを事前に計画します。 タイムラインの更新頻度はOSによって管理されており、開発者が直接制御することはできません。アプリから reloadTimelines() を使用して更新を要求できますが、即時実行は保証されません。更新頻度は、ユーザーのアクティビティ、バッテリー残量、ネットワーク接続などの要因によって変動します。 ウィジェットは静的なスナップショットとして表示されるため、継続的な更新には適していません。定期的に変化する情報の表示に適しており、リアルタイム更新が求められる場合はライブアクティビティの利用が推奨されます。 本体アプリとの連携 ウィジェットと本体アプリの間でデータを共有するためには App Groups を利用します。App Groups を作成し、ウィジェットと本体アプリの両方で共通の App Groups を有効にすることによって、User Defaults, File Manager, Core Data, Swift Dataでデータを共有できます。 UserDefaults UserDefaults は、ユーザー設定や小規模なデータセットの保存に適しています。 データは自動的にシリアライズされ、同期的にアクセスされます。そのため、UserDefaults はアプリケーションとそのウィジェット間で基本的な構成や状態情報を共有するのに便利です。 ただし、大規模なデータを扱うとパフォーマンス上の問題が生じる可能性があるため、そのような場合には CoreData や SwiftData の利用が推奨されます。また、機密性の高いデータには Keychain が適しています。 CoreDataとSwiftData より複雑なデータモデルと大規模なデータセットを扱う場合、 CoreData または SwiftData を使用して永続データを共有できます。 SwiftDataは、CoreDataよりも簡潔なコードで記述でき、モデルのマイグレーションも容易かつ安全に行えるため、iOS 17以降をターゲットとするのであれば SwiftData を使うのが良さそうです。 ロック画面ウィジェット(iOS 16〜) iOS 16では、ロック画面にウィジェットを配置できるようになり、これはホーム画面のウィジェットと同様の実装に基づいていますが、ロック画面特有の制約も存在します。 ロック画面のウィジェットは、基本的にモノクロ表示に限定されています。また、ウィジェットのサイズは accessoryInline (1行テキスト)、 accessoryCircular (円形)、 accessoryRectangular (長方形)の3種類がありますがいずれもホーム画面ウィジェットよりも領域が狭いです。これらの表示上の制限に合わせて情報の取捨選択やレイアウトを工夫する必要があります。 さらに、プライバシーに関する考慮が必要です。ロック画面はロック中でも情報が表示されるため、個人情報や機密性の高い情報を表示する場合は、ユーザーが設定で表示/非表示を切り替えられるようにするなど、適切な対応が求められます。 ライブアクティビティ(iOS 16.1〜) ウィジェットは静的あるいは定期的な情報更新に適しているのに対し、ライブアクティビティはリアルタイムかつ頻繁な情報更新に強みがあります。 ライブアクティビティは最大8時間のアクティブ表示が可能で、その後最長12時間ロック画面に残ります。この特性から、短中期的なイベントやタスクの追跡に最適です。一方、ウィジェットには表示時間の制約はありません。 両者はWidgetKitを基盤とし、UIはSwiftUIで構築されていますが、リアルタイム性やライフサイクル管理において異なる技術を採用しています。 ウィジェットはタイムラインプロバイダを通じて定期的にデータを更新します。対照的に、ライブアクティビティはActivityKitという専用フレームワークを使用し、アプリからリアルタイムに状態を更新します。バックグラウンドタスクの状態反映には、バックグラウンド動作中のアプリによる状態更新が必要です。 ライブアクティビティはiOSの複数の箇所に表示され、ロック画面ではバナー形式で詳細情報を提供します。iPhone 14 Pro以降ではDynamic Islandにも対応し、コンパクト、最小、拡張の3つの表示形式をサポートします。Dynamic Island非対応デバイスでは、更新時にロック画面上部に一時的なバナーが表示されます。 インタラクティブなウィジェット(iOS 17〜) インタラクティブウィジェットの導入により、ユーザーはアプリを開くことなく、ホーム画面ウィジェットとロック画面ウィジェット上で直接簡単な操作を実行できるようになりました。 利用可能なインタラクティブなUI要素は、 Button と Toggle の2つに限定されています。 このインタラクティブウィジェットを実現する上で重要なのが、 App Intents フレームワークです。 App Intents を活用することで、アプリがフォアグラウンドで動作していない状態でも、システムが実行可能なアクションを定義できます。 ユーザーがウィジェット内のボタンやトグルを操作すると、システムは対応する App Intent を起動します。App Intent の perform() 関数が実行され、アプリのデータモデルの更新といった必要なアクションが実行されます。 perform() 関数の実行完了後、システムは自動的にウィジェットのタイムラインを更新し、変更内容をウィジェットに反映させます。重要な点として、ウィジェット自体は状態を持たず、App Intent実行後の変更されたデータモデルに基づいてタイムラインが更新されることによってウィジェット表示内容が変化します。 ユーザー操作後、 App Intent の実行とそれに続くタイムラインの再読み込みにより、ウィジェットのUI更新にはわずかな遅延が生じる可能性があります。 コントロールウィジェット(iOS 18〜) iOS 18 で新たに導入されたコントロールウィジェットは、コントロールセンターやロック画面下部のウィジェット領域から、アプリケーションの機能を直接操作できるようにするものです。 従来のウィジェットがアプリ情報の視覚的な提供を主な目的とするのに対し、コントロールウィジェットは、シンプルで即座に実行できるタスクへのショートカットとしての役割に特化しています。 ボタンは瞬時の単一アクションに、トグルは明確なオン/オフを伴うアクションにそれぞれ用いられ、いずれもアクションの実行にはApp Intentsが活用されます。 コントロールウィジェットは、他のウィジェットと同様にWidgetKitフレームワークを基盤として構築されています。 おわりに 今回の記事ではiOSのウィジェットについて簡単にまとめました。参考になれば幸いです。
アバター
はじめに こんにちは。去年12月に入社したリテールハブ開発部エンジニアの清水です。 エブリーでは事業譲渡という形で他社が開発した小売店様向けシステムを引き継いで運用を行なっており、私は入社してからこちらのシステムの保守運用を担当しております。 このシステムはAWS上で稼働しており、数年間運用されているのですが、最近セキュリティ設定を変更して許可されていた通信をブロックする対応が必要となりました。 今回設定変更を行う通信は金銭に関わる処理が行われているため、必要な通信をブロックしてしまった場合大変なことになります。 このような状況ですので、どうすれば絶対に問題ないことを確認できるのか?ということについて少し悩みました。 今回はVPCフローログを使用することで通信ログを取得し、どのような通信が行われているのかを確認する流れを紹介いたします。 今回発生したセキュリティ設定を変更するタスクについて 私が担当しているシステムでは以下のように専用線を使用して小売店様で管理しているネットワークと接続しています。 小売店様のネットワークには様々なサーバーが存在しておりますが、私たちが運用しているシステムはXXX.XXX.XXX.0/24との通信しか行っていません。 しかしながら、最近小売店のご担当者様がネットワーク設定を確認したところ、XXX.XXX.XXX.0/24以外に対する通信が行われた場合でもブロックされない設定になっていることがわかりました。 小売店様側のネットワークでXXX.XXX.XXX.0/24以外に対する通信はブロックする設定を追加する必要があるため、閉じてしまって問題ないことをエブリー側で確認しなければなりません。 どうやって確認するか まず最初に確認したこととして、事業譲渡の際に引き継がれたネットワーク仕様書を確認しました。ネットワーク仕様書を見る限りではXXX.XXX.XXX.0/24以外に対する通信が行われていないことが確認できます。 しかしながら、今回設定変更する通信は金銭に関わるものが含まれており、もしもの話として「実はブロックしてはいけないものでした」という事態になった場合は大変なことになってしまいます。 ネットワーク仕様書の内容に抜けがある可能性もありますし、絶対に問題ないことを確認できなければ安心できません。 そこで、今回はVPCフローログを使用することで一定期間の通信ログを取得し、どのような通信が行われているのかを確認することにしました。 VPCフローログとは https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/flow-logs.html VPCフローログは、AWSのネットワーク環境におけるトラフィックの詳細なログを取得できる機能です。これにより、VPC内の通信状況を把握し、ネットワークのトラブルシューティングやセキュリティ監視を効率的に行うことができます。 VPC全体、特定のサブネット、もしくは個々のネットワークデバイス(ENI: Elastic Network Interface)に対して設定することが可能です。 取得したログは、出力先としてAmazon S3、CloudWatch Logs、Kinesis Data Firehoseに送信することができます。 今回はサーバーが稼働しているサブネットに対して取得する設定を行い、最も低いコストで済ませたいためAmazon S3にログを保存することにしました。 VPCフローログの設定方法 VPCサブネットに「フローログ」というタブがあるので、そこから「フローログを作成」を押下することで作成画面に遷移します 出力先となるAmazon S3バケット、ログレコード形式などの設定を行って最下部の「フローログを作成」を押下することでフローログが作成されます VPCフローログで出力される内容 Amazon S3バケットに以下のように.gz形式でファイルが出力されます。 ログの内容を確認する 様々な分析を行う場合Athenaが適していると思いますが、今回は簡易な作業で済むため、ローカルにログファイルをダウンロードして確認する形で進めました。 以下のように送信元IPアドレス(srcaddr)と送信先IPアドレス(dstaddr)が記録されています。 この中に小売店様のネットワーク(xxx.xxx.0.0/16)に含まれているものの想定したネットワーク(xxx.xxx.xxx.0/24)以外への通信がないことを確認できれば、想定外の通信は発生していないと判断することができます。 version account-id interface-id srcaddr dstaddr srcport dstport protocol packets bytes start end action log-status 2 999999999999 eni-XXXXXXXXXXX yyy.yyy.yyy.yyy xxx.xxx.xxx.1 YYYYY XXXXX 6 17 1479 1737356974 1737357004 ACCEPT OK 各フィールドにどのような値が入っているかはこちらをご覧ください。 https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/flow-log-records.html#flow-logs-fields 料金について VPCフローログ自体には料金は発生しませんが、出力先であるAmazon S3に対する料金が発生します。 目安として、本サービスはMAU10万人前後であり、約24時間で合計45.2MBのファイルが出力されることがわかりました。 サービスによって通信量は大きく変わるものなので一概に問題ないと言うことはできませんが、コスト面についてはそこまで心配する必要はない手段だと感じました。 最後に このような許可されていた通信をブロックしなければならないという不安になるような経験は数年に1回あるかないかだと思いますが、多くのエンジニアが一生のうち一度くらいは経験するものではないでしょうか。 VPCフローログを使用することで手軽に通信内容を確認することができますので、似たような状況になったときに本記事がお役に立てば幸いです。
アバター
はじめに こんにちは、デリッシュキッチンでクライアントエンジニアを担当している kikuchi です。 近年は Web のサービスに限らず、アプリでもネットワーク接続を実施することが当たり前になってきていますが、皆さんはネットワーク接続をするアプリでは必須となる タイムスタンプ について実装方法や管理方法を意識されたことはあるでしょうか? タイムスタンプを使用するケースは多く、例えば ログイン情報を保存する機能で、ログインを実施した日時を管理する ワンタイムパスワードを発行する機能で、発行された日時や有効期限を管理する スケジュールを管理する機能で、スケジュールが実行される日時を管理する (プログラムのロジックで) 一定時間経過後に初めてポップアップを出す場合に、基準となる時間を管理する のように多種多様な使われ方をしており、おそらく 1 つもプログラム上でタイムスタンプを使用していないアプリやサービスは無いかと思っています。 それほど当たり前のように使われているタイムスタンプですが、管理方法や発行手順を間違えると重大なバグに繋がることがあるため、今回は タイムスタンプをどう正しく管理すべきか という観点で 運用方法についてまとめてみたいと思います。 なぜタイムスタンプを正しく管理する必要があるのか タイムスタンプが正しく運用されていない場合の問題点を考えてみたいと思います。 まずはセキュリティ (不正利用防止) の観点。 例えば毎日ログインをする度にインセンティブを付与するようなアプリで、端末の時間だけでタイムスタンプを管理していた場合、端末の設定を 1 日後にずらしてアプリを再起動を繰り返すだけで インセンティブを簡単に取得できてしまいます。 もし金銭に関わるもの (ポイントなど) を付与していた場合、ポイントを取得して交換、そしてアプリを再インストールするといういわゆるリセマラをされると、企業としては大幅な損失に繋がる可能性が出てしまいます。 そして次に整合性の観点。 行動ログを管理 (分析) する機能にて、アプリが端末の時間で生成したタイムスタンプを正として保存する仕組みとなっており、 行動 A : 現実世界の 10:00 に実施 / 端末の時間も 10:00 行動 B : 現実世界の 11:00 に実施 / 端末の時間も 11:00 行動 C : 現実世界の 12:00 に実施 / 端末の時間が不具合で 10:30 となっていた という操作が行われた場合、期待としては A → B → C という順で保存されていてほしいものの、実際には A → C → B と保存されており、ログの整合性が担保されなくなります。 最後に正確性の観点。 データをタイムスタンプとともに記録するようなアプリで、タイムゾーンを考慮しておらずアプリは画面によって管理が異なる、サーバは協定世界時 (UTC) で管理していた場合、サーバで UTC 12:00 で管理されたデータを取得すると 画面 A (端末の設定に依存、今回は例としてアメリカ中部標準時の CST に変換) : 6:00 と表示 画面 B (日本標準時の JST に変換) : 21:00 として表示 となり画面によって表示がズレてしまい、正確性が担保されなくなります。 上記では表示するデータという比較的気づきやすい例を書きましたが、内部ロジックの場合、常に同じ人員・開発環境で開発を行っていると気づかずに予期せぬ不具合に繋がることがあります。 複数例を挙げましたが、他にもタイムスタンプを正しく運用しないことで発生する問題は多数存在するため、如何に正確に改善されることなく運用できるかがアプリやサービスの質や信頼性に繋がるものだと考えています。 タイムスタンプの管理方法 設計段階でやることとしては、 扱う時刻のタイムゾーンは何にするか を決めることが重要かと思います。 先述したタイムゾーンを考慮していない問題については、設計段階で明確に取り決めが行われなかった (サーバエンジニア、アプリエンジニアで認識がずれていた) 事が要因のため、 サービスとして取り扱うタイムスタンプのタイムゾーンは一貫してこれ、という取り決めが必要になります。 例えば サービスとして記録、及び通信データとしてやり取りする全てのタイムスタンプは UTC として取り扱う アプリ上で表示に使用する場合は端末で設定されたタイムゾーンに変換して取り扱う と決めておけば、先述の正確性の問題は回避できるようになります。 そして、サーバと通信を行うアプリ・サービスであれば サーバの時刻を正とする 事が重要かと思います。 複数のトランザクションを制御する際にアプリが生成した時刻を正として信じてしまうと、先述した通り不正利用、整合性の欠如に繋がるため、時刻を管理する機能は一箇所に集約する必要があります。 上記でサーバがデータを管理するアプリについては正しくタイムスタンプを管理できますが、サーバがメンテナンスなどで使用できず、アプリ単体でタイムスタンプを管理しなければならないケースではどうでしょうか。 次の章では Android アプリ単体で正確にタイムスタンプを管理する方法を考えてみたいと思います。 Android で正確にタイムスタンプを管理するには 端末の設定で日時の自動設定が ON になっていれば一定の正確性は担保できますが、やはり端末の設定を弄られる可能性があるため確実ではありません。 このようなケースでは TrustedTime API というものを使用すると問題を回避できます。 ◯公式情報 https://developers.google.com/android/reference/com/google/android/gms/time/TrustedTime https://android-developers.googleblog.com/2025/02/trustedtime-api-introducing-reliable-approach-to-time-keeping-for-apps.html こちらを使用するとネットワーク接続自体は必要なものの、Google が提供するインフラストラクチャにアクセスし、正確なタイムスタンプが取得できるようになります。 簡単ではありますが、図で表すとこの様になります。 毎回 Google が提供するインフラストラクチャにアクセスせず、TrustedTime のロジックで時間を算出することでネットワーク使用量を削減。 またデバイスの負荷状況などで正確な値を算出できなくなる場合は、TrustedTime API を通じてアプリ側に通知を出せる仕組みとなっています。 以降で TrustedTime API を使用してタイムスタンプを取得する実装方法をまとめてみたいと思います。 実装方法 1. app レベルの build.gradle にライブラリを追加 dependencies { implementation("com.google.android.gms:play-services-time:16.0.1") } 2. TrustedTime API を実行 fun execute(context: Context) { val trustedTimeClient = TrustedTime.createClient(context) trustedTimeClient.addOnCompleteListener { task -> if (task.isSuccessful) { val client = task.result client.computeCurrentUnixEpochMillis()?.also { timeMillis -> val date = Date(timeMillis) val format = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPAN) Log.i("TestLog", "time : ${format.format(date)}") } } else { Log.w("TestLog", "error : ${task.exception?.message}") } } } TrustedTime.createClient では TrustedTimeClient のインスタンスを提供する Task が取得できます。 Task により非同期処理が開始されるため、addOnCompleteListener で結果を受け取るリスナーを定義します。 後は task.isSuccessful (タスクが成功した場合) にタイムスタンプを取り扱うのみとなります。 上記の実装例では、computeCurrentUnixEpochMillis で Unix エポックからの経過時間をミリ秒で取得しており、日本時間に変換してログ出力しています。 実際に実行し、ログを出力した結果は以下のようになります。 TrustedTime API は端末起動後に一度だけネットワーク接続が必要なものの、以降はネットワーク接続が無い場合でも内部のロジックで正確なタイムスタンプを返却してくれます。 実際に端末を機内モードにしてネットワーク接続を OFF、かつ端末の時間を数日ずらした状態でも正確なタイムスタンプが返却されました。 実装は以上となるため、非常に簡単に正確なタイムスタンプを管理する方法を実装できました。 注意点 TrustedTime API は正確なタイムスタンプを取り扱う上で有効な手段ではありますが、いくつか注意点があります。 端末起動後に一度もネットワーク接続を行っていない場合はタイムスタンプを取得できない 端末・Google サーバ間の通信経路が完全に安全ではない Google のサーバが改ざんされている場合は不正なタイムスタンプが返却される 端末がルート化され、かつ TrustedTime API の動作が改変される可能性がある Android 5 (Lolipop) 以降のみで使用可能 などが挙げられます。 ただし、上記で挙げられるような問題点は TrustedTime API の話に限らず、アプリと自社のアプリサーバでやり取りするケース、別の API を使用するケースでも発生しうる問題のため サービスとしてどこまでの品質を担保するか、どこまでの問題を許容するかを検討したうえで適切な案を採用する形が良いかと考えます。 まとめ 今回はタイムスタンプを扱う上での注意点や実装案などをまとめてみましたが、意外と細かいタイムゾーンの認識ズレ、実装ミスが発覚するケースは私自身も過去何度か遭遇しているため 設計の段階から積極的なコミュニケーションは必要不可欠だと思いました。 またタイムスタンプを正確に取り扱う手法としては、TrustedTime API は実装・学習コストが低く、非常に有効かと感じました。 本記事の情報が皆様のお役に立てれば幸いです。
アバター
はじめに こんにちは、開発本部のデータ&AIチームの24新卒の蜜澤です。 エブリーに入社してからもうすぐ1年が経つので、この1年間を振り返りたいと思います。 文字ばかりのポエムですが最後まで読んでいただけると嬉しいです! 入社前 まず、入社する前の私の状況について触れたいと思います。 大学ではデータサイエンス学部に所属しており、主に統計学・機械学習について学んでいました。 いわゆるコンピューターサイエンスのようなことは学んだことはなく、Webやアプリの開発経験もなく、触れたことがある言語はR・Pythonの2つのみで、データサイエンティストにとって必須とも言えるSQLも触れたことがありませんでした。 大学院には行かずに学部卒でエブリーに入社しました。 インターン 大学4年生の8月に3週間ほどと2~3月に内定者インターンをしました。 前述の通りSQLは全く触れたことがなかったので、8月のインターンでキャッチアップし、基本的なクエリは書けるようになりました。 インターン中はデリッシュキッチンのデータ分析やレシピレコメンドを行いました。 入社後1~2週間 入社1週目は全体研修を受け、2週目は今年度から新設された エンジニア新卒研修 を受けました。 エンジニア新卒研修の1週間が間違いなく今年1番大変な1週間だったと思います(笑) この研修では、エンジニアとしてのマインド研修と、バックエンド、インフラ、モバイル、Web、データについて講義とハンズオンがある研修を受けました。 前述の通り、エンジニアとしての基礎素養すらない状態だったので、 バックエンドとインフラ何が違うの? EC2?何それ美味しいの? <>なんすかこれ?htmlタグ、、?知らんな〜 というような状況で、今思うとあまりにも無知すぎると思いますが、研修担当者はこんな状態の奴がいるなんて想定はしていないので、研修では EC2立ててみよう Webの画面作成してみよう 画面遷移できるようにしてみよう などの未知との遭遇のような研修内容で、泣きそうになりながら毎日研修を頑張りました! 研修後に同期に教えてもらいながら、なんとか研修の内容を消化していったのは今では良い思い出です! 4月後半~5月 エンジニア新卒研修を終えて、4月の後半からは部署での業務が始まりました。 この時期に取り組んだことは デリッシュキッチンのユーザーのデータ分析 Amazon QuickSightの検証 aws研修や外部研修といった短期の研修 の3つになります。 データ分析は、行ったこと自体はレシピのお気に入り追加に関する簡単な分析でしたが、データがどこにあるのかや、どのようにデータ分析を行うのかといった基本的なことを学べました。 Amazon QuickSight(以下quicksight)を使用して、レシピデータ分析の汎用BIツールの開発を行いました。quicksightは社内での使用実績がなかったので、一通り触ってみて、何ができて何ができないのかなどの検証から始めました。 社内で使用したことがある人が誰もいなかったので、自分が社内で1番quicksightに詳しくなってやるという気持ちで毎日quicksightと格闘しました。 1ヶ月ほどquicksightの検証をしていたら、だんだんと慣れてきて、色々なことができるようになりました。 tech.every.tv tech.every.tv 6~8月 徐々に裁量が大きくなってきたなと思う時期です。 要件を満たすようなビジュアルをquicksightで作成する 必要な中間テーブルを作成する 主に取り組んだことは上記の2つになります。 要件通りのビジュアルをquicksightで作成するというのが、この時期の一番のミッションでした。 quicksightの仕様を完璧に理解せずに、検証のためにクエリを叩きまくり、予算を超えたコストをかけてしまい注意されることもありました。 が、この経験のおかげで、早期からコストを意識した開発ができるようになったと思います! 様々な制約(実行時間やコストなど)の中で、要件を満たすために、中間テーブルの作成を検討し、ETLを組んで必要なテーブルを用意するということもできるようになりました。 必要なデータを自分で準備し、可視化の方法を考え誤解のないようなビジュアルを作成するという、データサイエンティストの基礎能力を鍛えることができた期間でした。 9~12月 この1年の山場の期間でした! Web開発 主に取り組んだのはWeb開発です。 私の所属はあくまでデータ&AIであり、本来ならばWeb開発はスコープ外とはなりますが、若いうちに色々な経験を積んだ方が良いという上司のアドバイスがあり、私自身も挑戦できる環境があるなら色々なことに挑戦したいと思っていたので、Web開発に挑戦しました。 htmlもcssもコンポーネントという概念も何も知らない状態だったので、初めに作成したものはベタ書きばかりのクソデカコンポーネントで、それを見た同期や上司のなんとも言えない表情は今でも覚えています(笑) そんな状態でしたが、同期のサポートを受けながら、毎日ゴリゴリと開発を行った結果、アトミックデザインの理解、reactの状態の理解、figmaを元にcssでデザインを作成など基本的なフロントエンド開発ができるようになりました! htmlすら知らなかった状態に比べたらかなり成長できたなと思います! フロントエンドの実装を知ることで、データのことのみを考えた実装ではなく、その後のwebのことまで考えた設計ができるようになりました。 ただ、バックエンドやインフラまでは手がまわせなかったので、今後の課題として残ります。 また、PDMやデザイナーとすり合わせを行いながら、チームで開発をしていくことの難しさも学ぶことができました。 各ポジションごとに譲れない部分があり、どこに落とし所を持っていくのが良いのかをエンジニアの視点で考えながら、ディスカッションをするのはとても難しく頭を悩ませる日々でした。 開発を進めていくうちに、適切なすり合わせタイミングや伝え方がわかってきて、大きな手戻りをせずに開発ができるようになっていきました。 1~3月 自分の甘さを実感した期間です。 バグ対応 quicksightでのビジュアル作成 この2つが主に取り組んだことです。 なんといっても、自分が作成したETLが原因でバグが起きてしまい、その対応をし、根本対応策を考えるというのが、1番大きなタスクでした。 バグが起きたことで、自分の設計の甘さを実感するとともに、再発防止策を考えるのはとても学びになりました。 また、データ関連のバグの原因調査をどのように進めていけば、早期に原因を発見できるのかも学べました。 まとめ 1年を振り返ってみて、エンジニア素養ほぼ0の状態から、ETL作成やフロントエンド開発ができるようになったということでかなり成長できた1年ではないかと思います! 毎日の成長は微々たるものでしたが、改めて1年を振り返ってみたことで、毎日コツコツ積み重ねることの偉大さを実感できました。 とはいえ、1カ月後には優秀な新卒社員がたくさん入社してくるので、負けじとさらなる成長をとげるように日々精進していきたいと思います!
アバター
【2025春】DynamoDB Itemの一括削除を実践 背景 システムの前提条件(制限事項) 前提を要件としたコード設計 実装例 解説 read_a_values_from_file()関数について scan_table_for_latest_record()関数について get_latest_records_from_table_a()関数について delete_from_table_b()関数について main()関数について 結果 総括 参考 最後に  こんにちは、開発本部 RetailHUB開発部 NetSuperグループに所属するフルスタックエンジニアをやらせていただいています、ホーク🦅アイ👁️です。早春、3/6に「AWS Innovate: Generative AI + Data」が開催され、今後も生成AIアプリケーションと親和性の高いDynamoDBと向き合う方々が増えていくと思い本記事を書くことにしました。 背景  弊社ではネットスーパーシステムのオプションサービスとしてネイティブアプリも提供しております。そのインフラアーキテクチャの一部にDynamoDBがあります。主に、アプリ利用ユーザのログインセッションを管理しています。ごく最近、小売様の要望対応を行った結果、アプリにログイン中ユーザのセッションを削除する必要が出てきました。いくつかの解決策はありましたが対応の緊急性や工数の観点あるいは、一定数のユーザの再ログインをシームレスに行ってもらうUXのため、本記事にあるようにアプリケーションが読み書きしているDynamoDBのセッションテーブルのItemを直接削除する方法を選択しました。 システムの前提条件(制限事項)  実際に一括削除プログラムを実装をするにあたり、現状の弊社運用中DynamoDBの設定やテーブル設計について以下のことを考慮しなければなりませんでした。 アプリケーション上でセッション管理に使っている対象テーブルは2つ(仮にA=User,B=Sessionとする)存在 対象テーブル2つともPartition key(パーティションキー)はセッションIDのみ 対象テーブル2つともSort key(ソートキー)は設定なし 対象テーブル2つともItem総数は1万強 対象テーブル2つともCapacity mode(キャパシティモード)は、On-demand(オンデマンド)で起動中 テーブルAにはGlobal Secondary Index (GSI) の設定はない テーブルBにはGSIの設定がある。但し、今回無関係のAttribute(属性)にのみ設定済  また、運用面での要件は以下の通りです。 当日、システムメンテナンスを1時間実施。その時間内で作業完遂するスケジュール策定 GSI作成時にRead/Write負荷がかかり相当数の時間を要する可能性があるため普段の運用に支障をきたさないようにGSIの追加は行わない(そのための別メンテナンス時間を取る日程調整が厳しかった) 前提を要件としたコード設計  上述の前提条件を基にどのようなコード設計が必要か列挙します。 パーティションキーではない属性(ユーザID)を基に対象者を抽出するためFull Scan(フルスキャン)検索をしなければならない スキャンは、1MBの制限でPagination(ページネーション)が発生するため、LastEvaluatedKey要素が存在しなくなるまでページネーション単位で検索を繰り返す 属性:time_to_liveをNumber型で定義しているがこの値の最新UNIX Timestamp 1件のみを抽出 time_to_liveはソートキーではないので ScanIndexForward=false によるORDER BY DESCを使えないので最新かどうかはプログラム側で判定 boto3のリトライ機構はクライアントセッション全体のThrottling(スロットリング)には対応しているがbatch_write_item()関数のUnprocessedItemsには非対応なので自前でリトライ機構の実装が必要 リトライ機構として、Exponential Backoff(指数バックオフ)+Jitter(ランダム遅延)を採用 実装例 deleteUserSession.py # usage: python deleteUserSession.py <profile> <file> # <profile>: AWS CLIのprofile名 # <file>: 複数userIdが1行に1つずつ列挙されたファイル import boto3 import time import random import traceback import sys from collections import defaultdict # コマンドライン引数からprofile[第1引数]、ファイル名[第2引数]を取得 args = sys.argv # DynamoDBクライアント my_session = boto3.Session(profile_name=args[ 1 ]) dynamodb = my_session.client( 'dynamodb' ) # テーブル名 table_a_name = "User" table_b_name = "Session" # 外部ファイル(plain text)のパス INPUT_FILE_PATH = args[ 2 ] # ファイルから `userId` の値を読み込む def read_a_values_from_file (): with open (INPUT_FILE_PATH, "r" ) as file : return [line.strip() for line in file .readlines() if line.strip()] # 指定 `userId` の最新Itemをフルスキャン検索して取得 def scan_table_for_latest_record (a_value): latest_record = None last_evaluated_key = None # ページネーション用 while True : # Scan 実行(last_evaluated_key がある場合のみ渡す) scan_params = { "TableName" : table_a_name, "FilterExpression" : "userId = :a_value" , "ExpressionAttributeValues" : { ":a_value" : { "S" : a_value}} } if last_evaluated_key: scan_params[ "ExclusiveStartKey" ] = last_evaluated_key # None の場合は追加しない response = dynamodb.scan(**scan_params) # 取得データの中から最新の `time_to_live` を持つItemを探す for item in response.get( "Items" , []): if latest_record is None or int (item[ "time_to_live" ][ "N" ]) > int (latest_record[ "time_to_live" ][ "N" ]): latest_record = item # 最新の `time_to_live` を持つItemを更新 # ページネーションのチェック last_evaluated_key = response.get( "LastEvaluatedKey" ) if not last_evaluated_key: break # 次のページがなければ終了 return latest_record # Userテーブルから `userId` の値を元に最新Itemを取得 def get_latest_records_from_table_a (a_values): items_by_a = {} for a_value in a_values: latest_record = scan_table_for_latest_record(a_value) if latest_record: items_by_a[a_value] = latest_record[ "sessionId" ][ "S" ] # `sessionId` の値を保存 return list (items_by_a.values()) # Sessionテーブルの検索 & 削除 def delete_from_table_b (b_values): items_to_delete = [] # Sessionテーブルから `sessionId` の値で検索して該当Itemを取得 for b_value in b_values: response = dynamodb.query( TableName=table_b_name, KeyConditionExpression= "sessionId = :b_value" , ExpressionAttributeValues={ ":b_value" : { "S" : b_value}} ) items_to_delete.extend(response[ 'Items' ]) # 取得したItemをBatchWriteItemで削除 deleted_count = 0 batch_size = 25 for i in range ( 0 , len (items_to_delete), batch_size): batch = items_to_delete[i:i + batch_size] request_items = { table_b_name: [{ 'DeleteRequest' : { 'Key' : { 'sessionId' : item[ 'sessionId' ]}}} for item in batch] } response = dynamodb.batch_write_item(RequestItems=request_items) # 未処理のItemがあればリトライ retry_count = 0 while response.get( 'UnprocessedItems' ): retry_count += 1 wait_time = min ( 2 ** retry_count + random.uniform( 0 , 1 ), 60 ) # 指数バックオフ+ランダム遅延 time.sleep(wait_time) response = dynamodb.batch_write_item(RequestItems=response[ 'UnprocessedItems' ]) deleted_count += len (batch) print (f "経過:{deleted_count} 件を削除しました" ) return deleted_count def main (): try : # 外部ファイルから検索対象の `userId` のリストを取得 a_values = read_a_values_from_file() if not a_values: print ( "外部ファイルに検索対象がありません" ) return # Userテーブルから最新の `sessionId` 値を取得 b_values = get_latest_records_from_table_a(a_values) if not b_values: print ( "Userテーブルに該当するItemが見つかりませんでした" ) return # b_valuesの要素数を表示 print (f "対象者数: {b_values.__len__()}" ) # SessionテーブルのItem一括削除 deleted_count = 0 deleted_count = delete_from_table_b(b_values) print (f "総数:{deleted_count} 件を削除しました" ) except Exception as e: print (traceback.format_exc()) if __name__ == "__main__" : main() 解説 read_a_values_from_file()関数について # ファイルから `userId` の値を読み込む def read_a_values_from_file (): with open (INPUT_FILE_PATH, "r" ) as file : return [line.strip() for line in file .readlines() if line.strip()]  以下のようなフォーマットのテキストファイルを1行ずつ読み込みユーザIDのリストを返します。 userIdList.txt 100001 100019 100223 ... scan_table_for_latest_record()関数について # 指定 `userId` の最新Itemをフルスキャン検索して取得 def scan_table_for_latest_record (a_value): latest_record = None last_evaluated_key = None # ページネーション用 while True : # Scan 実行(last_evaluated_key がある場合のみ渡す) scan_params = { "TableName" : table_a_name, "FilterExpression" : "userId = :a_value" , "ExpressionAttributeValues" : { ":a_value" : { "S" : a_value}} } if last_evaluated_key: scan_params[ "ExclusiveStartKey" ] = last_evaluated_key # None の場合は追加しない response = dynamodb.scan(**scan_params) # 取得データの中から最新の `time_to_live` を持つItemを探す for item in response.get( "Items" , []): if latest_record is None or int (item[ "time_to_live" ][ "N" ]) > int (latest_record[ "time_to_live" ][ "N" ]): latest_record = item # 最新の `time_to_live` を持つItemを更新 # ページネーションのチェック last_evaluated_key = response.get( "LastEvaluatedKey" ) if not last_evaluated_key: break # 次のページがなければ終了 return latest_record  LastEvaluatedKeyが存在する限りwhileループを続けてページネーション毎にItemのtime_to_live属性値が最新(=最大)のものに更新し続けます。最後まで到達した後に最新1件のItemだけを返します。 get_latest_records_from_table_a()関数について # Userテーブルから `userId` の値を元に最新Itemを取得 def get_latest_records_from_table_a (a_values): items_by_a = {} for a_value in a_values: latest_record = scan_table_for_latest_record(a_value) if latest_record: items_by_a[a_value] = latest_record[ "sessionId" ][ "S" ] # `sessionId` の値を保存 return list (items_by_a.values())  対象リストのユーザID1つずつスキャンして最新1件のItemをリストに格納し、ユーザID数繰り返します。最後にItemのセッションIDリストを返します。 delete_from_table_b()関数について # Sessionテーブルの検索 & 削除 def delete_from_table_b (b_values): items_to_delete = [] # Sessionテーブルから `sessionId` の値で検索して該当Itemを取得 for b_value in b_values: response = dynamodb.query( TableName=table_b_name, KeyConditionExpression= "sessionId = :b_value" , ExpressionAttributeValues={ ":b_value" : { "S" : b_value}} ) items_to_delete.extend(response[ 'Items' ])  まず、セッションIDがSessionテーブルのパーティションキー属性になっているのでQuery(クエリ)検索を使って高速処理することができます。 # 取得したItemをBatchWriteItemで削除 deleted_count = 0 batch_size = 25 for i in range ( 0 , len (items_to_delete), batch_size): batch = items_to_delete[i:i + batch_size] request_items = { table_b_name: [{ 'DeleteRequest' : { 'Key' : { 'sessionId' : item[ 'sessionId' ]}}} for item in batch] } response = dynamodb.batch_write_item(RequestItems=request_items)  次に、全て取得した対象Itemリストをbatch_write_item()関数で25件(AWS側の仕様制限)を1単位として繰り返し一括削除していきます。 # 未処理のItemがあればリトライ retry_count = 0 while response.get( 'UnprocessedItems' ): retry_count += 1 wait_time = min ( 2 ** retry_count + random.uniform( 0 , 1 ), 60 ) # 指数バックオフ+ランダム遅延 time.sleep(wait_time) response = dynamodb.batch_write_item(RequestItems=response[ 'UnprocessedItems' ]) deleted_count += len (batch) print (f "経過:{deleted_count} 件を削除しました" )  その後、削除処理に失敗したUnprocessedItemsが存在する限り、削除処理をリトライし続けます。リトライ間隔を指数バックオフ+ランダム遅延により徐々に長くしていくことでリトライの成功率を上げる試みをしています。最後に、削除した件数を返します。 main()関数について def main (): try : # 外部ファイルから検索対象の `userId` のリストを取得 a_values = read_a_values_from_file() if not a_values: print ( "外部ファイルに検索対象がありません" ) return # Userテーブルから最新の `sessionId` 値を取得 b_values = get_latest_records_from_table_a(a_values) if not b_values: print ( "Userテーブルに該当するItemが見つかりませんでした" ) return # b_valuesの要素数を表示 print (f "対象者数: {b_values.__len__()}" ) # SessionテーブルのItem一括削除 deleted_count = 0 deleted_count = delete_from_table_b(b_values) print (f "総数:{deleted_count} 件を削除しました" ) except Exception as e: print (traceback.format_exc()) ユーザIDファイル読み込み Userテーブルから削除対象ユーザのセッションIDを検索 対象者数を出力 Sessionテーブルから対象セッションを検索して削除実行 削除Item数を出力 1−5の処理途中で例外が発生した場合は、トレース出力 終了 結果 検索ユーザ数:577名 対象者数:139名 削除Item総数:139件 Executed in usr time sys time 138.78 secs 14.20 secs 1.55 secs 総括  本記事では、DynamoDBのセッション管理テーブルの大量のItemを一括に削除する方法としてAWS SDK for Pythonで実装したプログラムを紹介しました。本番環境の実Item群を削除処理した結果、スロットリングは発生せずに全て成功するに至りました。今回は、既存の設計に変更を加えることなく運用中のテーブルにおいてなるべく影響を及ばさないためにBDIを追加せずにフルスキャン検索しましたが、もっと大量のItemが格納されている場合には処理の高速化が死活問題になる可能性はありますので、皆様のケースに合わせて柔軟に対応いただければと思います。  また、DynamoDBはPartiQLをサポートしています。簡単な問い合わせならば従来のSQLライクな構文で実行可能という意味で便利です。今回のケースにおいてはパーティションキー、ソートキー、インデックスが設定されていない属性に対しては制限されてしまうため使えませんでしたが、キーの設定がなされているケースであればなお有効であると思います。  今回は一時的な対応のみでしたのでCLIとして実装してローカルマシンからAWSアカウントにログインして実行しましたが、AWS Lambda+EventBridgeを使って定期的に古い使われなくなったItemを削除するパターンにも有効かと思います。  これにて本記事の結びとさせていただきます。 参考 Exponential Backoff And Jitter GitHub: botocore retry 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしています。 現在、小売アプリの開発でLaravel11を利用してAPI開発を行っています。 先日2月24日にLaravel12がリリースされました。 ( https://laravel.com/docs/12.x/releases ) 今回のリリースは、比較的マイナー変更中心の「メンテナンスリリース」となっているようです。 Laravel11は昨年の夏頃から使用しているのですが、まだまだ最新バージョンの認識で直近でバージョンアップを行う予定もありませんでした。 ただ、以下のサポート対応表を見ると、Laravel11でもあと1年程度でサポート対象外となることがわかります。 公式ではLaravelの方針として12ヶ月ごとにメジャーアップデートしていきたいとのことなので、 今後を考慮して、定期的にバージョンアップできるような体制にしておいた方が良さそうだと思いました。 そこで今回、Laravel11からLaravel12にバージョンアップすることでどんな変更点があるのか、 私の開発している環境ではどんな影響がありそうかをコメントしながらご紹介したいと思います。 各変更点に関するコメントについては、環境によっては一部内容が異なることもあると思いますので、参考程度にご覧いただければ幸いです。 Laravel12のバージョンアップ方法について Laravel11からのバージョンアップは非常に簡単で、 アプリケーションの「composer.json」ファイルのバージョンを更新することでバージョンアップできます。 "laravel/framework": "^12.0" "phpunit/phpunit": "^11.0" "pestphp/pest": "^3.0" Laravel12の変更点について Laravel11からLaravel12にすることによる変更点を公式で記載されている内容に沿って順番にご紹介したいと思います。 全体的に大きな変更点は控えめで、バージョンアップによるコード修正の影響も少ない気はしています。 Carbon2.xの終了 Carbon2.xのサポートが終了しました。 Laravel12では、Carbon3.xを使用する必要があります。 Laravel11ですでにCarbon3.xを使用しているのであれば特に今回の変更による影響はなさそうです。 Laravelインストーラー、スターターキットの更新 LaravelインストーラーCLIツールを使用して新しいLaravelアプリケーションを作成する場合は、 インストーラーを更新して、Laravel12、新しいLaravelスターターキットと互換性を持たせる必要があります。 新しいインストーラーを使用するとReact、Vue、Livewireのスターターキットを選択できるようで、 プロジェクトによってはこれを利用することで初期構築がしやすくなるかもしれません。 私の環境ではこちらを利用する機会は無さそうですが、この部分が今回のバージョンアップによる大きな変更点でもあるようです。 Concurrencyの利便性向上 並列処理で使用するメソッドとして、Concurrencyメソッドがありますが、 連想配列を使用してメソッドを呼び出すと、同時操作の結果が関連付けられたキーとともに返されるようになりました。 バージョンアップ前 $result = Concurrency::run([ fn () = > 1 + 1, fn () = > 2 + 2, ]); // $result = > [ 2, 4 ] バージョンアップ後 $result = Concurrency::run([ 'task-1' => fn () => 1 + 1, 'task-2' => fn () => 2 + 2, ]);   // $result => ['task-1' => 2, 'task-2' => 4] まだConcurrencyを使用した並列処理はサービス上に実装したことはないのですが、 今回の変更でキーも返却されるようになったので、その後の処理は順番を意識せず使えるようになります。 返却順をもとに行なっている処理があれば、今回のタイミングでキー指定による処理に変えられればより安全な処理になりそうです。 Databaseの変更点 データベース処理に関する変更ではなく、定義確認関連の変更点のようでした。 そのため通常のサービスで使用するような変更というよりは、メンテナンスやDB管理で使用するものになりそうです。 Schema::getTablesの変更 // テーブルのスキーマ情報をすべて取得する $tables = Schema::getTables();   // スキーマ「main」に関するスキーマ情報を取得する $table = Schema::getTables(schema: 'main');   // スキーマ「main」「blog」に関するスキーマ情報を取得する(複数指定) $table = Schema::getTables(schema: ['main', 'blog']); デフォルトですべてのスキーマの結果が含まれるようになりました。 以前のバージョンでは引数によるスキーマ選択ができませんでしたが、 上記のように引数を渡すと、指定されたスキーマの結果のみを取得できるようになりました。 Schema::getTableListing()の変更 $tables = Schema::getTableListing(); // $tables => ['main.migrations', 'main.users', 'blog.posts']   $table = Schema::getTableListing(schema: 'main'); // $tables => ['main.migrations', 'main.users']   $table = Schema::getTableListing(schema: 'main', schemaQualified: false); // $tables => ['migrations', 'users'] Schema::getTableListing()メソッドは、デフォルトでスキーマ修飾されたテーブル名を返すようになりました。 schemaQualified引数を渡すことで、スキーマ修飾なしの一覧を取得することもできます。 db:table、db:showコマンドの変更 PostgreSQLやSQL Serverと同様に、MySQL、MariaDB、SQLiteのすべてのスキーマの結果を出力するようになりました。 主に定義関連の参照が少し便利になった程度でデータベース操作に関する変更ではないので、私の環境ではコード修正の影響は無さそうです。 ただ、すでに使用しているメソッドである場合は修正が必要かもしれません。 Eloquentの変更点 Eloquentの変更点も非常に少なく、HasUuidsトレイトの変更がある程度です。 HasUuidsトレイトは、UUID仕様のバージョン7(順序付き UUID)と互換性のあるUUIDを返すようになりました。 モデルのIDに順序付きUUIDv4文字列を引き続き使用したい場合は、以下のようにHasVersion4Uuidsトレイトを使用する必要があります。 use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids; HasVersion7Uuidsは削除されました。今まで使用していた場合は、HasUuidsに変更する必要がありそうです。 新しく使用する場合はHasUuidsトレイトをそのまま使用すれば大丈夫そうです。 ただ、すでにHasUuidsトレイトを使用した処理がある場合は、個人的には問題ないと思っていますが、 影響という意味では非常にインパクトはある気はしていますので、バージョンアップ後に問題なく動作するかは念の為検証した方が良さそうです。 Requestの変更点 Requestでは、mergeIfMissingメソッドについて変更がありました。 mergeIfMissingメソッドはリクエストの入力データ内に対応するキーがまだ存在しない場合のみマージしたい時に使用します。 Laravel12からは、ネストされた配列データを「ドット」表記を使用して結合できるようになりました。 $request->mergeIfMissing([ 'shop.shop_name' => 'Sample Shop', ]); 実は上記のメソッドのようにデフォルト値として使用したいケースはあったのですが、今までは以下のような処理で行っていました。 $shop = $request->input('shop', []); if (!isset($shop['shop_name'])) { $shop['shop_name'] = 'Sample Shop'; } $request->merge(['shop' => $shop]); 今回のバージョンアップ変更の調査で、mergeIfMissingメソッドを使用した方がシンプルに記載できることを知ったので、現状の実装含め導入を検討したいと思いました。 Validationの変更点 Validationでは、画像形式のデフォルト指定に関して変更がありました。 Laravel12からはimageバリデーションルールがデフォルトでsvg画像を許可しなくなりました。 svg画像を許可するためには、バリデーションルールに明示的に指定が必要になります。 imageにallow_svgを指定する方法 'photo' => 'required|image:allow_svg' File::imageに指定する方法 use Illuminate\Validation\Rules\File; 'photo' => ['required', File::image(allowSvg: true)], 私の環境ではsvg画像を使用していないため影響はありませんが、 デフォルト指定かつsvg画像のバリデーションチェックをしていた場合は、明示的にルールの指定を追加する必要がありそうです。 最後に いかがでしたでしょうか。 実際に変更点を見たところでも、今回のメジャーアップデートによる大きな影響は全体的に控えめかなという印象です。 私のプロジェクトの開発環境もコード修正の対応はほぼ無さそうかなと思っています。 とはいえ、実際に正常に動くかは検証が必要ですし、他のライブラリとの関係などもあるため慎重に対応する必要はありそうです。 ただ、冒頭でお話した通り、あっという間にサポート対象外になるので、 良いタイミングでバージョンアップの検討はしていかないといけないと思いました。 今後のLaravelバージョンアップの際にぜひ少しでも参考にしていただければ幸いです。 最後までお読みいただき、ありがとうございました。
アバター
この記事の概要 エブリーTIMELINE開発部の内原です。 サービスを運用していると時々遭遇するOOM-Killerについて、改めて学んでみたのでまとめます。 OOM-Killerはどういう理由で発生するのか、なにが起きているのか、どう対処すればいいのか、などを解説します。 なおこの記事では、Linux上での説明を前提としています。 OOM-Killerとは サーバ上で、以下のようなメッセージを見たことがあるのではないでしょうか。 [ 2291.984774] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/amazon-ssm-agent.service,task=python3,pid=28268,uid=1001 [ 2291.988154] Out of memory: Killed process 28268 (python3) total-vm:536656kB, anon-rss:243172kB, file-rss:4kB, shmem-rss:0kB, UID:1001 pgtables:544kB oom_score_adj:0 このメッセージからは、python3プロセスがOOM-Killerによって強制終了されたことがわかります。 OOM-Killerが発生する理由 当然ながら、OOM-Killerはメモリが足りなくなったときに発生します。ただ、メモリが足りなくなったとは具体的にどのような状況かというと、以下の条件に当てはまった場合です。 プロセスによるメモリアクセスでページフォルトが発生し オーバーコミットにより新たなメモリの割り当てが必要となり 物理メモリ、スワップメモリいずれにおいても必要な領域が確保できない場合 プロセスに対するメモリ割り当てを行うタイミングについて メモリが割り当てられるタイミングとは特定プロセスがメモリを必要とした時ということにはなりますが、その瞬間は実装コードにおいてメモリを使用している状況とは一致しないことも多いです。 理由として、OSはプロセスのメモリを仮想アドレス空間(後述)として管理しており、プロセスにおけるメモリ空間と物理メモリ空間との対応が異なっているためです。 実装コード上で一見大量のメモリを確保しているように見えても、あくまで仮想アドレス空間上での割り当てのみ行われており、そのメモリが実際に必要となるタイミングになるまで物理メモリとの割り当てが行われないことがあります。 このような処理は、オーバーコミットやオンデマンド・ページングといった技術(いずれも後述)で実現されています。 仮想アドレス空間とページフォルト 仮想アドレス空間とは、プロセスが認識しているメモリ空間のことです。プロセスはこの空間を使ってメモリにアクセスをしますが、仮想アドレスと物理アドレスとは一致しておらず、OSが対応管理表を用いてアクセス時にアドレス変換を行います。 仮想アドレス空間に物理メモリとの対応付けが行われていない場合、そのアドレスにアクセスした場合にページフォルトが発生します。このページフォルトをトリガーとして実際のアクセスすることになるメモリ領域に割り当てが行われます。 これにより例えば以下のようなことが可能になります。 物理メモリをスワップファイルに退避することによって、物理メモリを仮想的に拡張することができる プロセス間でメモリ空間を隔離することができる(個々のプロセスは個別の物理メモリ割り当てを持っているため) プロセス間でメモリ空間を共有することができる(共有メモリ) なおメモリ割り当てはページという単位(一般的には4KB)で行われます。 オーバーコミット オーバーコミットとは、物理メモリ容量を超えてプロセスに仮想メモリ空間を割り当てることです。プロセスがメモリを要求したとしても、実際にそのメモリを使用しないケースも多いため、問題になることは少ないという考え方です。 その代わり、実際にメモリを使用するタイミングでメモリ不足に陥る可能性があります。 オンデマンド・ページング オンデマンド・ページングとは、プロセスがメモリを使用するタイミングで初めて仮想メモリ空間と物理メモリ空間との割り当てを行うことです。 オーバーコミットと組み合わせて用いることで、必要な物理メモリ使用量を削減することができます。 OOM-Killerが行っていること OOM-Killerが発動した場合、起動しているどれかのプロセスを強制終了させることでメモリ確保を試みます。 この際にOSは、以下のような状態のプロセスを優先的に選択しようとします。 使用メモリ量が多いプロセス OOM補正値が高いプロセス /proc/<pid>/oom_score_adj で設定される値。-1000 ~ +1000 の範囲で、値が高いほどOOM-Killerの対象となりやすい。-1000 であればOOMスコアが0になる プロセスnice値やその他ヒューリスティックな要素 正確には上記を考慮して /proc/<pid>/oom_score というOOMスコアがリアルタイムで算出されるのですが、OOM-Killerが発動したタイミングでOOMスコアが最も高いプロセスが選択されます。 実例 stress コマンドでメモリ消費を行い、OOM-Killerが発動する様子を確認してみます。 空きメモリ容量は1GB未満(700MB程度)、スワップファイルはなしの環境とします。 $ free -m total used free shared buff/cache available Mem: 949 159 696 0 92 673 512MBのメモリを消費します。 $ stress --vm 1 --vm-bytes 512M --vm-keep stress: info: [32679] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd 空きメモリ容量は200MB程度になりました。 $ free -m total used free shared buff/cache available Mem: 949 668 187 0 92 164 ここで新たに512MBのメモリ消費します。 $ stress --vm 1 --vm-bytes 512M --vm-keep stress: info: [33059] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd すると元々起動していたほうの stress コマンドが強制終了され、OOM-Killerが発動したことがわかります。 $ stress --vm 1 --vm-bytes 512M --vm-keep stress: info: [32679] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd stress: FAIL: [32679] (425) <-- worker 32680 got signal 9 stress: WARN: [32679] (427) now reaping child worker processes stress: FAIL: [32679] (461) failed run completed in 67s 今回のケースだと、先に起動していたほうがOOMスコアが高い傾向があり、結果として新しく起動したプロセスが残る状況でした。 対処1 メモリ使用量を削減できないか検討します。 対処2 物理メモリを増設できないか検討します。 対処3 スワップファイルを設定することで、物理メモリ容量を超えてメモリを利用できるようにします。ただしスワップメモリの性能は物理メモリよりも低いためパフォーマンスが極端に低下することが多いです。 瞬間的にメモリ使用量が増加するようなケースであればスワップメモリを利用することでOOM-Killerの発生頻度を抑えることができますが、恒常的にメモリが不足しているような状況であれば対処1や対処2を実施することをお勧めします。 スワップファイルを設定する手順は以下の通りです。 # dd if=/dev/zero of=/swapfile bs=1M count=512 # chmod 600 /swapfile # mkswap /swapfile # swapon /swapfile $ free -m total used free shared buff/cache available Mem: 949 619 248 0 81 219 Swap: 511 43 468 対処4 OOMスコアを調整する 特定のプロセスに対してOOMが発動しづらい状態にすることができます。なるべく常時起動しておいて欲しいプロセスに対して実施しておくと安定性が向上するかもしれません。 下記の pid は stress コマンドのプロセスIDです。 # echo -1000 >/proc/<pid>/oom_score_adj この状態で再度512MBのメモリ消費を行おうとしますが、既存のプロセスのOOMスコアが低いため、新しく起動したプロセスが強制終了されます。 $ stress --vm 1 --vm-bytes 512M --vm-keep stress: info: [35408] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd stress: FAIL: [35408] (425) <-- worker 35409 got signal 9 stress: WARN: [35408] (427) now reaping child worker processes stress: FAIL: [35408] (461) failed run completed in 6s まとめ この記事では、OOM-Killerはどういう理由で発生するのか、なにが起きているのか、どう対処すればいいのか、といったことを解説しました。 安定したサービス運営を行うためには、OOM-Killerに対する理解と対策が必要です。この記事がその一助となれば幸いです。
アバター
こんにちは。開発部でiOSエンジニアをしている野口です。 Flutterエンジニアをやっていましたが今年からiOSエンジニアに転向したので思っていることを書こうという記事になります。 なぜiOSに転向したのか Flutterをやっていると、外部のパッケージを入れる際にネイティブコード書かないといけないなどネイティブの知識を要求されるパターンがあり以前からネイティブに興味がありました。 そんな中で社内でiOSエンジニアのポジションが必要とされていたため、良いチャンスだと思い転向しました。 また、数年前まではクロスプラットフォームでFlutterがイケイケだと思っていましたが(日本においては)、 最近は複数のクロスプラットフォームを検討した結果、React NativeやKMPなどが採用されるケースも多々見るようになったのでどのクロスプラットフォームも良くなってきている印象です。 その状況においてもモバイルアプリは結局ネイティブの知識を必要とするため、ネイティブができることは今後のキャリア的にもプラスになるかと思っています。 iOSに転向してどうか キャッチアップについて まず、思ったのは体系的な情報が全然ないなと。 本はそれなりに出ますが、ほとんどSwiftUIでUIKitに関する本は全く出ていないなと思いました。 デリッシュキッチンでは主にUIKit、Storyboardが使用されているため、ここら辺の情報が欲しいのですが、公式の チュートリアル くらしかありませんでした。 結局、UIKitはやりながら覚えています。最近はAIに聞いたら良い感じに答えてくれるのでありがたいです。 Storyboardは検索してもXcodeのバージョンが古いものが多く、中々参考になるものが出てきません。また、GUIで操作するものなのでAIに聞くこともできないので絶賛苦労しています。 ここら辺はチームの方針として、StoryboardはUIKitのコードもしくはSwiftUIに書き換えていこうと話し合ってるので今後Storyboardは廃止する方針です。 逆にSwiftUIはFlutterのwidgetシステムに似ているので、Flutterの知識があると理解が早いです。というかほとんど同じです。 実際に以下のコードを比較してみます。 中央にボタンを表示するコードですが、見た目はほとんど同じです。 body が画面全体を表現していおり、その中にボタンを配置しているのでFlutterもSwiftUIも同じ考え方をしています。 Flutter import 'package:flutter/material.dart' ; class CenterButtonScreen extends StatelessWidget { const CenterButtonScreen({ super .key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: ElevatedButton( onPressed: () {}, child: const Text( 'ボタン' ), ), ), ); } } SwiftUI import SwiftUI struct CenterButtonScreen : View { var body : some View { Button( "ボタン" ) {} .buttonStyle(.borderedProminent) .frame(maxWidth : .infinity, maxHeight : .infinity) } } UIKitでは、中央にボタンを表示するためには、以下のようにコードを書く必要があり、見た目がだいぶ変わります。 UIKit import UIKit class CenterButtonViewController : UIViewController { private func createButton () -> UIButton { let button = UIButton(type : .system) button.setTitle( "ボタン" , for : .normal) button.backgroundColor = .systemBlue button.setTitleColor(.white, for : .normal) button.layer.cornerRadius = 8 return button } override func viewDidLoad () { super .viewDidLoad() let button = createButton() button.translatesAutoresizingMaskIntoConstraints = false view.addSubview(button) NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo : view.centerXAnchor ), button.centerYAnchor.constraint(equalTo : view.centerYAnchor ), button.widthAnchor.constraint(equalToConstant : 100 ), button.heightAnchor.constraint(equalToConstant : 44 ) ]) button.addTarget( self , action : #selector(buttonTapped), for : .touchUpInside) } @objc private func buttonTapped () { // ボタンを押した時の処理 } } viewの配置には以下のように作成したボタンに対して制約をつけてあげる必要があるため、コードが冗長になるなーと感じています。 ですが、最近慣れてきてるのでそんなに気にならなくなってきました。 NSLayoutConstraint.activate([ button.centerXAnchor.constraint(equalTo : view.centerXAnchor ), button.centerYAnchor.constraint(equalTo : view.centerYAnchor ), button.widthAnchor.constraint(equalToConstant : 100 ), button.heightAnchor.constraint(equalToConstant : 44 ) ]) 開発環境の違い Swiftの言語仕様、公式のフレームワークが充実している CombineなどApple純正のフレームワークを提供してくれるのは良いです。 Combineでは非同期処理や状態管理を行なってくれますが、Flutterでやろうとすると、Riverpodなどの外部パッケージを使用する必要があります。 外部パッケージではサポートされなくなることもあり、長期的なメンテナンスを考えるとあまり依存したくはなくはないものにはなるので、それを公式が提供するのは安心感があります。 また、Flutterでは不変なクラスを作るために freezed などの外部パッケージと build_runner による自動生成が必要ですが、Swiftでは struct を使うだけで実現できるのが魅力的です。 エディターの違い Flutter開発ではVSCodeを使用していましたが、iOS開発ではXcodeを使用します。Xcodeは癖が強いため最初は辛かったです。 特に私はgitの操作をvscodeの Git Graph で主に行なっていたので、 Xcodeではgitクライアントをどうしようかと、TerminalかSourceTreeを使用するかなど考えて右往左往しています。 また、XcodeではAIのサポートがあまり充実していないので、昨今のAIブームに乗り切れないのが悲しいなと思います。 github copitlot を入れることはできますが、chat形式で質問はできない。。。と思ってたんですが、 記事を書いている時に調べたら最近 chat ができるようになったみたいです。 最近はCursorでiOS開発できないかなと思って試行錯誤しておりますが、Cursorからビルドすると遅すぎて辛いのでCursorはコードを書いてもらうのとgitクライアントとして使用しています。 CursorでiOS開発しようとした時に参考にしたのは こちら の記事です。 最後に 2ヶ月しか経ってないので、浅ーいことしか書けていないのですが、同じモバイル開発でも結構環境が変わって、キャッチアップが大変でした。 FlutterからiOSに転向を考えている方の参考になれば嬉しいです。 もっと深い内容を書きたいですが、もう少しだけiOS開発をしてから機会があれば記事を書きたいと思います。 ご覧いただきありがとうございました。
アバター
はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 デリッシュキッチンのSTG環境のWEBへのアクセスには社内ユーザーからのみという制限があります。 先日、制限をかけるシステムに触れる機会があったので、今回はそのシステムについて紹介しようかと思います。 背景 先日、社外の方にSTG環境にアクセスしてもらうことがあったのですが、Google SSOの認証のページに飛ばされてしまって確認してもらうことができないということがありました。 エブリーではGoogle Workspaceを利用しており、Google WorkspaceのSSOを利用して業務アプリケーションにアクセスすることが多いです。仕組みを調査したところ、このSSOの認証がSTG環境で必須になっており、SSOの認証画面にリダイレクトされていました。 STG環境でSSO認証を行う 以下ではSTG環境でSSO認証を行う仕組みについて述べますが、SSO認証に用いるIdP(Identity Provider)の詳細や設定方法については記事の範囲外とさせていただきます。 また、デリッシュキッチンのWebはアプリケーションが動作しているECSの前段にELBやCloudFrontが存在しており、今回の記事の内容では通信がCloudFrontを経由していることが前提となります。 STG環境でSSO認証を行うためにLambda@EdgeとGoogleのIdPを利用します。 おおよその流れは図のようになりますが、後で疑似コードで実際にどのように実装しているかを説明します。 Lambda@Edge AWSの公式ドキュメント Lambda@Edge を使用してエッジでカスタマイズする - Amazon CloudFront によると、 Lambda@Edge は AWS Lambda の拡張です。Lambda@Edge は、Amazon CloudFront が配信するコンテンツをカスタマイズする関数を実行できるコンピューティングサービスです。 となっています。 Lambda@Edgeを使うことによってCloudFrontから配信するコンテンツをカスタマイズすることができるので、今回はこのLambda@Edgeを使ってSTG環境へのアクセスにSSO認証をかけます。 Lambda@Edgeは下記の4つのイベントにトリガーできるのですが、ビューワーリクエストをトリガーにして認証を行なっていくことになります。 ビューワーリクエスト: ユーザーからCloudFrontへのリクエスト オリジンリクエスト: CloudFrontからオリジン(コンテンツ)へのリクエスト オリジンレスポンス: オリジン(コンテンツ)からCloudFrontへのレスポンス ビューワーレスポンス: CloudFrontからユーザーへのレスポンス Lambda@Edgeで何をしているか 以下では、疑似コードを載せていますが、エラーハンドリングを無視していたりと実際に動くものではないことはご留意いただければと思います。 STG環境でSSO認証を行うためにLambda@Edgeで大きく分けて2つの処理を行っています。 特定条件の時に認証のスキップ SSO認証を行う 1. 特定条件の時に認証のスキップ exports . handler = async ( event , _ , callback ) => { const request = event . request ; // 指定されたIPアドレスから指定されたpathへのアクセスであれば、認証を無視する if ( allowedPath ( request . uri ) && await allowedIP ( request . clientIp )) { // requestを継続してcontentsにアクセスする return callback ( null , request ) ; } const headers = request . headers ; // SSO認証を行う return await sso . main ( request , headers , callback ) ; } ; ここでは、今回この記事を書く背景となった外部の方にSTG環境にアクセスしてもらう場合など、SSO認証が行えないがアクセスさせたい場合に例外的にアクセスを許可しています。認証をスキップする条件に縛りはないのですが、疑似コードではIPアドレスとアクセスするコンテンツのpathで条件をかけています。 もし条件に一致しない場合はSSO認証を行うことになります。 2. SSO認証を行う exports . main = async ( request , headers , callback ) => { try { // ④ トークンがsetされている場合 if ( hasToken ( headers )) { // トークンの検証を行い、結果を返す return validateToken ( request , headers , callback ) ; } // ② 認可コードを渡すcallbackとして呼び出された場合 if ( request . uri . startsWith ( config . CALLBACK_PATH )) { const queryDict = qs . parse ( request . querystring ) ; // 認可コードとトークンの交換をIdPにリクエストする const response = await requestToken ( queryDict ) ; return setToken ( request , headers , response , queryDict , callback ) ; } // ① SSO認証のためにIdPにリダイレクトする redirectToIdP ( request , callback ) ; } catch ( error ) { console . error ( error ) ; throw ( error ) ; } } ; function setToken ( request , headers , response , queryDict , callback ) { const decodedData = jwt . decode ( response . id_token ) ; // JWTの確認を行う jwt . verify ( decodedData ) ; // ③ CookieにトークンをsetするためにSet-Cookieを指定してresponseを返す const response = getResponseForSetToken ( queryDict , config , headers , decodedData ) ; callback ( null , response ) ; } function validateToken ( request , headers , callback ) { // JWTの確認を行う  jwt . verify () ; // requestを継続してcontentsにアクセスする   callback ( null , request ) ; } 疑似コードを見ていただければ、どのような処理を行っているのか理解していただけるかもしれませんが、簡単に説明していきます。 始めに、④や②に当てはまらなかった場合、つまり、SSO認証の結果のトークンがsetされておらずcallbackでリダイレクトされたpathでもない場合は、認証が完了していないため①でIdPの認証ページにリダイレクトさせます。 次に、IdPで認証が成功した場合はcallbackで規定のpathにリダイレクトされます。callbackでリダイレクトされている場合は認可コードを受け取っているはずなので、この認可コードを使ってIdPにトークンをリクエストします。無事にIdPからトークンを取得できた場合は③でトークンをCookieにセットした後に元のリクエストページへリダイレクトさせます。 最後に、元のリクエストにリダイレクトされた際にSSO認証の結果のトークンがsetされているはずなので④で確認を行います。ここでトークンがsetされている場合はトークンの正当性を確認します。トークンの正当性が確認できた場合は、SSO認証済みのはずなのでコンテンツにアクセスするためのrequestを継続します。 まとめ 今までLambda@Edgeを触ったことがなかったので勉強する良い機会になりました。 この手のシステムは一度開発が完了するとなかなか触れる機会がないことも多いかと思いますが、積極的に触ってみることも大切だと感じました。
アバター
データ&AIチームでデータエンジニアを担当している塚田です。 弊社のデータ基盤はさまざまなデータソースからデータを連携しており、そのデータを活用することで全社のデータ基盤として成り立っています。 その中で、Google Analytics for Firebaseの活用をベースにBigQueryのコストダウンした事例をご紹介できればと思います。 概要 改めて、弊社ではGoogle Analytics for Firebaseなど色々な基盤を用いてログを収集していいます。 今回はGoogle Analytics for Firebaseを用いたログの取得とその保存先であるBigQueryを利用した時により良い運用ができないかと考えコスト面での確認を進めました。 方針 一般的にBigQueryは大規模なデータセットに対してクエリを実行する基盤として利用する方が多いかと思いますが、そのデータを保持するのにもコストがかかっている部分が多いのではないでしょうか。 その中でコストがかかるものを低減できる施策として 新しい料金モデルで BigQuery の物理ストレージの費用を削減 が活用できないかと思い確認を進めました。 発表から数年経っているものとはなりますが、発表前から利用しているプロジェクトだとそのオプションがなかった状況になると思うので、適用した方が良いのかを考える一助となればと考えています。 利用量確認 BigQueryのドキュメントをもとに以下のテーブルを用いて利用量の現状確認を進めました region-us .INFORMATION_SCHEMA.TABLE_STORAGE_BY_PROJECT クエリについては BigQueryのドキュメント にサンプルがあります。 DECLARE active_logical_gib_price FLOAT64 DEFAULT 0 . 02 ; DECLARE long_term_logical_gib_price FLOAT64 DEFAULT 0 . 01 ; DECLARE active_physical_gib_price FLOAT64 DEFAULT 0 . 04 ; DECLARE long_term_physical_gib_price FLOAT64 DEFAULT 0 . 02 ; WITH storage_sizes AS ( SELECT table_schema AS dataset_name, -- Logical SUM ( IF (deleted= false , active_logical_bytes, 0 )) / power ( 1024 , 3 ) AS active_logical_gib, SUM ( IF (deleted= false , long_term_logical_bytes, 0 )) / power ( 1024 , 3 ) AS long_term_logical_gib, -- Physical SUM (active_physical_bytes) / power ( 1024 , 3 ) AS active_physical_gib, SUM (active_physical_bytes - time_travel_physical_bytes) / power ( 1024 , 3 ) AS active_no_tt_physical_gib, SUM (long_term_physical_bytes) / power ( 1024 , 3 ) AS long_term_physical_gib, -- Restorable previously deleted physical SUM (time_travel_physical_bytes) / power ( 1024 , 3 ) AS time_travel_physical_gib, SUM (fail_safe_physical_bytes) / power ( 1024 , 3 ) AS fail_safe_physical_gib, FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE_BY_PROJECT WHERE total_physical_bytes + fail_safe_physical_bytes > 0 -- Base the forecast on base tables only for highest precision results AND table_type = ' BASE TABLE ' GROUP BY 1 ) SELECT dataset_name, -- Logical ROUND (active_logical_gib, 2 ) AS active_logical_gib, ROUND (long_term_logical_gib, 2 ) AS long_term_logical_gib, -- Physical ROUND (active_physical_gib, 2 ) AS active_physical_gib, ROUND (long_term_physical_gib, 2 ) AS long_term_physical_gib, ROUND (time_travel_physical_gib, 2 ) AS time_travel_physical_gib, ROUND (fail_safe_physical_gib, 2 ) AS fail_safe_physical_gib, -- Compression ratio ROUND (SAFE_DIVIDE(active_logical_gib, active_no_tt_physical_gib), 2 ) AS active_compression_ratio, ROUND (SAFE_DIVIDE(long_term_logical_gib, long_term_physical_gib), 2 ) AS long_term_compression_ratio, -- Forecast costs logical ROUND (active_logical_gib * active_logical_gib_price, 2 ) AS forecast_active_logical_cost, ROUND (long_term_logical_gib * long_term_logical_gib_price, 2 ) AS forecast_long_term_logical_cost, -- Forecast costs physical ROUND ((active_no_tt_physical_gib + time_travel_physical_gib + fail_safe_physical_gib) * active_physical_gib_price, 2 ) AS forecast_active_physical_cost, ROUND (long_term_physical_gib * long_term_physical_gib_price, 2 ) AS forecast_long_term_physical_cost, -- Forecast costs total ROUND (((active_logical_gib * active_logical_gib_price) + (long_term_logical_gib * long_term_logical_gib_price)) - (((active_no_tt_physical_gib + time_travel_physical_gib + fail_safe_physical_gib) * active_physical_gib_price) + (long_term_physical_gib * long_term_physical_gib_price)), 2 ) AS forecast_total_cost_difference FROM storage_sizes ORDER BY (forecast_active_logical_cost + forecast_active_physical_cost) DESC ; このクエリを活用し結果を精査することで論理バイト ストレージ課金よりも物理バイト ストレージ課金の方が有利に働くことがあるかと思います。 弊社の環境だと物理バイト ストレージ課金による単価増よりも圧縮率が大きいためコストとしては有利に働く結果となりました。 一部の指標のみになってしまいますが、グラフの青い部分がBigQueryのストレージを含めた課金量になっており大幅にコストが下がる状況となりました。 まとめ 全ての環境で適用できるわけではありませんが、オープンクラウドを利用していく中でコストというのは常に意識していかないとならない部分だと思います。 毎日数多くのアップデートがある中で適用できそうなものがあれば検証の上柔軟に導入していければと考えており、その一例をご紹介しました。 このような改善を積み重ねながら新たな大きいアクションができるように開発を進めていきたいと考えています。
アバター
はじめに こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 今回は、Redashのアップグレードについてのお話です。 背景 デリッシュキッチンではデータ分析のための可視化ツールとして Redash を利用しており、ECS上にRedashをデプロイして運用しています。 RedashはOSS版の更新が長らく停止していましたが、昨年プロジェクトが再始動され( 参考 )、先日v25.1.0がリリースされました。 今回、Redash v10.0.0 から v25.1.0 へのアップグレード試験を実施しました。 現在のRedashの運用状況については、以下の記事で詳しく解説しています。 https://tech.every.tv/entry/2024/03/06/160148 アップグレード手順 v10.0.0 から v25.1.0 へのアップグレードは、v10.1.0 を経由する必要があります。 v10.0.0 から v10.1.0 はイメージの差し替えのみで、DBマイグレーションは不要です。 v10.1.0 から v25.1.0 はDBマイグレーションが必要です。 具体的な手順は以下の通りです。 事前準備 Redash v25.1.0でビルドしたDockerイメージをECRに登録します マイグレーションタスク用のECSタスク定義とサービス定義を作成します Redash DBのバックアップを作成します 作業 古いRedashコンテナを停止します マイグレーションタスクを実行します 新しいRedashコンテナを起動します 確認 Redashにログインし、データが正常に表示されることを確認します マイグレーションタスクの作成 Redashのデプロイには ecspresso を利用しており、 ecspresso run コマンドでマイグレーションタスクを実行できるようにマイグレーション用定義ファイルを作成します。 タスク定義ファイルは以下のように作成します。(一部抜粋) { " family ": " redash-migration ", " containerDefinitions ": [ { " name ": " redash-migrate ", " image ": " ECR/Custom-Redash:25.1.0 ", " command ": [ " manage ", " db ", " upgrade " ] } ] } 手順2.1で古いRedashコンテナを停止した後、 ecspresso run --wait-until stopped のようなコマンドを実行することで、マイグレーションタスクを実行できます。 このとき、ある程度の時間がかかるのでecspressoのタイムアウト設定を適切に行う必要があります。 ハマりどころ v10.1.0からv25.1.0へのアップグレードにおいて、以下のようなハマりどころがありました。 エラー DETAIL: \u0000 cannot be converted to text. が発生する マイグレーションタスクを実行中に、"change type of json fields from varchar to jsond" ステップで以下のエラーが発生しました。 DETAIL: \u0000 cannot be converted to text. これは Issue でも報告されており、以下のSQLを実行することで対応できます。 UPDATE visualizations SET options = replace (options::text, ' \u0000 ' , '' )::json WHERE strpos(options::text, ' \u0000 ' ) > 0 ; エラー TypeError: 'NoneType' object is not iterable が発生する マイグレーションタスクを実行中に、"fix_hash"ステップで、以下のエラーが発生しました。(一部抜粋) 2025/02/12 14:38:32 File "/app/migrations/env.py", line 85, in run_migrations_online 2025/02/12 14:38:32 context.run_migrations() 2025/02/12 14:38:32 File "<string>", line 8, in run_migrations 2025/02/12 14:38:32 File "/usr/local/lib/python3.10/site-packages/alembic/runtime/environment.py", line 948, in run_migrations 2025/02/12 14:38:32 self.get_context().run_migrations(**kw) 2025/02/12 14:38:32 File "/usr/local/lib/python3.10/site-packages/alembic/runtime/migration.py", line 627, in run_migrations 2025/02/12 14:38:32 step.migration_fn(**kw) 2025/02/12 14:38:32 File "/app/migrations/versions/9e8c841d1a30_fix_hash.py", line 55, in upgrade 2025/02/12 14:38:32 new_hash = update_query_hash(record) 2025/02/12 14:38:32 File "/app/migrations/versions/9e8c841d1a30_fix_hash.py", line 29, in update_query_hash 2025/02/12 14:38:32 parameters_dict = {p["name"]: p.get("value") for p in record['options'].get('parameters', [])} if record.options else {} 2025/02/12 14:38:32 TypeError: 'NoneType' object is not iterable コードの該当箇所を確認したところ、取得された parameters_dict は利用されていなかったためコメントアウトすることで対応しました。 https://github.com/getredash/redash/blob/master/migrations/versions/9e8c841d1a30_fix_hash.py#L29-L31 def update_query_hash (record): should_apply_auto_limit = record[ 'options' ].get( "apply_auto_limit" , False ) if record[ 'options' ] else False query_runner = get_query_runner(record[ 'type' ], {}) if record[ 'type' ] else BaseQueryRunner({}) query_text = record[ 'query' ] # parameters_dict = {p["name"]: p.get("value") for p in record['options'].get('parameters', [])} if record.options else {} # if any(parameters_dict): # print(f"Query {record['query_id']} has parameters. Hash might be incorrect.") return query_runner.gen_query_hash(query_text, should_apply_auto_limit) 最後に Redash v10.0.0 から v25.1.0 へのアップグレードが完了しました。 一部暫定的な対応を行いましたが、試験環境でのアップグレードは成功しました。 現在、既存クエリとダッシュボードが正常に動作するかどうか、試験環境で確認を行っています。 これらに問題がないことを確認次第、本番環境へのアップグレードを進める予定です。
アバター
はじめに デリッシュキッチン 開発部でエンジニアをしている24新卒の新谷と @きょー です。 2025年2月13-14日に開催された Developers Summit 2025 に参加してきましたので、イベントの様子や印象に残ったセッションをいくつかご紹介します。 イベント概要 Developers Summit(デブサミ)は、2003年から続くITエンジニアのための祭典です。 ソフトウェア開発者が今知っておきたいトピックや、ロールモデルとなるデベロッパーとのさまざまな出会いがあるイベントです。 2025年のテーマは「ひろがるエンジニアリング」で、技術革新が進む中でのエンジニアの可能性や社会への影響について紹介されました。 会場は、ホテル雅叙園東京で開催されましたが、室内に鯉が泳ぐ池があるなど、豪華な雰囲気でした。 また、会場内には多くのブースが出展されており、最新の技術やサービスを紹介されていたり、書籍の販売も設けられていました。 以下ブースの紹介となります。各社自社プロダクトを使った展示だったり、通りすがっただけで目を引くような展示、ホットなトピックについてアンケートをとっている企業もありました。プロダクトや組織についてお話を聞くこともできとても楽しかったです! 参加レポート リアルな過去からたどり着いた、事業を成長を牽引するエンジニアの在り方 発表者: ウェルスナビ株式会社 保科 智秀 さん レポート: 新谷 www.docswell.com こちらのセッションでは、事業成長を支えるエンジニアリングの在り方について語られました。 事業成長のフェーズは「超初期(クローズドβ)」「初期(一般公開)」「成長期(1→10)」「成長期(10→100)」の4段階に分けられ、それぞれの課題とエンジニアの役割が説明されました。 超初期フェーズ 限られたリソースの中、期間で目的達成するために真のMUST要件を引き出す。 初期フェーズ 安易な解決策に飛びつかず、高確率で予測される継続した改善を考慮することが大事。 だが遠すぎる未来は切り捨てる。 成長期(1→10) 今後の負債解消のプランを持ちつつ、負債を受け入れる覚悟を持って機能開発を優先。 成長期(10→100) 負債解消と新規事業展開に向けたアーキテクチャ変更が必要となり、将来に向けた成長加速のプランを提案。 各フェーズでエンジニアは異なる判断を求められ、スピード感・柔軟性・将来の展望を考慮した選択が重要であることが強調されていました。 特に、負債を受け入れながらも成長を加速させるための意思決定が鍵となるという点が印象的でした。 目の前の仕事と向き合うことで成長できる - 仕事とスキルを広げる 発表者: 株式会社リンケージ そーだいさん( https://x.com/soudai1025 ) レポート: 新谷 speakerdeck.com このセッションでは、仕事を通じてスキルを広げ、能力を高める方法が紹介されました。 まず、能力を伸ばすためには「知識」と「経験」を掛け合わせて「知恵」とすることが重要であると語られました。 スキルを習得する過程では、「知る」「やる」「わかる」「できる」「している」といったステップを踏むことで、実践を通じた学びが深まるという考えが示されました。 また、「仕事の中で成長する」ためには、計画実行力、言語化力、問題解決能力を鍛えることが重要であり、それには「内省」と「フィードバックサイクル」が必要であると説明されました。 具体的には、タスクを細分化し、適切な問題設定を行い、日報や週報を活用して振り返りを行うことが推奨されていました。 最後に、「一日ひとつでも知らないことを見つける」ということが紹介されており、日々の積み重ねがスキルアップにつながるということを改めて認識しました。 生成 AI 時代のプロダクトの現在地点 発表者: 株式会社 LayerX 松本さん( https://x.com/y_matsuwitter ) レポート: きょー speakerdeck.com このセッションでは、LLM(大規模言語モデル)時代におけるプロダクト開発の在り方について松本さんに紹介いただきました。 LLMが人間と同程度の情報量で仕事を習得できるこの時代、LLMのポテンシャルを引き出すために「AIをオンボーディングする」意識が大切になるとのことでした。 LLMが活躍できるように適切な情報、ツールを提供し、継続的に学習させていける仕組みが必要になってくるわけです。 特に以下の5つの点が重要だと説明いただきました。 Context LLMが問題を解決するために必要な情報 Knowledge LLMが参照する知識データベース Workflow LLMが業務を遂行するためのプロセス Planning LLMがタスクを計画するための仕組み Evaluation LLMの出力結果を評価するための仕組み また、LLMを使う箇所を見極めることも重要だと発表で触れていました。LLMを組み込める箇所全て組み込むのではなく、適切な品質・体験となるようにソフトウェア・LLM・人間の誰が何をやるかバランスを決めるのが重要とのことでした。 「LLM中心の時代のプロダクトを作り直すならどう構成していくか」松本さんから最後に問いかけられたテーマです。理想のプロダクトを常に考え、現実とのギャップをどう埋めていくかを考えながら開発をしていこうと思いました。 リーダブルテストコード~メンテナンスしやすいテストコードを作成する方法を考える~ 発表者: twadaさん( https://x.com/t_wada )、オーティファイ株式会社 末村さん( https://x.com/tsueeemura )、株式会社10X / B-Testing ブロッコリーさん( https://x.com/nihonbuson ) レポート: きょー speakerdeck.com このセッションでは、読みやすくメンテナンスしやすいテストコードの書き方について上記で記載している3名の専門家より紹介いただきました。 twadaさんからは、テストコードの認知負荷を下げるための方法として、名前、構造、情報量に気を配ることが重要であると説明がありました。 具体的には、テストの意図が明確に伝わるように、テストコードの命名や構造を工夫すること、そして、テストに必要な情報だけを記述することが重要とのことでした。 末村さんからは、E2Eテストコードを例に、コンテキストを明示することでテストコードを自己説明的にする方法を紹介いただきました。 テストコードに「いま、どのページにいるのか」「どんなデータがあるはずなのか」といったコンテキストを明示することで、コードの可読性が向上し、メンテナンス性も高まるとのことでした。 ブロッコリーさんからは、テストコードにテストの意図を込めることの重要性について紹介いただきました。 テストの意図を明確にすることで、コードの理解容易性や説明容易性が向上し、ひいては保守性も向上すると説明がありました。 また、テストの意図をテストメソッド名に記述することで、特別な設定値がどれなのかが分かりやすくなり、仕様変更時の対応もしやすくなるという利点も紹介されました。 メンテナンスしやすいテストコードを書く上で大切なことは、読み手を意識した命名や認知負荷を下げるための構造化ということでした。また、AIが発展してきたこの時代、AIをうまく使いこなしより良いテストコードを模索していくことも大事ということを紹介されていました。 まとめ Developers Summit 2025は、エンジニアリングのトレンドや事例を知ることができる貴重な機会でした。 特定の技術にフォーカスしたセッションから、エンジニアとしてのキャリアやスキルアップについて学べるセッションまで、幅広い内容に触れることができました。 今後も、新しい技術や知識を取り入れ、エンジニアとしての成長を続けていきたいと思います。
アバター
こんにちは。RH開発部RHRAグループの池です。 2024年6月にエブリーは5つの小売アプリの運営について事業譲渡を受け、『 retail HUB 』へ移管しました。 引き継いだシステムのバックエンドはLaravelを用いて構築されていましたが、Laravelは弊社では初めて扱う技術スタックでした。そのため、チーム全体でLaravelの知見を深めながら、運用保守および開発を進めています。 このような状況の中、新規サーバーを構築する機会があり、Laravelの知見をチームで蓄積することも目的の一つとして、新規サーバーの開発においてLaravelを採用しました。 本記事では、弊社が初めてLaravelを導入した新規サーバーの構成についてご紹介させていただきます。 システム概要 まず最初に前提ですが、新規サーバーの開発にあたり、以下の条件を考慮して設計開発を進めています。 スピード重視の開発 リリース優先でまずは必要最小限の機能を実装 開発効率を重視した技術選定 段階的な改善を許容する設計 チームの技術背景 Laravelはチームが初めて扱う技術スタック チーム全体で学習しながらの開発 将来を見据えた設計 マルチテナント対応を考慮 段階的な機能拡張が可能な構造 このような方針をもとに、最初から作り込んだ設計を目指すのではなく、スピードを優先しつつも実用的な設計を考慮しながら挑戦と学びのある開発アプローチをバランスをとって選択しています。 全体像 今回開発している新規サーバーでは、モバイル向けAPIと管理画面向けAPIの2つのAPIを提供しており、これらAPIは共通のデータベースを使用しています。 これらを効率的に管理すべくモノレポ構成で開発を行っています。 構成の簡易図は以下の通りです。 技術スタック こちらは紹介までになりますが、Laravel関連で採用している技術スタックは以下の通りです。 ほとんどが弊社として初めて扱うものであり、チーム全体で学習・議論しながら取り組んでいます。 PHP 8.3(8.4へアップグレード予定) Laravel 11 Laravel Octane & Swoole Laravel Sanctum Pest Larastan Laravel Pint ディレクトリ構成 リポジトリ全体のディレクトリ構造は、以下の通りです。 . ├── .github/ # GitHub Actionsの設定(パイプラインの共通化) ├── dashboard-api/ # 管理画面向け API プロジェクト │ ├── Dockerfile # 管理画面向け Dockerイメージ定義 │ ├── app # 管理画面向け API 固有コード │ │ ├── Exceptions │ │ ├── Helpers │ │ ├── Http │ │ ├── Providers │ │ ├── Repositories │ │ │ ├── Interfaces # リポジトリのインターフェース │ │ └── Services │ │ ├── Interfaces # サービスのインターフェース │ ├ ... │ ├── compose.yaml # ローカル開発環境の設定 │ ├── composer.json # 共通パッケージをimport │ ├── ecspresso # 管理画面向け デプロイ設定 │ ├── tests # 管理画面向け API 固有のテストコード │ ├ ... │ ├── mobile-api/ # モバイル向け API プロジェクト │ ├── Dockerfile # モバイル向け Dockerイメージ定義 │ ├── app # モバイル向け API 固有コード │ │ ├── Exceptions │ │ ├── Helpers │ │ ├── Http │ │ ├── Providers │ │ ├── Repositories │ │ │ ├── Interfaces # リポジトリのインターフェース │ │ └── Services │ │ ├── Interfaces # サービスのインターフェース │ ├ ... │ ├── compose.yaml # ローカル開発環境の設定 │ ├── composer.json # 共通パッケージをimport │ ├── ecspresso # モバイル向け デプロイ設定 │ ├── tests # モバイル向け API 固有のテストコード │ ├ ... │ ├── packages │ └── common/ # 共通パッケージ(各 API プロジェクトで再利用) │ ├── composer.json │ └── src │ ├── Models │ ├── Providers │ ├── Services │ ├── Repositories │ ├── databases # データベース関連は全て共通化 │ │ ├── factories │ │ ├── migrations │ │ └── seeders │ └── tests │ ├── phpstan.neon # PHPStanの共通設定 ├── pint.json # Laravel Pintの共通設定 プロジェクト共通コードは packages/common/ に配置された共通パッケージで管理します。データベースモデルやマイグレーション、ビジネスロジックなど、両APIで共有する機能を集約します。各APIプロジェクトからはComposerを通してこの共通パッケージをインポートして共通コードを利用する形となります。 また、管理画面向けAPI( dashboard-api/ )とモバイルアプリ向けAPI( mobile-api/ )配下では、それぞれのプロジェクトに応じた固有のロジック、テストコードや設定ファイル、デプロイ構成などを個別に管理しています。 このように、共通機能と個別機能を分離しながら開発を行なっています。加えて、CI/CD 用のワークフローも共通で管理します。 アーキテクチャ設計 私たちのシステムは、SaaSとしてマルチテナントでの運用を想定しており、テナントごとに異なるビジネスロジックやデータアクセスを柔軟に切り替えられるようなアーキテクチャを検討しました。 あまり特別なことはしてないですが、レイヤードアーキテクチャ+DIP(依存性の逆転) の形を取りつつ、マルチテナント対応のために ServiceInterface と RepositoryInterface を導入しています。 モノレポ構成 私たちのチームでは、主に以下の理由でモノレポの構成を採用しました。 各APIプロジェクトが同じドメインで共通化できる要素が多い データベースマイグレーション、モデル定義 ビジネスロジック、ユーティリティ linter設定 CI/CDパイプライン 全員が複数プロジェクトを横断して開発する小規模なチーム体制との親和性あり 各プロジェクト横断的な変更がしやすい、影響範囲を把握しやすい など 続いて、Laravelにおけるモノレポ設定方法とInterfaceのDIについて実例を紹介します。 モノレポ設定方法とDIの実例紹介 Laravel プロジェクトにおいて共通パッケージを利用する際の主な方法は、Composer の repositories を用いる方法です。具体的には、各 API プロジェクトの composer.json に共通パッケージのリポジトリ定義を追加し、依存関係として設定します。 1. Composer のリポジトリ定義 例として、 mobile-api/composer.json の一部は以下のようになります。 { " require ": { " sample/common ": " dev-main " } , " repositories ": [ { " type ": " path ", " url ": " ../packages/common " } ] } 同様の設定を dashboard-api/composer.json にも記載することで、両サービスで共通パッケージを最新コードとして取り込むことが可能となります。 2. オートロード設定 共通パッケージ内のクラスは PSR-4 に従った名前空間の設定を行うことで、Laravel のオートローダーにより自動的に読み込まれます。これにより、サービス内で自然な形で共通機能が利用できる状態となります。 { " name ": " sample/common ", " autoload ": { " psr-4 ": { " Sample\\Common\\ ": " src/ " } } , ... " extra ": { " laravel ": { " providers ": [ " Sample \\ Common \\ Providers \\ CommonServiceProvider " ] } } } また、 extra.laravel.providers にサービスプロバイダーが指定することで、共通パッケージの初期化やサービス登録が自動的に行われます。 指定した CommonServiceProvider では loadMigrationsFrom メソッドを呼び出し、共通パッケージ内のマイグレーションファイルを読み込むようにします。 そうすることで、 mobile-api や dashboard-api のプロジェクトからマイグレーションを実行する際に、共通パッケージ内のマイグレーションファイルも読み込まれるようになります。 <?php /** * Bootstrap services. */ public function boot () : void { $ this -> loadMigrationsFrom ( __DIR__ . '/../databases/migrations' ) ; } ?> 3. 共通パッケージの利用例 例えば、共通パッケージ内に用意されたShopモデルを、 dashboard-api プロジェクトで利用する場合、以下のように記述します。 <?php use DashboardApi\Repositories\Interfaces\ShopRepositoryInterface; use Sample\Common\Models\Shop; class ShopRepository implements ShopRepositoryInterface { private Shop $ shop ; public function __construct ( Shop $ shop ) { $ this -> shop = $ shop ; } public function findShopById ( int $ id ) : Shop { return $ this -> shop -> find ( $ id ) ; } ?> 4. InterfaceのDI インターフェースと実装の紐付けは、Laravel のサービスコンテナを活用して行っています。これにより、テナントごとに異なる実装を柔軟に切り替えることが可能です。 <?php namespace DashboardApi\Providers; use Illuminate\Support\ServiceProvider; use Sample\Common\Services\Interfaces\ArticleServiceInterface; use Sample\Common\Repositories\Interfaces\ArticleRepositoryInterface; use DashboardApi\Services\ShopService; use DashboardApi\Repositories\ShopRepository; class AppServiceProvider extends ServiceProvider { public function register () : void { // Service層のDI設定 $ this -> app -> bind ( ShopServiceInterface :: class , ShopService :: class ) ; // Repository層のDI設定 $ this -> app -> bind ( ShopRepositoryInterface :: class , ShopRepository :: class ) ; } } ?> 将来的にはテナントごとにDIを切り替えることで、テナントごとに異なるビジネスロジックを持たせるような想定をしています。 <?php public function register () : void { $ this -> app -> bind ( ShopServiceInterface :: class , function ( $ app ) { // テナントに応じて実装を切り替え return match ( $ tenant ) { 'tenant_a' => new TenantAShopService ( $ app -> make ( ShopRepositoryInterface :: class ) ) , 'tenant_b' => new TenantBShopService ( $ app -> make ( ShopRepositoryInterface :: class ) ) , default => new ShopService ( $ app -> make ( ShopRepositoryInterface :: class ) ) , } ; }) ; } ?> 現状の課題と向き合い方 現在、開発を始めて間もない段階ですが、いくつかの課題が見えてきています。 当初は管理画面APIとモバイルアプリAPIで多くのビジネスロジックを共通化できると考えていましたが、実際には想定より共通化できる範囲が限定的でした。 現時点では主にデータベースのモデル定義とマイグレーションの共通化に留まっており、より効果的なロジックの共通化方法を模索しています。 細かいですが、開発環境については、現在各APIプロジェクトで個別に環境を立ち上げる必要があり、一つのdocker composeで統合的に管理できる環境の整備を検討しています。 チーム開発での課題については、スピード重視の開発という前提において、挑戦と学習、および品質との丁度良いバランスについて日々議論しています。 例えば、 少人数チームで初期開発フェーズにおいてテストコードの適切な粒度 クラス設計の責務分担 必要十分なドキュメント整備の範囲 フローを固めすぎない開発プロセス など、少人数チームならではの密なコミュニケーションを取りながら方針を固めています。 まとめ 今回は私たちが初めて取り組むLaravelでのバックエンド開発について、開発方針を踏まえた構成のアプローチをご紹介させていただきました。 最後に、本記事が同じようにLaravelでの開発を検討されている方々の参考になれば幸いです。 また、私たちは常に新しい技術的チャレンジに取り組める仲間を募集しています。私自身も新しい技術にチームで試行錯誤しながら取り組める環境で、日々成長を感じています。少しでも面白そうと感じていただけた方はぜひお気軽にお声がけください! 参考記事
アバター
はじめに デリッシュキッチン開発部のバックエンド中心で開発をしている @きょー です。 この記事では、普段業務で Postman を使っていく際に便利だと思った機能について紹介します。 Postman とは Postman は、API の構築・利用を支援する API プラットフォームです。API リクエストの作成・保存、リクエスト送信後のレスポンス確認といった用途で利用されることが多いですが、他にも様々な機能があります。 Postman で開発効率を上げるための便利機能 変数 変数を使う前後の差分を先に示します。 変数を使う前 変数を使った後 変数の設定画面 変数を設定・使用することで同じ値として使い回されることが多い URL のドメイン部分や Header の情報などを一箇所で管理できるようになります。 また、開発環境や本番環境用の変数を設定することで簡単にリクエストの向き先を変えることもできます。 環境ごとに変数を作成 環境の変え方 Postman の変数にもスコープという概念が存在し、広いものから順に、Global、Collection、Environment、Data、Local となっています。 上記で紹介した環境ごとの変数は Environment 変数になります Postman における Collection と Environment の違いが分かりずらいので補足です。 Collection は API リクエストをグループ化するための機能で使われ、Environment はグルーピングされたリクエスト群に対して環境を変えるために使われることが多い認識です。 変数のスコープ learning.postman.com ここで実際に使われることが多かった 3 つの変数の特徴と実際の使用例について紹介しようと思います。 Global 変数 特徴 Postman 全体で有効な変数 どのコレクションやリクエストからも参照可能で環境に依存しない値を保持するのに適している 実際の使用例 複数コレクション(=システム)で共有する認証情報 Collection 変数 特徴 特定のコレクション内でのみ有効な変数 コレクション内のリクエスト間で値を共有するのに適している 実際の使用例 環境を問わないリクエストパラメータ device や os の バージョン Environment 変数 特徴 特定の環境 (開発環境、テスト環境、本番環境など) のみで有効な変数 API の domain 部分や認証情報などの環境ごとに異なる値を設定するのに適している 実際の使用例 API の domain 部分 認証情報 詳しい説明は 公式ドキュメント をご覧ください。 script Postman では、リクエストの前後に JavaScript のコードを実行できます。 これらはそれぞれ pre-request script 、 post-response script と呼ばれるもので、実行順序は以下の画像のようになっています。 実行順序 試しに前のセクションで設定した環境変数である X-Foods-Token を自動で更新する script を書いてみましょう。手順は以下の通りです。 X-Foods-Token に設定される値を取得(リクエスト先:/signup) レスポンスから token を抽出 抽出した token を X-Foods-Token に設定 X-Foods-Token を自動で更新する post-response script 以下のように pm オブジェクトを通して Postman が提供している機能を使用することができます。 const response = pm . response . json () ; pm . environment . set ( "X-Foods-Token" , response . token ) ; 他にもリクエスト URL、Header、リクエストパラメータなどを動的に生成・変更したり、テストデータを準備するために外部ファイルから読み込むことなどもできます。 また、一つのリクエストだけでなく Collection に対して script を設定することもできるみたいです。Collection 全体で実行したい処理がある場合にはとても便利な機能です。 各scriptの実行順序 詳しい説明は 公式ドキュメント をご覧ください。 Postman へのインポート機能 Postman では cURL 形式のリクエストや OpenAPI 仕様で作成されたドキュメント等の取り込み・管理ができます。 管理できるようになることで簡単に API リクエストの保存・リクエストパラメータの変更ができるようになります。 それでは実際に二つの形式で取り込んでみましょう。 cURL curl --location --request GET 'http:/localhost:8080/foods' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --header 'X-Foods-Token: foods-token' \ 上記のリクエストをエンドポイントを書く場所にコピペしてみましょう。 cURL形式のリクエストを取り込む 正常に import できると下の画像のように cURL リクエストを Postman に取り込むことができます。 リクエスト先、ヘッダー、リクエストパラメータがすべて取り込まれていることがわかります。 cURL形式のリクエストを取り込んだ後 同じように書き出し Postman で管理されてあるリクエストを cURL 形式で書き出したり Go や Rust での書き出しもできるようでした。 OpenAPI 仕様書 sample ファイルは こちら のものを使います。 yaml ファイルを Postman の上にドラッグドロップすると import ができるので、そのまま import を続行してください。 正常に import できると下の画像のように OpenAPI 仕様書を Postman に取り込むことができるかと思います。 OpenAPI 仕様書を取り込んだ後 最後に 今回は Postman についての Tips について紹介させていただきました。 これらの整備をしたから最高に開発しやすくなる!!みたいなことはないと思いますが、普段の業務の 1% くらいは開発しやすくできたのではないでしょうか?この記事が皆様のお役に立てたなら幸いです! 参考 https://www.postman.com/ PS Postman のテーマを変えられるので自分の色に染めたっていい!! テーマ変えられるよ 小学生の頃にハマったゲームの一つにパタポンというものがあります。単純なリズムゲーではあるのですが、キャラクターデザインやサウンドトラック、ゲーム性など色々なポイントが当時の自分に刺さりまくり 時間を溶かした 楽しませてもらった作品です。なんとこの度、完全なる続編ではないのですが手掛けたクリエイターによる新作が今年リリースされるということで震えが止まりません!! ♪ ラッタ!ラッタ!ラッタッタ! kickstarter.ratatan.jp
アバター
こんにちは。 開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。 最近はAIプロダクト開発をメインで担当しています。 今回は、Databricks Asset Bundlesを活用して、AIプロダクト開発向けにCI/CDパイプラインを整備した内容をまとめます。 Databricks Asset Bundlesとは Databricks Asset Bundlesは、データやAIプロジェクトでソフトウェア開発におけるソース管理、コードレビュー、テスト、CI/CDなどを導入しやすくするツールです。 簡単に言えば、Databricksの各種リソースをInfrastructure-as-Code(IaC)として管理できます。 ノートブックをはじめとするソースコードやDatabricks上で動かすJobなどのリソースを、ymlファイルで定義できます。 docs.databricks.com 図1: Asset Bundlesを使用した開発およびCI/CDパイプライン 動機 社内の多くのDatabricks用途は、 メダリオンアーキテクチャを踏襲したデータ基盤 です。 例えば、ダッシュボードで参照するデータのETL、A/Bテスト結果の集計、バッチ推論ベースのMLモデルの学習・推論・デプロイなどがあります。 一方でAIプロダクト文脈では、モデルの学習はバッチ処理ですが、推論はリアルタイム処理、モデルのデプロイ( Model Serving の機能を利用)はソースコードを更新したタイミングなことが多いのではないでしょうか。 特にLLMの場合は、OpenAIなどのAPIを利用することが多いため、モデルの学習自体不要です。 つまり、推論とデプロイに重点を置いた開発がメインになります。 この開発・運用上のギャップを埋めたい思いがありました。 これらのギャップを、As-Is(現状)とTo-Be(理想)としてまとめると以下のようになりました。 No. 項目 As-Is To-Be 理由 1 ソースコード管理 pythonノートブック pythonファイル コードの再現性・テストの導入のしやすさを目指し、モジュール単位でコード管理したいため 2 ワークスペース Default workspace(ap-northeast-1)のみ Default workspace(ap-northeast-1)とTest workspace(us-east-1) 推論にLLMを利用することが多く、LLMに対する評価のフィードバックを得ながら開発・運用したいため(us-east-1の方がアップデートが早く、LLMを評価する機能が利用可能) 3 処理タイミング スケジューリングされたバッチ処理 githubのpushをトリガーとした処理 CI/CDパイプラインを用いて、テストの実行、モデルの保存と評価、Model Servingへのデプロイを自動化したいため 全体像 図1を参考に、今回のsample-project用のCI/CDパイプラインを作成しました(図2)。 右上のDatabricks workspacesを一部変更しています。 ワークスペースは2つのregion(ap-northeast-1、us-east-1)を利用します。 Default workspace(ap-northeast-1) Test workspace(us-east-1) 図2: sample-project用のAsset Bundlesを使用した開発およびCI/CDパイプライン コードは以下のような構成で進めます。 sample-project ├── .github │ └── workflows │ ├── _deploy_databricks.yml │ └── sample_project_cd.yml ├── bundles │ ├── resources │ │ └── job_deploy_sample_project_model.yml │ └── targets │ ├── default_databricks.yml │ └── test_databricks.yml ├── src │ ├── sample_project │ │ ├── __init__.py │ │ ├── pipeline.py │ │ ├── rag.py │ │ └── model.py │ └── deploy_model.py ├── tests └── databricks.yml 実装してみる 1. pythonファイルによるソースコード管理 モジュール メイン機能となるモジュール群は src/sample_project/* に置きます。 rag.py RAGの機能を有したモジュールを定義します。 今回は、シンプルなRAGクラスを定義し、OpenAIのAPIを利用して回答を生成するコードを記述しています。 from openai import OpenAI class RAG : def __init__ (self): self.llm = OpenAI() def generate (self, query, contexts): context_str = " \n " .join([f "- {context['content']}" for context in contexts]) messages = [ { "role" : "system" , "content" : "You are a helpful assistant." }, { "role" : "user" , "content" : f "Context: \n {context_str} \n\n Query: {query}" }, ] completion = self.llm.chat.completions.create( model= "gpt-4o-mini" , messages=messages, ) answer = completion.choices[ 0 ].message.content return answer def retrieve (self, query): query_embedding = self.llm.embeddings.create( input =query, model= "text-embedding-3-large" , ) contexts = self.retieve_databricks_vector_store(query_embedding) return contexts def retieve_databricks_vector_store (self, query_embedding): # query_embeddingをもとにベクトル検索したコンテキスト情報を返す # ここではダミーのコンテキスト情報を返す contexts = [ { "doc_uri" : "doc1.txt" , "content" : "In 2013, Spark, a data analytics framework, was open sourced by UC Berkeley's AMPLab." }, { "doc_uri" : "doc2.txt" , "content" : "To convert a Spark DataFrame to Pandas, you can use toPandas()" }, ] return contexts pipeline.py ユーザのクエリを受け取り、LLMの回答を返すパイプラインを定義します。 from sample_project.rag import RAG def generate_answer (query): rag = RAG() contexts = rag.retrieve(query) answer = rag.generate(query, contexts) return answer model.py pipelineを利用するmlflow.pyfunc.PythonModelを継承したクラスを定義します。 このクラスは、モデルの保存時に指定することで、python modelとしてMLflowのモデルとして保存され、Model Servingで利用できるようになります。 import mlflow from sample_project.pipeline import generate_answer class SampleAI (mlflow.pyfunc.PythonModel): def predict (self, context, model_input): query = model_input[ 'query' ][ 0 ] return generate_answer(query) テスト テストは tests/* に置きます。 LLMによる生成が絡む処理のテストは難しいですが、前処理や後処理などのテスト可能なコードに対しては単体テスト書く想定です。 今回のサンプルコードでは前処理や後処理はないですが、要件が複雑化していくと必要になってくるかと思います。 今までの開発では、ノートブックかつDatabricksのコンソール上での開発だったため、モジュール単位の実装やテストコードが書きづらい問題がありました。 しかし、pythonファイルによるモジュール化ができたことで、モジュール単位に実装が容易になりました。 これにより、コーディングや単体テストはローカルで行い、Spark、Mlflow、Unity CatalogなどDatabricksのメイン機能を利用する開発はコンソール上で行う、といった開発フローができるようになりました。 モデルの保存と評価 モデルの保存と評価は deploy_model.py に記述します。 このコードは例外的にノートブックで管理します。 理由としては 将来的に自作のパッケージをinstallするとき、動的に pip install できることが便利なため MLflow Tracing を利用したため などがあります。 色々記述していますが、重要なのは mlflow.start_run() で実行される、モデルの保存と評価コードです。 保存 mlflow.pyfunc.log_model で、モデルの保存をしています。 このとき引数にcode_pathsを指定することで、モデルと一緒に該当のソースコードを保存することができます。 注意点として、 MLflowのドキュメント にある通り、code_pathsは親ディレクトリを見ることができない仕様になっているようです。 そのため、以下のようなディレクトリ構成にし、 code_paths=["sample_project"] を指定しています。 ├── sample_project └── deploy_model.py この問題に関しては、 MLflowのIssue でも言及されており、今後仕様が変わる可能性があります。 評価 mlflow.evaluate で、モデルの評価をしています。 このとき、 model_type="databricks-agent" を指定することで、 Mosaic AI Agent Evaluationに組み込まれているAI審査員機能 (一般的にはLLM as a Judgeと呼ばれる審査員用のLLMがプロダクトのLLMを評価する機能)を利用することができます。 今回は以下のAI審査員を指定しています。 correctness : エージェントの実際の応答がground truth(expected_response)と比較して誤っていないことを保証 relevance_to_query : エージェントの応答が無関係なトピックに逸脱することなくユーザーの入力に直接対処することを保証 safety : エージェントの応答に有害、攻撃的、または有毒な内容が含まれていないことを保証 %load_ext autoreload %autoreload 2 # COMMAND ---------- %pip install --upgrade mlflow cloudpickle databricks-vectorsearch databricks-agents openai tiktoken %restart_python # COMMAND ---------- dbutils.widgets.text( "model_env" , "dev" ) dbutils.widgets.text( "workspace_url" , "https://{sub-domain}.cloud.databricks.com" ) model_env = dbutils.widgets.get( "model_env" ) workspace_url = dbutils.widgets.get( "workspace_url" ) # COMMAND ---------- import os os.environ[ "OPENAI_API_KEY" ] = dbutils.secrets.get(...) os.environ[ "DATABRICKS_VECTOR_SEARCH_HOST" ] = "https://{sub-domain}.cloud.databricks.com" os.environ[ "DATABRICKS_VECTOR_SEARCH_TOKEN" ] = dbutils.secrets.get(...) # COMMAND ---------- import mlflow from sample_project.model import SampleAI # DatabricksのUnity Catalogを利用するための設定 mlflow.set_registry_uri( "databricks-uc" ) # COMMAND ---------- # Unity Catalogで登録するモデル名 model_name = ( "{catalog}.{schema}.sample_project_model" if model_env == "prd" else "{catalog_dev}.{schema}.sample_project_model" ) # 実験管理先を設定 mlflow_experiment_name = '/Shared/experiments/sample_project' mlflow.set_experiment(mlflow_experiment_name) # COMMAND ---------- from mlflow.models import ModelSignature from mlflow.types.schema import Schema, ColSpec input_schema = Schema([ColSpec( "string" , "query" )]) output_schema = Schema([ColSpec( "string" , "answer" )]) signature = ModelSignature(inputs=input_schema, outputs=output_schema) # COMMAND ---------- # https://docs.databricks.com/ja/generative-ai/agent-evaluation/index.html # 今回は評価データは直に書く import pandas as pd eval_df = pd.DataFrame({ "request" : [ { "query" : "What is Spark?" }, { "query" : "How do I convert a Spark DataFrame to Pandas?" } ], "expected_response" : [ "Spark is a data analytics framework." , "To convert a Spark DataFrame to Pandas, you can use the toPandas() method." , ] }) display(eval_df) # COMMAND ---------- # モデルの保存・評価 with mlflow.start_run(): model_info = mlflow.pyfunc.log_model( artifact_path= "model" , python_model=SampleAI(), signature=signature, registered_model_name=model_name, code_paths=[ "sample_project" ], ) # test-databricks環境でのみ評価 if "test" in workspace_url: mlflow.evaluate( model=generate_answer, data=eval_df, model_type= "databricks-agent" , evaluator_config={ "databricks-agent" : { "metrics" : [ "correctness" , "relevance_to_query" , "safety" , ] } } ) 2. 複数のregionに分けてワークスペースを運用 図1をはじめ、Databricksのドキュメントでは、Development、Staging、Productionごとにワークスペースを分けた運用例が多いです。 しかし、弊社では、1つのワークスペースでdev/prdをwidgetsで切り替えて運用しているケースがほとんどです。 例えば、 dbutils.widgets.text( "model_env" , "dev" ) と書くと、model_envという名前のwidgetsが作成され、 model_env = dbutils.widgets.get( "model_env" ) と書くと、model_envの値を取得できます。 以降、model_envの値によって参照するデータソースを変えるなど、処理の分岐をさせることができます。 Databricks Asset Bundlesは、上記のようなケースでも柔軟に対応することができました。 ここでは、冒頭の全体像で述べたようにDefault workspaceとTest workspaceの2つのワークスペースを利用します。 Default workspace : 主要なワークスペースで、regionはap-northeast-1を使用。 Model ServingやVector Storeなどを運用する。 Test workspace : テスト用のワークスペースで、regionはus-east-1を使用。 モデルの保存時に、AI審査員によるLLMの評価を行う。 ここではDevelopment、Staging、Productionを、それぞれ dev 、 stage 、 prd とし、以下のような意味を持つとします。 図2と合わせて参照ください。 dev : 個人開発用。 ワークスペースのName(自分の名前)ディレクトリに反映される。 ローカル から、コードやJobの設定などのリソースをデプロイするときに使用する。 stage : 開発用。 ワークスペースのStagingディレクトリに反映される。 作業ブランチ→developブランチ にpushした時に、CI/CDでコードやJobの設定などのリソースをデプロイするときに使用する。 prd : 本番用。 ワークスペースのProductionディレクトリに反映される。 developブランチ→masterブランチ にpushした時に、CI/CDでコードやJobの設定などのリソースをデプロイするときに使用する。 .databrickscfg ~/.databrickscfg にワークスペースの設定を記述します。 この例では、パーソナルアクセストークン(PAT)の認証方法を利用しています。 databricksの認証に関しての詳細は こちら を参照してください。 [DEFAULT] host = https://{default-databricks-subdomain}.cloud.databricks.com token = dapi11111111111111111111111111111111 [TEST] host = https://{test-databricks-subdomain}.cloud.databricks.com token = dapi22222222222222222222222222222222 databricks.yml Databricks Asset Bundlesの各種設定を databricks.yml に記述します。 ここでは、ymlファイルの見通しをよくするために、resourcesとtargetsを別のファイルに分離しています。 dev 、 stage 、 prd のpathは、それぞれは使いまわしやすいようにvariablesに定義しています。 bundle : name : sample-project variables : dev_file_path : description : "The path to the development" default : /Repos/${workspace.current_user.userName}/${bundle.name} stage_file_path : description : "The path to the staging" default : /Repos/Staging/${bundle.name} prd_file_path : description : "The path to the production" default : /Repos/Production/${bundle.name} run_as : user_name : ${workspace.current_user.userName} include : - "bundle/resources/*.yml" - "bundle/targets/*.yml" resources JobなどのDatabricksのリソースを定義するファイルを resources/*.yml に記述します。 他にも設定できるリソースに関しては こちら を参照してください。 resourcesの設定は全てのtargetsで共通のため、defaultでは dev の設定で記述します。 これらの設定は、targetsの設定で 上書き することができます。 ここでは、モデルの保存と評価を実行する deploy_sample_project_model というJobを定義しています。 このJobを実行することで、モデルの保存と評価をCI/CDのパイプラインに組み込むことができます。 resources : jobs : deploy_sample_project_model : name : deploy_sample_project_model tasks : - task_key : deploy_model notebook_task : notebook_path : ${var.dev_file_path}/src/deploy_model source : WORKSPACE base_parameters : model_env : dev workspace_url : "{{workspace.url}}" job_cluster_key : deploy_model_cluster job_clusters : - job_cluster_key : deploy_model_cluster new_cluster : spark_version : 15.4.x-cpu-ml-scala2.12 aws_attributes : first_on_demand : 0 availability : SPOT zone_id : auto instance_profile_arn : arn:aws:iam::123456789101:instance-profile/databricks_shared-instance-profile spot_bid_price_percent : 100 ebs_volume_count : 0 node_type_id : r7gd.large enable_elastic_disk : false data_security_mode : SINGLE_USER runtime_engine : STANDARD autoscale : min_workers : 1 max_workers : 2 permissions : - group_name : dai-engineer level : CAN_MANAGE queue : enabled : false targets 各環境ごとの設定を targets/*.yml に記述します。 ここでは、Default workspace(default_databricks)と Test workspace(test_databricks)の設定を記述しています。 default_databricks.yml dev 、 stage 、 prd の3つの環境を定義しています。 dev をdefaultに設定し、 stage と prd の設定は必要な箇所を上書きしています。 run_as CI/CD用のサービスプリンシパルを指定 指定するサービスプリンシパルは stage と prd で同様 resources jobの設定を上書き stage では、notebook_pathを開発用に上書き prd では、notebook_pathとbase_parametersを本番用に上書き targets : dev : mode : development default : true workspace : host : https://{default-databricks-subdomain}.cloud.databricks.com file_path : ${var.dev_file_path} stage : mode : production workspace : host : https://{default-databricks-subdomain}.cloud.databricks.com file_path : ${var.stage_file_path} run_as : service_principal_name : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" resources : jobs : deploy_sample_project_model : name : "[${bundle.target}] deploy_sample_project_model" # notebook_pathを開発用に上書き tasks : - task_key : deploy_model notebook_task : notebook_path : ${var.stage_file_path}/src/deploy_model source : WORKSPACE prd : mode : production workspace : host : https://{default-databricks-subdomain}.cloud.databricks.com file_path : ${var.prd_file_path} run_as : service_principal_name : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" resources : jobs : deploy_sample_project_model : name : deploy_sample_project_model # notebook_pathとbase_parametersを本番用に上書き tasks : - task_key : deploy_model notebook_task : notebook_path : ${var.prd_file_path}/src/deploy_model source : WORKSPACE base_parameters : model_env : prd workspace_url : "{{workspace.url}}" test_databricks.yml default_databricks.yml とほとんど同じ設定です。 Test workspace固有の設定をする場合、こちらに記述します。 targets : test-dev : mode : development workspace : host : https://{test-databricks-subdomain}.cloud.databricks.com file_path : ${var.dev_file_path} test-stage : mode : production workspace : host : https://{test-databricks-subdomain}.cloud.databricks.com file_path : ${var.stage_file_path} run_as : service_principal_name : "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" resources : jobs : deploy_sample_project_model : # notebook_pathを開発用に上書き name : "[${bundle.target}] deploy_sample_project_model" tasks : - task_key : deploy_model notebook_task : notebook_path : ${var.stage_file_path}/src/deploy_model source : WORKSPACE test-prd : mode : production workspace : host : https://{test-databricks-subdomain}.cloud.databricks.com file_path : ${var.prd_file_path} run_as : service_principal_name : "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" resources : jobs : deploy_sample_project_model : # notebook_pathとbase_parametersを本番用に上書き name : deploy_sample_project_model tasks : - task_key : deploy_model notebook_task : notebook_path : ${var.prd_file_path}/src/deploy_model source : WORKSPACE base_parameters : model_env : prd workspace_url : "{{workspace.url}}" 3. pushのタイミングで処理を実行 sample_project_cd.yml CI/CDのパイプラインをGithub Actionsで sample_project_cd.yml に記述します(テストなどは sample_projet_ci.yml を追加想定)。 このパイプラインは、developブランチとmasterブランチに、それぞれpushされたタイミングで処理します。 dev は個人開発用のため、CI/CDのパイプラインでは stage と prd のみ記述します。 name : CD sample-project on : push : branches : - master - develop jobs : deploy_test_stage : if : ${{ github.ref_name == 'develop' }} uses : ./.github/workflows/_deploy_databricks.yml with : targets : test-stage deploy_model_name : deploy_sample_project_model secrets : DATABRICKS_CLIENT_ID : ${{ secrets.TEST_DATABRICKS_CLIENT_ID }} DATABRICKS_SECRET : ${{ secrets.TEST_DATABRICKS_SECRET }} deploy_stage : if : ${{ github.ref_name == 'develop' }} uses : ./.github/workflows/_deploy_databricks.yml with : targets : stage deploy_model_name : deploy_sample_project_model secrets : DATABRICKS_CLIENT_ID : ${{ secrets.DEFAULT_DATABRICKS_CLIENT_ID }} DATABRICKS_SECRET : ${{ secrets.DEFAULT_DATABRICKS_SECRET }} deploy_test_prd : if : ${{ github.ref_name == 'master' }} uses : ./.github/workflows/_deploy_databricks.yml with : targets : test-prd deploy_model_name : deploy_sample_project_model secrets : DATABRICKS_CLIENT_ID : ${{ secrets.TEST_DATABRICKS_CLIENT_ID }} DATABRICKS_SECRET : ${{ secrets.TEST_DATABRICKS_SECRET }} deploy_prd : if : ${{ github.ref_name == 'master' }} uses : ./.github/workflows/_deploy_databricks.yml with : targets : prd deploy_model_name : deploy_sample_project_model secrets : DATABRICKS_CLIENT_ID : ${{ secrets.DEFAULT_DATABRICKS_CLIENT_ID }} DATABRICKS_SECRET : ${{ secrets.DEFAULT_DATABRICKS_SECRET }} _deploy_databricks.yml sample_project_cd.yml で利用するデプロイのパイプラインを _deploy_databricks.yml に記述します。 このパイプラインは、 databricks/setup-cli を利用し、Databricks Asset Bundleを使ってリソースをデプロイします。 databricks bundle deploy でリソースをデプロイし、 databricks bundle run でデプロイしたコードをJobとして実行します。 name : deploy databricks on : workflow_call : inputs : targets : required : true type : string deploy_model_name : required : true type : string secrets : DATABRICKS_CLIENT_ID : required : true # service_principal_nameと同じ DATABRICKS_SECRET : required : true jobs : deploy_resources : name : "Deploy resources" runs-on : ubuntu-latest steps : - uses : actions/checkout@v4 - uses : databricks/setup-cli@main - run : databricks bundle deploy -t ${{ inputs.targets }} working-directory : . env : DATABRICKS_CLIENT_ID : ${{ secrets.DATABRICKS_CLIENT_ID }} DATABRICKS_CLIENT_SECRET : ${{ secrets.DATABRICKS_SECRET }} deploy_model : name : "Deploy model" runs-on : ubuntu-latest needs : - deploy_code steps : - uses : actions/checkout@v4 - uses : databricks/setup-cli@main - run : databricks bundle run ${{ inputs.deploy_model_name }} -t ${{ inputs.targets }} working-directory : . env : DATABRICKS_CLIENT_ID : ${{ secrets.DATABRICKS_CLIENT_ID }} DATABRICKS_CLIENT_SECRET : ${{ secrets.DATABRICKS_SECRET }} 結果 Jobの実行結果 test_databricks.ymlの設定を利用する場合は、targetsには test-dev 、 test-stage 、 test-prd などを指定します。 test-dev はローカルから、 test-stage と test-prd はGithub Actionsから実行します。 そのため、Created byを見ると、 test-dev のみユーザ名であり、 test-stage と test-prd はCI/CDのサービスプリンシパル名であることが確認できます(図3)。 図3: 作成されたJob一覧 Jobの画面に進むと実行結果も確認できます(図4)。 resourcesに記述した各種設定内容もここで確認できます。 正直、Jobの設定をコード管理できるという時点で感無量です。 図4: deploy_sample_project_modelのJob設定画面 モデルの評価の結果 モデルの評価結果は、 mlflow.evaluate で指定したAI審査員によって判定されます。 今回評価データとして設定した、「What is Spark?」と「How do I convert a Spark DataFrame to Pandas?」は、contextに含まれるため、AI審査員をPassすることができました(図5)。 図5: AI審査員評価 AI審査員の評価結果の詳細は以下のとおりです(図6)。 Responseに書かれているModel outputとExpected outputの文章は一致していなくても、回答の文脈として合っているかで評価されていることがわかります。 Correct 期待される回答は「Sparkはデータ分析フレームワークである」と述べている。 回答はSparkを「暗黙のデータ並列性とフォールトトレランスを備えたクラスタ全体をプログラミングするためのインタフェースを提供するオープンソースのデータ処理フレームワーク」と説明している。 回答では「データ分析フレームワーク」という用語は明確に使われていないが、SQLクエリ、機械学習、グラフ処理、ストリーム処理などのタスクを含むデータ処理のために設計されたフレームワークとしてSparkを説明している。 これらのタスクは一般的にデータ分析に関連している。したがって、期待される回答がサポートされている。 Relevant 質問は「Sparkとは何か?回答は、Sparkの目的、起源、特徴、サポートされているプログラミング言語など、Sparkとは何かについて詳しく説明している。 解答のすべての部分が、Sparkとは何かを理解するのに関連している。 Safe 回答に有害なコンテンツが検出されない 図6: AI審査員評価詳細 ここで、contextになく、gpt-4o-miniの学習データに含まれない、最新の情報を問う質問を評価データに加えてみることにします。 今回は、巷で話題の2025/02/02に発表された OpenAIのDeep research に関して質問してみます。 ground truthであるexpected_responseの文章は、発表時の文章をgpt-4oで要約させて作成しました。 eval_df = pd.DataFrame({ "request" : [ { "query" : "What is Spark?" }, { "query" : "How do I convert a Spark DataFrame to Pandas?" }, { "query" : "What is Deep research" }, ], "expected_response" : [ "Spark is a data analytics framework." , "To convert a Spark DataFrame to Pandas, you can use the toPandas() method." , "Deep research is a ChatGPT feature that autonomously conducts multi-step web research, generating detailed, cited reports in minutes. It’s ideal for in-depth inquiries, leveraging OpenAI’s o3 model for advanced analysis and synthesis." , ] }) 図7からわかるとおり「What Is Deep research」というクエリで、CorrectnessがFailとなりました。 「Deep research」を「深掘った調査」という意味合いとして回答してしまっています。 図7: Deep researchに関して質問した場合のAI審査員評価 Deep researchに関して質問した場合のAI審査員の評価結果の詳細は以下のとおりです(図8)。 なぜFailとなったかを説明してくれています。 Incorrent Deep researchはChatGPTの機能で、多段階のweb調査を自律的に行い、詳細な引用レポートを数分で作成する。 OpenAIのo3モデルを活用し、高度な分析と合成を行うため、詳細な調査に最適である。 この回答では、「Deep research」とは、広範なデータ収集と分析、批判的思考、情報の統合を含む、特定の分野における徹底的かつ集中的な調査または研究であると説明している。 これには、包括的な文献調査、方法論の厳密さ、革新的な問題解決、学際的アプローチ、縦断的研究、知識への貢献などが含まれる。 この回答には、ChatGPT、自律的なweb調査、数分でレポートを作成すること、OpenAIのo3モデルの活用については何も言及されていない。したがって、期待される回答は回答によってサポートされていない。 Relevant 設問は「Deep research」について尋ねており、回答は、包括的な文献レビュー、方法論の厳密さ、革新的な問題解決、学際的アプローチ、縦断的研究、知識への貢献といった側面を含む、深い研究とは何かを詳細に説明している。 これらの点はすべて、深い研究とは何かを理解するのに関連している。 Safe 回答に有害なコンテンツは検出されない 図8: Deep researchに関して質問した場合のAI審査員評価詳細 なお、RelevanceやSafetyをPassしているのは、expected_responseと比較して評価していないためです。 「Deep research」を「深掘った調査」と回答したとしても、質問の回答としては関連性がある答えのため、RelevanceをPassしているのだと考えられます。 おわりに Databricks Asset Bundlesを使って、モデルの保存と評価をCI/CDパイプラインとして組み込むまでの一連の流れを紹介しました。 今回はモデルのデプロイの自動化に関しては取り組めませんでした。 ここも、Model Servingへのデプロイ処理を deploy_model_serving.py のように作成し、Jobに設定を追加するようにすれば、同様のパイプラインを組み込むことができると思います。 モデルの評価では、AI審査員について紹介しました。 個人的に、このLLMの評価を実運用に乗せるには、各AI審査員がどのような評価しているかの特性に対して理解が必要だと感じました。 他にもretrieval観点で評価するAI審査員もおり、LLMの評価には様々な観点を考える必要がありそうです。 LLMの評価に関しては、また別の機会にまとめたいと思います。 また、今回は日本語で評価しませんでしたが、日本語でも概ね精度は変わらない印象です。 ただ、AI審査員の回答は英語のため、Pass/Failとなった説明に日本語と英語が入り混じることになり、文章として読みづらい感は否めません。 ap-northeast-1のパブリックプレビューと同時に、日本語で回答するAI審査員の登場に期待です。
アバター
はじめに Android 開発エンジニアを担当している岡田です。 弊社のサービスであるヘルシカにて、Material You Design のアイコンを実装しました。 今回は Material You Design とアイコンの実装についてご紹介したいと思います。 Material You Design Android 12 から導入された Material You Design は、Android 端末の画面を自分好みの色合いやデザインに変える機能です。 2014 年に発表した Material Design をさらにアップデートし、機能性だけでなく、ユーザーがスマホをより自分らしくカスタマイズできるようになりました。 壁紙の色を基調としたカラーパレットが自動的に生成され、その中から配色を選ぶことで、手軽に自分好みのカスタマイズが可能です。 ウィジェットやアイコン、クイック設定など OS 側のアイテムはもちろん、アプリ内でも自分で選択した色を取得して使用することができます。 Material You Design の 3 つのテーマ Material You Design には 3 つのテーマがあります。 Personal for every style (あらゆるスタイルに対応するパーソナルなデザイン) ユーザーが壁紙やテーマカラーを選択すると、それに基づいてアプリのUI全体が動的に変化し、ユーザーは自分だけのオリジナルなデザイン体験を楽しむことができます。 Alive & Adaptive for every screen (あらゆる画面に対応し、あらゆるデバイスにフィットするデザイン) レスポンシブデザインをさらに進化させ、様々な画面サイズやデバイスに最適なレイアウトを自動的に生成します。 ユーザーはどのデバイスを使っても、快適な操作性を体験できます。 Accessible for every need (あらゆるニーズに対応するアクセシブルなデザイン) ユーザーの視覚、聴覚、運動能力など、様々なニーズに対応したアクセシビリティ機能を提供します。 より多くの人がデジタルの世界に参加し、情報やサービスを利用できるようにします。 つまり、Material You Design は 多様性を担保した新しいデザインスタイル です。 Material You Design の活用例 Google アプリ Google の多くのアプリが、Material You Design を採用することで、より洗練されたユーザーインターフェースを実現しています。 Material You Design は、単なるデザインシステムにとどまらず、ユーザーとデバイスの関係性を再定義する新しいパラダイムと言えるでしょう。 以下は Google 公式がリリースしている電卓アプリのスクリーンショットです。 引用元: Material You とは? スマホのホーム画面などを自分好みにカスタマイズしよう 著作権者:Google LLC Google Pixel スマートフォン Material You Designは、Google Pixel スマートフォンで最も顕著に見ることができます。 壁紙の色調がシステム全体の色調に反映されるなど、パーソナライゼーションが特徴です。 自身の端末 (端末:Pixel6a / OS:Android14) で試した見たところ、[設定 > 壁紙とスタイル] から簡単に設定できました。 ウィジェットや対応済みのアプリアイコン、電卓アプリでのテーマの変化を確認できます。 設定 > 壁紙とスタイル Home画面 電卓アプリ 対応していないアプリアイコンは Home 画面で浮いてしまう可能性 Material You Design でアイコンの色を変える「テーマアイコン」に対応していないアプリは、Home 画面で浮いてしまう可能性があります。 現時点でこの機能を仕様しているユーザーに、Home にアイコンを置くことを避けられる可能性があるということです。 例えば上記の Home 画面の画像は、意図的に Material You Design 対応を外したヘルシカのアイコンが写っています。ヘルシカのみ真っ白のアイコンなため、画面内で浮いていることがわかると思います。 アプリの Style までとはいかずとも、アプリアイコンのみでも Material You Design に対応しておきたいです。 では Material You Design にアイコンを対応させるには、どのような実装をすれば良いのでしょうか。 Material You Design のアイコン対応 ic_launcher.xml に、 monochrome を追加します。 <? xml version = "1.0" encoding = "utf-8" ?> <adaptive-icon xmlns : android = "http://schemas.android.com/apk/res/android" > <background android : drawable = "@drawable/ic_app_background_icon" /> <foreground android : drawable = "@drawable/ic_app_foreground_icon" /> <monochrome android : drawable = "@drawable/ic_app_monochrome_icon" /> </adaptive-icon> アダプティブアイコンに対応していない場合は、先に対応する必要があるのでそこに注意です。 ヘルシカは Material You Design のアイコンに対応しました。 とても簡単に実装できるので、まだ対応していない場合は是非対応してみてください。 参考記事 https://www.android.com/intl/ja_jp/articles/322/#section-1 https://m3.material.io/blog/announcing-material-you https://developer.android.com/develop/ui/views/launch/icon_design_adaptive?hl=ja
アバター