TECH PLAY

株式会社エブリー

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

385

はじめまして、データストラテジストのoyabuです。 RedashからCSVでデータをエクスポートして、GoogleDriveに保存、更にCSVをSpreadsheet化してようやく可視化の準備が整うの、めんどくさいですよね。それらを自動化するGASを作ったので、書きます 注意点 極限までサボりたかったのでChatGPTに聞いてツギハギして動けばヨシの精神で作りました。出来上がったものをみて、コードの整然さやエラーハンドリングについて、思うところは多々ありますがそのままにしています。社内用に展開しているものは複数クエリに対応しているのですが、1->nになると本筋と関係のない話題が増えるので今回はデータを抽出するクエリが1つに限定されたGASのコードを考えることにします 課題 まずそもそもなんでこれやったかです。以下が理由です RedashのQuery API はSpreadhsheet上でIMPORTDATAできて便利だが、パラメータを使っているクエリのデータは抽出できない 基本CSV->G Spreadsheetに変換したうえで加工することが多いので、施行回数が多くなるとつらい using redashAPI with GASの記事はいくつかみかけるが、ポーリングをsleepなどにまかせていて、重いクエリがそもそも回せない やったこと 上記問題を解くために、以下を実施しました パラメータつかってるクエリからもAPI使ってデータ抽出できるようにする 時間がかかるクエリはポーリングする(GASのtimeoutである6minに負けない) CSV->スプレッドシート化まで自動化 中でも本記事で触れるのはあんまり情報として見ない(気がする)以下の項目です RedashのUser API Keyの簡単な解説とGASでの使い方 GASのトリガーを使ったポーリング その他についてはよく見るので、詳細については本記事では触れません RedashのUser API Keyの簡単な解説と使い方 詳しくは公式を参照していただければと思うのですが、めんどくさいです。まずresponseが書いてありません。愚直にrequestして、responseをみる必要があります。つらいです API 今回やりたいことを実現するうえでの主役はこいつになります /api/queries/<id>/results クエリIDとパラメータを渡してPOSTしたときにキャッシュされた結果があればそれを、なければクエリを実行するエンドポイントです responseの中にstatusを格納したキーが無く、query_resultキーがあればデータが返ってきた。なければクエリが実行されたので、ポーリングしてデータが返ってくるまで待つ。の判断をしないといけないのでちょっとゾワゾワします 親切にやるならこっちでstatusを判断してObjectとしてラップして返しちゃう関数を作るのがよいと思いますが、今回のコンセプトは極限までサボる。です。心を鬼にしてChatGPTが出したものを正として進めていきます。 余談ですがChatGPTは一瞬で80点までは出してくれるのですが、100点までChatGPTオンリーで詰めるのはちょっとしんどいと思ってます。(百里を往くものは九十を半ばとす。なので、このあたりは自前の実装でも同じことは言えますが、一段抽象化されているのでコントロールが効きづらく、十里がより遠くなる印象) 出来上がり is belowなのですが、ここに関する白眉なコードはこんな感じです function _getJobId ( queryId , param ) { const apiUrl = ` ${ host } /api/queries/ ${ queryId } /results` ; const data = { parameters : param } ; const payload = JSON . stringify ( data ) ; const options = { 'method' : 'post' , 'contentType' : 'application/json' , 'headers' : { 'Authorization' : 'Key ' + apiKey } , 'payload' : payload , 'muteHttpExceptions' : true } ; let results = UrlFetchApp . fetch ( apiUrl , options ) ; let isResult = !! JSON . parse ( results ) . query_result ; console . log ( isResult ) ; if ( isResult ) { console . log ( JSON . parse ( results ) . query_result . data . rows ) ; return JSON . parse ( results ) . query_result . data ; } const jobId = JSON . parse ( results ) . job . id ; return jobId ; } なんとキャッシュがあれば配列を、なければjobIdを返します。ChatGPTが言うので仕方ないですが結構しんどいです とはいえ、 /api/queries/<id>/results の仕様上どこかでこんな感じの処理が必要になってきます(もうちょい考慮してwrapするべきという議論は置いておきます) GASのトリガーを使ったポーリング GASのtimeoutが6minなので、sleepで待ってもそもそも重いクエリの実行が無理になってしまいます。 一方でGASはトリガーが結構な量作れたりする ので、これを使ってポーリングすると楽になるシーンが多いです。 変数は渡せないので、そこはspreadsheetのシート上に持たせる解き方で頑張ります。(ほんとは実行者のみ編集できるセルとかにしたほうがいいけど、サボります) 例えばこんな感じです。今回は自分を呼んでます data = getJobResult ( jobId ) ; if ( ! data ) { ScriptApp . newTrigger ( 'generateRedashFiles' ) . timeBased () . after ( min * 60 * 1000 ) . create () ; return; } 結果のキャッシュがなかったときは、Redash側でクエリが実行されるので、一旦ジョブIDをスプレッドシートに保存しといて、再度自分を呼び出したときに参照するようにします こんな感じでトリガーが作成されます。急いでなかったり、重めのクエリかもなー。というときは30minとかで良さそうな気もします 出来上がり ChatGPTにいっぱい聞いてツギハギしてちょっとだけ手直しした結果がこれです。とりあえず動きます。使い方は後述します。 const host = '${redashのホスト名}' ; const apiKey = '${redah user API Key}' ; const urlSheetName = 'URLリスト' ; // TODO : edit as each env const ss = SpreadsheetApp . getActiveSpreadsheet () ; const folderId = '${データ保存先のG DriveフォルダID}' ; let min = 30 ; function generateRedashFiles () { let sheet = ss . getSheetByName ( urlSheetName ) ; let data ; let url = sheet . getRange ( 2 , 2 ) . getValue () ; let fileName = sheet . getRange ( 2 , 1 ) . getValue () ; let jobId = sheet . getRange ( 2 , 3 ) . getValue () ; if ( ! jobId || jobId == '' ) { jobId = getJobId ( url ) ; // jobID or data if ( jobId instanceof Object ) { data = jobId ; json2Csv ( fileName , data ) ; return; } else { sheet . getRange ( 2 , 3 ) . setValue ( jobId ) ; } } data = getJobResult ( jobId ) ; if ( ! data ) { ScriptApp . newTrigger ( 'generateRedashFiles' ) . timeBased () . after ( min * 60 * 1000 ) . create () ; return; } json2Csv ( fileName , data ) ; importCsvFilesToSpreadsheet () ; } function getJobResult ( jobId ) { const jobStatusUri = ` ${ host } /api/jobs/ ${ jobId } ?api_key= ${ apiKey } ` ; let queryResultId = null ; const jobStatus = JSON . parse ( UrlFetchApp . fetch ( jobStatusUri )) . job ; const status = jobStatus . status ; if ( status === 3 || status === 4 ) { queryResultId = jobStatus . query_result_id ; } else { return; } const jobResultUri = ` ${ host } /api/query_results/ ${ queryResultId } .json?api_key= ${ apiKey } ` ; results = UrlFetchApp . fetch ( jobResultUri ) ; return JSON . parse ( results ) . query_result . data ; } function getJobId ( url ) { let payload = generatePayload ( url ) ; let queryId = payload [ 0 ] ; let param = payload [ 1 ] ; let jobId = _getJobId ( queryId , param ) ; return jobId ; } function _getJobId ( queryId , param ) { const apiUrl = ` ${ host } /api/queries/ ${ queryId } /results` ; const data = { parameters : param } ; const payload = JSON . stringify ( data ) ; const options = { 'method' : 'post' , 'contentType' : 'application/json' , 'headers' : { 'Authorization' : 'Key ' + apiKey } , 'payload' : payload , 'muteHttpExceptions' : true } ; let results = UrlFetchApp . fetch ( apiUrl , options ) ; let isResult = !! JSON . parse ( results ) . query_result ; console . log ( isResult ) ; if ( isResult ) { console . log ( JSON . parse ( results ) . query_result . data . rows ) ; return JSON . parse ( results ) . query_result . data ; } const jobId = JSON . parse ( results ) . job . id ; return jobId ; } function generatePayload ( url ) { url = url . split ( '#' )[ 0 ] ; let match = url . match (/ \ /queries \ / (\d + ) \ /source /) ; let queryId = match ? match [ 1 ] : null ; console . log ( queryId ) ; let params = {} ; let queryString = url . split ( '?' )[ 1 ] ; const regex = / ^ \d {4} - \d {2} - \d {2} -- \d {4} - \d {2} - \d {2}$ / ; if ( queryString ) { let pairs = queryString . split ( '&' ) ; pairs . forEach (( pair ) => { let kv = pair . split ( '=' ) ; let key = decodeURIComponent ( kv [ 0 ]) . substring ( 2 ) ; let val = decodeURIComponent ( kv [ 1 ] || '' ) ; if ( regex . test ( val )) { let dates = val . split ( '--' ) ; val = { 'start' : dates [ 0 ] , 'end' : dates [ 1 ] } } params [ key ] = val ; }) ; } console . log ( params ) ; return [ queryId , params ] ; } function getFolderId () { const folderUrl = ss . getSheetByName ( settingSheetName ) . getRange ( 'B1' ) . getValue () ; console . log ( folderUrl ) ; const matches = folderUrl . match (/ [ - \w] {25,} /) ; if ( ! matches ) { throw new Error ( 'Invalid folder URL' ) ; } return matches [ 0 ] ; } function moveFileToFolder ( fileId ) { const folder = DriveApp . getFolderById ( folderId ) ; const file = DriveApp . getFileById ( fileId ) ; file . moveTo ( folder ) ; console . log ( `File " ${ file . getName ()} " has been moved to folder " ${ folder . getName ()} "` ) ; } function importCsvFilesToSpreadsheet () { var folder = DriveApp . getFolderById ( folderId ) ; var csvFiles = folder . getFilesByType ( MimeType . CSV ) ; // 新しいスプレッドシートを作成し、特定のフォルダに移動 var spreadsheet = SpreadsheetApp . create ( 'summary' ) ; var spreadsheetFile = DriveApp . getFileById ( spreadsheet . getId ()) ; folder . addFile ( spreadsheetFile ) ; DriveApp . getRootFolder () . removeFile ( spreadsheetFile ) ; var firstSheet = true ; while ( csvFiles . hasNext ()) { var file = csvFiles . next () ; var fileName = file . getName () ; var csvData = Utilities . parseCsv ( file . getBlob () . getDataAsString ()) ; if ( firstSheet ) { // 最初のCSVファイルの場合、既存のシートを使用 var sheet = spreadsheet . getSheets ()[ 0 ] ; sheet . setName ( fileName ) ; firstSheet = false ; } else { // 2つ目以降のCSVファイルの場合、新しいシートを作成 var sheet = spreadsheet . insertSheet ( fileName ) ; } // CSVデータをシートに書き込む var range = sheet . getRange ( 1 , 1 , csvData . length , csvData [ 0 ] . length ) ; range . setValues ( csvData ) ; } // 最後にスプレッドシートを開く SpreadsheetApp . getActiveSpreadsheet () . toast ( 'CSVファイルの集約が完了しました。' , '完了' , 5 ) ; var url = spreadsheet . getUrl () ; Logger . log ( 'スプレッドシートのURL: ' + url ) ; } // json to csv function json2Csv ( fileName , data ) { // CSV文字列を生成 let csvContent = '' ; // ヘッダーを追加 const headers = data . columns . map ( column => column . friendly_name ) ; csvContent += headers . join ( ',' ) + '\n' ; // 各行のデータを追加 data . rows . forEach ( row => { const rowValues = data . columns . map ( column => { const value = row [ column . name ] ; // CSVの規則に従って、カンマや改行を含む値をダブルクォートで囲む return `" ${ value . toString () . replace (/ " / g , '""' )} "` ; }) ; csvContent += rowValues . join ( ',' ) + '\n' ; }) ; // CSVファイルをGoogleドライブに保存 const file = DriveApp . createFile ( fileName + '.csv' , csvContent , MimeType . CSV ) ; // ファイルのURLをログに出力 Logger . log ( 'CSVファイルが作成されました: ' + file . getUrl ()) ; moveFileToFolder ( file . getId ()) ; } 使い方 URLリスト シートを作って、こんな感じに設定します jobID部分は後で更新されます GASに上記の出来上がりコードを貼って generateRedashFiles 関数を実行します ジョブIDが更新され、トリガーが登録されます 時間が来るとトリガーが実行され、結果がキャッシュされていれば指定のフォルダにCSVとスプレッドシートが保存されます 終わりに ところどころ手直しはしたいですが、とりあえず動くものができました。 今回は簡単のために単一クエリに話しを限定しましたが、RedashAPIをGASで動かせることの最大の強みは、パラメータだけ変えたクエリをスプレッドシート上で大量に作れるところだと思います。 なんだかんだパラメータを設定する、、他のパラメータも別画面で設定する、、結果がでるまで待つ。。やっぱり別の設定のがよいな。。設定し直す。。結果がでるまで待つ。。みたいな作業がBIツールを使っているとどうしても発生しがちなので、そこをG Suiteにまかせて富豪的に解決出来るのは他の作業ができて個人的には便利なところかと思っています。 それではさようなら
アバター
はじめに こんにちは!トモニテにて開発を行なっている吉田です。 今回は先日参加した Amazon Bedrock ワークショップに参加させいただいたのでそこで学んだことについて紹介します! ワークショップは AWS 様からエブリー向けに開催いただきました。 Amazon Bedrock とは Amazon Bedrock は、高性能な基盤モデル (Foundation Model) の選択肢に加え、生成 AI アプリケーションの構築に必要な幅広い機能を提供する完全マネージド型サービスです。 特徴としては以下が挙げられます。 ユースケースに最適な基盤モデルを簡単に試すことができる 調整や検索拡張生成 (RAG) などの手法を使用してデータに合わせて非公開でカスタマイズ可能 サーバーレスであるため、インフラストラクチャを管理する必要がない aws.amazon.com ワークショップの流れ 当日の流れとしては簡単な自己紹介から始まり AWS の方に今回のテーマである Amazon Bedrock(以下、Bedrock とします) について、Bedrock を利用するにあたり必要となる知識について講義をいただきその後ワークショップ用にご準備いただいたソースを使い各自で使ってみるという流れでした。 その中で学んだ Bedrock とその周辺知識について以下に簡単に紹介します。 Bedrock は、生成 AI アプリケーションの構築に必要な幅広い機能を有していますが、そもそも AI とは、人間のように学習し、理解し、反応し、問題を解決する能力を持つ技術のことを指します。 また基盤モデルとは、大量のデータから学習し、広範な知識と能力を持つ大規模な機械学習モデルのことで、その特徴は、入力プロンプトに基づいて、さまざまな異なるタスクを高い精度で実行できる点にあります。 タスクには、自然言語処理 (NLP)、質問応答、画像分類などがあり、テキストによるセンチメントの分析、画像の分類、傾向の予測などの特定のタスクを実行する従来の機械学習モデルと比べて、基盤モデルはサイズと汎用性で差別化されています。 基盤モデルが可能とすることは以下の通りです。 言語処理 基盤モデルには、自然言語の質問に答える優れた機能があり、プロンプトに応じて短いスクリプトや記事を書く機能さえあります。また、NLP 技術を使用して言語を翻訳することもできます。 視覚的理解 基盤モデルは、特に画像や物理的な物体の識別に関して、コンピュータビジョンに適しています。これらの機能は、自動運転やロボット工学などのアプリケーションで使用される可能性があります。また、入力テキストからの画像の生成、写真やビデオの編集が可能です。 コードの生成 基盤モデルは、自然言語での入力に基づいて、さまざまなプログラミング言語のコンピュータコードを生成できます。基盤モデルを使用してコードを評価およびデバッグすることもできます。 人間中心のエンゲージメント 生成 AI モデルは、人間の入力を使用して学習し、予測を改善します。重要でありながら見過ごされがちな応用例として、これらのモデルが人間の意思決定をサポートできることが挙げられます。潜在的な用途には、臨床診断、意思決定支援システム、分析などがあります。 また、既存の基盤モデルをファインチューニングすることで、新しい AI アプリケーションを開発できます。 音声からテキストへ 基盤モデルは言語を理解するため、さまざまな言語での文字起こしやビデオキャプションなどの音声テキスト変換タスクに使用できます。 aws.amazon.com 一方で基盤モデルが苦手とすることもあります。 基盤モデルは大量の GPU を消費する(=コストがかかる) 適材適所の基盤モデル利用が重要 Hallucination 嘘の情報を答えてしまう プロンプトのトークン数の制限 プロンプトに含められるトークン数には基盤モデルによって制限がある 回答に冪等性がない 特定の入力に対して毎回同じ結果を返すとは限らない (ワークショップ内資料より引用) これら基盤モデルの苦手ポイントを解決するために有効な手法の一つとして RAG(Retrieval Augmented Generation)が挙げられます。 RAG とは外部の知識ベースから事実を検索して、最新の正確な情報に基づいて大規模言語モデル(LLM)に回答を生成させることができます。 次節では実際に RAG を用いてタスクを実行してみます! 実際に使ってみた Amazon Bedrock の Knowledge Bases for Amazon Bedrock(以下、Knowledge Bases とします) を使用すると、Bedrock の基盤モデルを、RAG のために企業データに安全に接続することができるとのことで実際に使ってみました! Knowledge Bases ではデータソースとしてS3を指定し埋め込みモデルを選択します。(今回はAmazon Titan Embeddingsを選択) そしてベクトルデータベースについては新しいベクトルストアを作成するか、他で作成したベクトルストアがある場合にはそれを設定することもできます。今回は初めての利用ということで新しくベクトルストアを作成しました。 データソースには弊社のサービスであるトモニテで 2023 年 8 月に実施された「トモニテ子育て大賞 2023」と昨年実施の「MAMADAYS 総選挙 2022」の内容を保存しました。(1 つのバケットに 2 つのオブジェクトがある状態です。) election2023.tomonite.com tomonite.com ※2023 年 8 月にブランドリニューアルを行ったことから名称が異なっております 関連記事はこちら tech.every.tv 使ってみた結果がこちらです! 質問内容:mamadays 総選挙、トモニテ子育て大賞それぞれのデカフェ飲料部門の最優秀賞について教えてください 回答: ※Knowledge Bases for Amazon Bedrock 実行結果のスクリーンショット 実際のコンテンツ:(左: MAMADAYS総選挙 2022、右: トモニテ子育て大賞 2023) ソースを明示した上で質問に回答しており、2 つのバケットから異なる情報を引き出すことができていました! ただ質問によっては片方の情報のみ回答したり、異なる回答をすることもありましたが今回は web ページをそのまま pdf 化して保存しただけだったのでページ内の情報を適切にテキスト化した上でソースとして保存すればより回答の正確性は向上するのかなと思いました! 現に、ほとんど文字の羅列である企画書をソースとして保存しその内容について回答を求めた場合はほぼほぼ適切な情報を返してくれていました。 ワークショップでの学び 普段は SE として業務に関わっていることもあり AI や機械学習に関わる機会は少ないですが今回のワークショップは Bedrock の深い理解を得ることができ、非常に有意義な時間となりました。学んだことを社内に持ち帰り業務に役立てていきたいと思います。 終わりに ワークショップの開催にあたり、多くのリソースを提供していただいた AWS の皆様に心から感謝申し上げます。
アバター
はじめに こんにちは、 retail HUB で Software Engineer をしているほんだです。 今回は私が現在着手している事業譲渡されたアプリを社内で持続的なプロダクト開発を行える状態にするリプレイスプロジェクトをどのように行っているか紹介しようと思います。 この記事ではリプレイスを行うにあたってどのようなことを課題に感じてその課題に対してどのような解決策をとったか主にサーバーの実装について説明しています。 ネットスーパーアプリとは 現在弊社ではネットスーパーアプリとして Web アプリとスマホアプリの二つのシステムを提供しています。 Web アプリは販促コンテンツの設定や売り上げの管理・集計を行うことが可能な管理システムと受け取り方法に応じた価格変更や送料変更にも対応し、消費者の柔軟な買い物を実現するお客様向けアプリを 17 の小売り様に、スマホアプリでは Web アプリのお客様向けアプリと同等の機能を Android と iOS のアプリとして株式会社リウボウストア様に リウボウネットスーパー として提供しています。 こちらのサービスは以前株式会社ベクトルワン様が開発・運用していたものを事業譲渡されたものです。 リプレイス前の実装 リプレイス前の実装は上記図のようになっていました。 ネットスーパーアプリは GraphQL Mesh で作成された GraphQL Gateway Server を呼びその裏では AppSync と Lambda を用いて GraphQL が実装されていました。 GraphQL のリゾルバーに当たる Lambda は Python で書かれていました。 リプレイスの背景 課題点 既存の実装では下記のような問題があったため今回リプレイスを行うに至りました。 社内に知見が少ないインフラ構成や、言語で実装されている。 Appsync, Lambda を用いた GraphQL の実装がチューニング不足もあるかもしれないが遅かった。 重複する場合やコアとなるロジックを切り出すのに Lambda レイヤーにする必要があり管理が大変だった。 リプレイスを進めるにあたり満たしたいこと リプレイスを進めるにあたり満たしたいこととしては下記のようなことを意識しています。 このプロダクトは現状は1小売様向けとなっていますが今後小売りの拡大やバグを見つけたときに早期対応、機能の追加をできるような持続的なプロダクト開発をできるようにする。 DB は既存の Web アプリのものを用いるため大量の table 、小売りごとに特定の table の有無があるものを適切扱えるようにする。 サーバーの実装と同時並行でアプリの実装もすすめられるようにする。 リプレイス後の技術スタック リプレイス後のインフラ構成は上記図のようになる予定です。 リプレイス前に用いていた GraphQL Gateway Server は GraphQL を REST に移行また今後は REST に統一していく点から導入している必要がなくなったため廃止しました。 満たしたいこと 1 にあげた持続的なプロダクト開発をできるようにすることを満たすためになるべく社内に知見があるものを選定するようにしました。 インフラに関しては社内の他のサービスでも使われていて知見が豊富な ECS を、開発に用いる言語に関しても Python から社内の知見が豊富な Go、framework は echo を採用しました。 満たしたいこと 2 DB を適切に扱えるようにすることを満たすために Go の ORM は sqlboiler を採用しました。具体的な理由については後述します。 満たしたいこと 3 サーバーとアプリの実装の最適化を満たすために OpenAPI を用いたスキーマ駆動開発を実践しています。OpenAPI を用いてエンドポイントの仕様を事前に決めておくことでサーバーとクライアントが並列に実装を行えるようにしています。 OpenAPI 定義書の作成には Stoplight Studio を用いています。 次にリプレイスにあたり特筆する点について説明していきます。 oapi-codegen oapi-codegen は Stoplight Studio で作成した OpenAPI 定義書から Go のコードを作成するために用いています。 oapi-codegen を用いて Go のコードを作成することで Request Header の値や Query Parameter の validation を自分で実装する必要がなくなります。 また、ルーティングも任せることは可能ですがその場合全てのエンドポイントに middleware を反映することになり個別に設定することができなくなってしまうため今回は用いていません。 main 関数の実装は下記のようになります。 func main() { e := echo.New() wrapper := openapi.ServerInterfaceWrapper{ Handler: handler.NewHandler(chainSchemaMap), } g := e.Group( "" ) g.Use(echomiddleware.Recover()) // 認可なしエンドポイント { g.GET( "/policy" , wrapper.GetPolicy) } // 要認可エンドポイント g.Use(middleware.Authorize()) { g.GET( "/items" , wrapper.GetItems) g.GET( "/items/:id" , wrapper.GetItem) } e.Logger.Fatal(e.Start( ":1323" )) } sqlboiler sqlboiler は toml ファイルを記述し実際に DB に接続することでその table 定義を元に Go の struct を生成することができ、既存の table の数だけ stuct として書き直す手間が省けます。 今回、小売ごとの DB の差分は特定の table の有無なため local 環境に全ての table を持つ DB を作成し、それを参照することで小売ごとのカスタマイズを含む全ての table の struct を作成できます。 Go のサーバーと DB の接続には一つのユーザーを用いているため、どの小売のアプリがどの DB にアクセスできるかは Go で map を定義することで対応しています。 Stoplight Studio スキーマ駆動開発のための OpenAPI 定義書は Stoplight Studio を用いて記述しています。 Stoplight Studio は GUI 形式で OpenAPI 定義書を編集できるツールとなっています。 また、ツール内から Mock Server や実際のサーバーを叩くことができるので OpenAPI を書く用途だけでなく、どのようなリクエストでどのようなレスポンスが返ってくるかも確認することも容易となっています。 まとめ まだリプレイス作業が始まったばかりでレイテンシーの改善などは具体的に測れていないの結果として捉えることは今後やっていく必要があるなと感じました。 リプレイスを行っていくにあたっても最終系から逆算し何が必要かということをまとめられていなかった点もあり適切な工数見積もりができなかったり後手になることもあったので今後はそういった点も意識していきたいです。 今回私自身実務の Python のコードに触れるのが初めてでそれを慣れ親しんだ Go に書き換えるという経験は言語の長所などを改めて捉え直す貴重な機会になったなと思います。
アバター
こんにちは。 開発本部のデータ&AIチームでデータサイエンティストをしている古濵です。 引き続き、私がフルコミットしているDELISH KITCHENのレシピレコメンドについてまとめていきます。 前回の投稿 の続きのような位置づけです。 私自身の苦悩も含めた思考過程と実際に取り組んだことについてまとめていきます。 背景 DELISH KITCHENではユーザの嗜好に寄り添ったアプリのパーソナライズに向けた開発をしています。 大きく3つの課題を解決するために、アプリのパーソナライズに注力しています。 受動的に提示するレシピのパーソナライズ不足 サービスの成長に伴い、ユーザ数もレシピ数も増えているのに対して、アプリのロジック部分は更新されていない状態が続いています。 そのため、ユーザが好みのレシピの発見の機会を増やすために、レシピのレコメンドの開発を進めています。 ロジックの癒着 DELISH KITCHENでは、一部の機能がサーバー側の簡易な集計ロジックをもとに提供しているため、サーバー側の実装と密結合となっている部分があり、データ&AIチームが継続的にロジックの改善に集中できない状態です。 そのため、データ&AIチームがオーナーシップを持ってロジックを開発し、サーバーエンジニアがロジック改善に伴う修正を対応せずとも運用できる状態を目指しています。 ML活用が部分的にしか行われていない ユーザの行動データやレシピの栄養素データなど多くのデータが利活用できる状態なのに対して、MLをプロダクトに活用する動きが部分的にしかできていません。 そのため、MLをプロダクトに活用する事例づくりや、ML基盤の構築が必要となっており、データ&AIチーム総手で取り組んでいます。 直近のML事例は以下をご覧ください 『DELISH KITCHEN』におけるバンディットアルゴリズムの取り組み紹介 レシピ栄養情報を用いたDELISH KITCHENユーザー嗜好のクラスター分析 レシピレコメンド開発の道のり 構成 対象面は、最近見たレシピからおすすめの枠です。 レシピレコメンドの全体構成は以下のとおりです。 Data Sourceとなるdelta lakeからデータを読み込み、ロジック用の集計をdatabricksのnotebookで実装します。 実装した結果をレコメンド結果としてdelta lakeに保存し、そのデータと同じフォーマットのデータを推論結果のデータストアとして、Delish ServerのRedis(ElastiCache for Redis)内にデプロイします。 このパイプラインをデイリーのバッチで実行し、推論結果をサーバー側で取得して、DELISH KITCHENアプリで表示できるようにしています。 最初にやったこと ルールベースのベースライン まず、ルールベースのベースライン作成をしました。具体的には、ルールベースロジックnotebook内で、ユーザごとに検索経由で視聴した動画の中で長く再生したレシピ順にレコメンドする集計をしました。 集計データをrule base resultとしてdelta lakeに保存します。 Redisへデプロイするためにフォーマット整形も別のnotebookで実装し、recommend resultとして保存します。 最近見たレシピからおすすめ、というタイトルともシナジーもあり、ユーザにとってもわかりやすいレコメンドになったと思います。 数年間更新されていなかった既存ロジックと、ルールベースのベースラインを比較するためにA/Bテストしました。 結果として大きく改善しましたが、以下の2点のことがわかりました。 既存ロジックとルールベースのベースラインでは、レコメンド対象のユーザ数が異なることが判明し、ルールベースのベースラインが改善したというよりも、レコメンドを展開しているユーザ規模を増やすことができたことが改善の大きな要因だということ レコメンド対象に含まれていないユーザは、アプリ上で最近見たレシピからおすすめ枠が表示されていないということ 上記のような手探りの状態から始まりました。 とはいえ、ルールベースのベースラインを作って検証し、新たな課題を得ることができたと思います。 ルールベースのベースラインの限界 ルールベースのベースラインを作成した時点で、ルールベースの限界も感じていました。理由は2つあります。 1つ目は、動画をあまり再生しないユーザも一定数いることが見えてきたからです。 レコメンドをする順序を再生秒数の多い順にしていましたが、動画をあまり再生しないユーザにとっては、嗜好に添わないレシピが上位に表示されることになります。 それは、DELISH KITCHENのユーザは、動画を見るためにアプリを使っているわけではなく、レシピを探すためにアプリを使っているからではないかと推測しています。 あくまで動画はレシピを選定するための手段にすぎず、目的はレシピを探すことであり、Youtubeなどの動画サービスとは異なる思考が必要だと感じました。 そのため、動画を再生せず、材料などが見れるレシピ詳細を見てからレシピを選定しているユーザもいると考えました。 2つ目は、実装コストに対して、大きな改善は望めないと思ったからです。 多くのルール作成したり、レコメンド順序を細かくチューニングするなどすれば、より良いレコメンドができるかもしれませんが、その実装をするコストに対して改善の幅は小さいだろうと感じていました。 実際に、ルールベースのベースラインにレシピ詳細の表示ログを追加してA/Bテストしたところ、大きな改善はしませんでした。 MLロジックに向けての情報収集 ルールベースに限界を感じていたため、MLロジックに向けての情報収集を始めました。 まずは、ブログ記事を読み漁り、引用されている論文など目を通しました。 MLの導入やレコメンドのアルゴリズムに関して多くの知識があったわけではなかったため、基礎や体系的に学べる書籍を購入して読み進めました。 推薦システム 仕事ではじめる機械学習 推薦システム入門 施策デザインのための機械学習入門 また、Kaggleなどコンペティションで実施された解法なども参考になりました。 コンペティションの場合、コードが公開されているケースが多くあり、コードを読んで理解する助けになりました。 H&M OTTO atmaCup15 atmaCup16 情報収集した気づきとして、書籍で紹介される協調フィルタリング(行列分解など)のような手法は、コンペティションの解法ではメインで使われていないということです。 あくまで主軸となっていたのは、Two-stage Recommender Systemsと呼ばれる、候補生成とリランキングの2つからなる手法でした。 候補生成の一つとして協調フィルタリングなどが使われているケースはたくさんありましたが、メインとして使われている解法は多くなかった印象でした。 Two-stage Recommender Systemsは、大規模なユーザ x アイテムの組み合わせを全て扱うのではなく、ユーザ一人当たりに対して候補を生成し、その候補を並び替え(リランキング)するという手法です。 手法の肝としては、情報検索における検索クエリの結果が候補であり、検索結果をどの順序で並び替えるかがリランキングに該当するのかなという所感を受けています。 正しくは確認できていないですが、 Covington Paul, Adams Jay, and Sargin Emre. 2016. Deep neural networks for Youtube recommendations. In RecSys. 191–198. で提案された手法が有名であり、その後Two-stage Recommender Systemsという言葉が広まったかなと思います。 Two-stage Recommender Systemsを実装するために、まず、どのような候補を生成できるかのアイデアを一覧化しました。 候補の肝となるeventログをデータソースとして、SQLで集計可能な候補を中心に整理しています。 後述する候補生成モジュールでは、このアイデア一覧をもとに、候補を生成するためのクエリを一元管理しています。 候補生成のアイデアを整理する中で、ルールベースのベースラインである 検索経由で視聴したレシピ を候補の一つとして扱えるのではという考えが浮かびました。 Two-stage Recommender Systemsであれば、 検索経由で視聴したレシピ と レシピ詳細を表示したレシピ の2種類をそれぞれ候補として扱い、ルールベースにおける並び替えの限界を、MLロジックでリランキングすることでユーザの嗜好にあった順にレコメンドできると考えました。 Two-stage Recommender Systemsの実装 候補生成 まずは、候補生成をするパイプラインの作成から始めました。 全体構成としては、ルールベースロジックのnotebookが候補生成notebookに置き換わります。 候補生成notebookでは、複数の候補を一括で生成するために、候補生成モジュールを用いています。 候補生成モジュールを作成した経緯として、レコメンド開発をしていく上で、今後多くの候補を作るだろうと予測していたためです。 DELISH KITCHENで全てのレシピからレコメンドする場合、ユーザ一人当たり5万レシピ強になります。 候補生成は、このレシピの数を減らす役割がありますが、特定の候補からだけでレコメンドした場合、特定の人気レシピや上位のポジションに位置するレシピばかりがレコメンド対象になる可能性があります。 本ブログ執筆時点では、 検索経由で視聴したレシピ と レシピ詳細を表示したレシピ を候補にしていますが、さらに複数候補からの組み合わせでレコメンドしたいケースも出てくると考えました。 そこで、候補生成モジュールを作成し、候補生成に関する集計ロジックを一元管理することにしました。 使い回しやすい 2-stage recommender systemの デザインパターンを考えて実装した話 を参考に、Candidate、QueryGererator、Evaluatorのクラスを作成し、これを候補生成モジュールと呼称します。 Candidateに対して、それぞれQueryGereratorとEvaluatorが依存しています。 メインとなるCandidateは、以下のメソッドを持っています。 generate QueryGereratorからクエリ(=query)をstringを受け取り、spark.sql(query)を実行 QueryGereratorでは、候補を生成するためのクエリと、クエリがアウトプットするスキーマを保持 クエリはspark.sql()で実行可能なクエリであり、スキーマはpyspark.sql.typesのStructTypeで定義 evaluate Evaluatorクラスで定義された評価関数を使って、生成した候補とground truthを比較 評価関数には、precision@k, recall@k, map@k等 validate generateで生成したデータや、evaluateで評価するデータに対して、簡単なバリデーションを実施 バリデーションにはdataframeの空チェック、カラム数チェック、カラム名チェック、カラムの型チェック等 候補生成モジュールを用いて候補生成notebookを実行し、候補を生成します。 サンプルコードとしては、以下のとおりです。 from src.recommend_system.candidate_generation.candidate import Candidate ground_truth_table = spark.sql(...) # example) # user_id, recipe_id # aaaaaaa, 111111111 # ... candidate = Candidate( delta_schemas=delta_schemas, user_col= "user_id" , item_col= "recipe_id" , candidate_col= "candidate_recipes" , ground_truth_col= "recipe_ids" ) candidate_names = candidate.catalog_schema.keys() # example) # candidate_names = ["検索経由の視聴", "レシピ詳細の到達"] for candidate_name in candidate_names: # 候補生成 candidate.generate( candidate_name, date ) # 評価 candidate.evaluate( candidate_name, ground_truth_table, eval_topk=[ 3 , 8 , 10 ], mlflow_eval_cache_name=candidate_name ) # 保存 results = candidate.generated_candidates[candidate_name] results.write \ .format( "delta" ) \ .mode( "overwrite" ) \ .option( "mergeSchema" , "true" ) \ .save(f 'path/{candidate_name}' ) candidate_namesに生成する候補名となるkeyが格納されます。 これは、QueryGeneratorクラスで定義した、候補を生成するためのクエリとクエリがアウトプットするスキーマをもとに、Candidateクラスのcatalog_schemaに格納されます。 QueryGeneratorクラスの、候補を生成するためのクエリとクエリがアウトプットするスキーマは、以下のような定義をしています。 新規で候補を追加したい場合、以下のような実装を追加するだけでOKです。 # わかりやすくするために一部日本語にしています class QueryGenerator : def __init__ (self, delta_schemas: DeltaSchema): self.catalog = { "検索経由の視聴" : { "query" : { "func" : self.fetch_検索経由の視聴, "params" : { "days" : 30 } }, "schema" : StructType([ StructField( "user_id" , StringType()), StructField( "recipe_id" , LongType()), StructField( "seconds" , DoubleType()), ]) }, ... } ... # Candidateクラスでgenerateメソッドが呼ばれたときに、このメソッドが呼ばれる def get_query (self, candidate_name: str , date: str ) -> str : ... return query def fetch_ 検索経由の視聴(self, from_date: str , to_date: str ) -> str : return f """ SELECT user_id, recipe_id, sum(seconds) AS seconds FROM ... WHERE event_date BETWEEN '{from_date}' AND '{to_date}' AND ... GROUP BY 1, 2 """ リランキング 生成した候補から得られたuser_id x recipe_idの組み合わせを用いて、リランキングをします。 リランキングでは、教師あり機械学習を用いてリランキングモデルを作成し、予測結果を降順でソートして上位k個をレコメンド対象とします。 今回は、LightGBMを使ってリランキングモデルを作成しました。 候補群の作成 まず、各候補をfull outer joinして、user_id x recipe_idの組み合わせとなる候補群を作成します。 今回の場合は、 検索経由で視聴したレシピ と レシピ詳細を表示したレシピ の2種類の候補になります。こうして作成された候補群がリランキングの対象となります。 特徴量の作成 次に、リランキングモデルの学習をするための特徴量を作成します。 特徴量は、ユーザの行動データやレシピの栄養素データなどを使って作成します。 行動データは動画の表示及び視聴やレシピ詳細の表示、最後のアクセスからの経過日数、アプリ内の様々なタップログを用いています。 栄養素データは、DELISH KITCHENのレシピのメタデータにあるカロリー、たんぱく質、脂質、糖質などの栄養素を使っています。 栄養素データは DELISH KITCHENのWebサイト で公開されており、ユーザがレシピを選定する上で重要な指標になっていると考えています。 目的変数の設定 次に、正解ラベルを用意します。 これを目的変数とします。 今回の学習では、候補生成時点よりも未来の時間軸にユーザが視聴したレシピを正解ラベルとします。 そのため、正解ラベルは視聴した=1、視聴していない=0を持ちます。 正解ラベルは、最近見たレシピからおすすめの枠で視聴されたレシピのみに限定せず、アプリ上の全ての枠で視聴された動画を対象としました。 その理由は以下の3つです。 最近見たレシピからおすすめの枠は、ユーザにレシピを再度見てもらうための枠だと位置づけており、ユーザが興味を持ちそうなレシピを広く反映させたいため 最近見たレシピからおすすめ枠の視聴レシピだけでは、すべての枠で視聴されたレシピの数に比べて、正解ラベルの数が少なくなるため 最近見たレシピからおすすめ枠経由のレシピだけを用いると、正解ラベルが既存ロジックのバイアスの影響を受けるため 学習 次に、候補群に対して、特徴量と正解ラベルをleft joinして学習データを作成します。 学習データをもとに、再視聴の有無を予測するための二値分類問題として、LightGBMで学習します。 正解ラベルは視聴していない=0の方が圧倒的に多いため、0となる方をダウンサンプリングしています。 学習後、mlflowを使ってモデルを保存します。 予測 次に、学習データと同じ特徴量を使って、最新の候補群に対して予測します。 予測結果を降順でソートし、上位k個がリランキングモデルにおけるレコメンド対象になります。 評価 最後に、同じ候補群を用いて、ルールベースのベースラインとリランキングモデルの性能を評価します。 ルールベースのベースラインは再生秒数で降順にソートし、上位k個がルールベースのベースラインにおけるレコメンド対象になります。 リランキングモデルとルールベースのベースラインの各評価指標もmlflowで記録し、モデルの性能を比較できるようにしています。 A/Bテスト controlをルールベースのベースライン、testをTwo-stage Recommender Systemsによるレコメンド、としてA/Bテストしました。 A/Bテストの結果、ある指標において、リランキングモデルによるレコメンドがルールベースのベースラインよりも改善されたことがわかりました。 まとめ 本ブログでは、DELISH KITCHENのレシピのレコメンドにTwo-stage Recommender Systemsを導入するまでの道のりについてまとめてきました。 現時点では検証段階であり、あくまでTwo-stage Recommender Systemsの一通りの実装をしただけに過ぎません。 リランキングモデルの目的変数の設定は深く検討できておらず、特徴量も既存のものを使いまわしているため、モデルの性能は十分とは言えません。 バイアスの考慮なども含めるとチューニングして改善する余地は多くあります。 そんな未だ手探りの状態とも言えますが、ユーザの嗜好に寄り添ったアプリを目指した改善が少しずつできていると思います。 CTOの今井が 過去のブログ でも記載している「事業を推進する開発組織になる」を目指して、データ&AIチームとして、引き続きプロダクトに寄り添った開発を進めていきたいと考えています。 私個人としても、MLをプロダクトに導入するという非常に挑戦的な取り組みをできており、裁量を持って開発をできていることに成長を実感しています。 データ&AIチームでは一緒に働く仲間を募集しています! 動画メディアでAI/MLプロダクトの推進にご興味のある方はぜひ、以下のURLからご応募ください。 corp.every.tv
アバター
はじめに エブリーでCTOをしている今井です。 先日の池のブログ でも少し触れておりますが、2月に DevEnableグループ を設立したので、その紹介と設立した背景ついてお話しできればと思います。 tech.every.tv DevEnableグループとは DevEnableグループはCTO室に属しているグループで、開発本部を横断し、組織の活性化・成長環境の提供・発信・広報の強化・採用など、さまざまな課題解決を推進するグループです。 DevEnableという名前は Developer Enablement から取られており、「社内外から憧れる開発組織へ」というのをミッションに、エンジニア自身やエンジニア組織がより活性化し、成果を出し続けられる人・組織にすることを目標としています。 Developer Enablementは各社定義もかなり幅があるように感じておりますが、自分は、エンジニア自身の成長はもちろん、組織とのコラボレーション、これから迎えるメンバーの採用やその方の早期活躍に向けたオンボーディング、また自社だけでないエンジニアコミュニティの活性化など、かなり広義にとらえております。 DevEnableグループでは音頭を取ったり、活動がやりやすい場の提供をすることで推進し、活動自体は開発本部に所属する全員で行なっていきたいと考えております。 なぜ作ったのか 自分がCTOになった時から口酸っぱく言ってきたのが、「事業を推進する開発組織になる」ということでした。それが浸透してきたのもあり、各メンバーが技術だけじゃなく事業を考え開発に向き合ってくれるようになった一方で、相対的に技術に関する取り組みが減り、振り返ると技術的な挑戦ができてないと感じることも多くなりました。 また採用観点でも、まだまだエブリーを知っていただけてないことが多かったり、エブリーは知っているが具体的に今何をしてる会社かわからないなどの声をいただくことも多く、課題を感じていました。 それらの課題に向き合うために生まれたのがDevEnableグループの前身となる、組織活性化委員会でした。 前身: 組織活性化委員会 上記の課題に対して、特にDeveloper Experienceに興味がる有志で結成された組織活性化委員会です。この委員会では、TechTalkや社内勉強会の開催、挑戦WEEEKの実施、アドベントカレンダーの開催など、組織の活性化に向けた様々な活動を行ってきました。詳しくはいくつかブログにもなっているので、ぜひ一読ください。 tech.every.tv これらの活動を通じて、組織内のコミュニケーションが活性化し、開発者同士の繋がりが強くなるなどの成果が見られました。一方で、有志で集まった非公式な組織であるが故の活動のやりにくさがあったり、より広い課題に取り組みたい、また今後も継続的な取り組みが必要だと感じていたので、正式な組織とすることにしました。 足元の取り組み 具体的には大きく3つの軸で活動する予定です。 細かい内容はまだまだ詰めている途中なものもあり、追加や変更あるとは思いますが、 一部詳細な内容も含めてご紹介できればと思います。 1. 社内活性化 こちらは組織活性化委員会時代からの引き継いだものが主になります。 「挑戦WEEK」、「TechTalk」、「勉強会」などがあります。 それぞれ、ブログにもなっておりますので、こちらも合わせて読んでいただけると嬉しいです! tech.every.tv 2. 外部発信・コミュニティ貢献 昨年度よりテックブログの執筆推進を進め、半年で50~60本ほどの記事を上げることができる体制になってきました。今年はそれに加えて、技術だけじゃなく人や取り組みにフォーカスした記事の執筆なども増やしていきたいと考えています。 また、今年から国内カンファレンスへの協賛も積極的に行なっていくことで、国内の技術コミュニティへの貢献もしていきたいと考えております。さっそく6月のGoConferrenceへの協賛が決まりました!(これに関しては後日またきちんとご報告できればと思います。) このほか、勉強会の開催など、技術系のコミュニティへ積極的に貢献していきたいと考えておりますので、何か弊社で貢献できそうなことがあれば、ぜひ気軽に連絡いただけると嬉しいです。 3. 採用およびオンボーディング 課題にも書きましたが、エンジニアは全職能において絶賛採用中ではあるものの、あまり認知されてないという課題があります。上記の発信に加えて、採用面でも発信を強化するとともに、より会社の魅力が伝わるような会社説明資料の刷新から採用プロセスの見直し、リファラル採用のサポートなども進めています。 また、入社後早期に活躍できる仕組み作りにも取り組みたいと考えており、まずは4月に入社する新卒向けのオンボーディングプログラムを作成しています。 最後に 私たち DevEnable グループは、まだ発足したばかりですが、今後も「社内外から憧れる開発組織へ」というミッションの実現に向けて、様々な施策に取り組んでいきます。 何度も言いますが、弊社は全方位で積極採用中です! DevEnableグループをおもしろうそうと思った方や、そんなグループが活躍してる組織で働きたいと思った方はぜひお話しましょう! corp.every.tv
アバター
はじめに こんにちは。DELISH KITCHEN 開発部 SERS グループ兼、CTO 室 DevEnable グループ所属の池です。 SERS グループでは主に小売向けプロダクトの開発を行なっており、DevEnable グループでは社内開発組織活性化に向けた活動を行なっています。 今回は DevEnable グループの活動の一つである、”TechTalk” という社内技術共有会の取り組みにスポットを当てたイベントレポートをお届けします。 DevEnable グループとは 2024 年 2 月に DevEnable グループが新設されました。有志が組織活性化委員会として行なっていた活動を正式な組織活動としてより広く深く取り組むためのグループです。 私たち DevEnable グループのミッションは「社内外から憧れる開発組織へ」です。そのミッションの実現に向けて採用・発信・成長環境などの課題を改善するため、施策の検討から実施まで推進しています。 様々な施策を推進している中で私は現在主にオンボーディングプロセスの改善や TechTalk の運営などの施策推進を行っています。 TechTalk とは TechTalk とはエブリーが月次で開催している社内技術共有の場です。 エブリーでは DELISH KITCHEN、トモニテ、TIMELINE と各事業部に分かれており、普段はそれぞれが別チームとして動いているため、チーム横断でのコミュニケーションが取りづらいという組織体系による課題があります。 チームを超えたナレッジ共有や情報共有ができずに、チームごとに同じような技術検証や課題に取り組んでしまうと、無駄な労力に繋がってしまいます。 活性化組織委員会のリーダーを担っていた國吉さんが書いた 挑戦 Week の記事 にも上記課題への言及があるのでご参照ください。 TechTalk はこの課題を解決するための取り組みの一つであり、以下の目的を持っています。 組織横断したナレッジ共有 エンジニアの技術的知見の共有 開発部全体でのエンジニアの交流 TechTalk 実施内容 TechTalk のアジェンダは次のとおりです。 新しく入社されたメンバーの自己紹介 開発部 ALL HANDS ポストモーテム共有会 ライトニングトーク(LT) 懇親会 オフライン参加者はフリースペースに集まり、オンライン参加者は Zoom で繋ぎます。 自己紹介 新たに加わったメンバーの自己紹介を行います。 開発部全員が集まる場で自己紹介することによって、組織へのスムーズなオンボーディングを促します。 このように開発部全員が集まる機会は少ないので、貴重な機会となっています。 開発部 ALL HANDS ALL HANDS では、各グループの OKR やプロジェクト進捗、課題やトピックスについて共有します。 これにより、開発部の各部門全体の動向について把握することができます。 ALL HANDSの様子 ポストモーテム共有会 ポストモーテムとはインシデントについてまとめた文書のことをいいます。 エブリーではインシデントが発生した際には、関係者全員で振り返りを行い、ポストモーテムを作成する文化が根付いています。 同じインシデントを組織内で繰り返さないため、ポストモーテムの内容を共有するセクションを設けています。 LT 続いて LT セクションです。今回の発表は以下の 3 つでした。 Vue 3.4 アップデート:開発者が知っておくべきこと push 通知について勉強しました DAP の概要の理解を目指して 最新アップデート内容の共有から、担当を超えた技術領域について学んだ話や、DAP(Delish App Platform)という社内プラットフォームの技術共有など、多岐にわたるトピックが発表されました。 ここからはケータリングのピザを食べながらワイワイと LT 会を行います。 ケータリング LT1 LT2 LT3 懇親会 LT の後は懇親会に移ります。フリースペースでケータリングを食べながらエンジニア同士が交流を深めます。 普段の業務では会話する機会の無い他部署の方と交流を深めることができます。 懇親会の様子 運営に携わった所感 私は 2024 年 2 月度の TechTalk から運営に携わりました。 運営側の視点に立ってイベントの意義を考えると、単にイベントを運営するということではなく、開発組織の文化形成や、エンジニアの成長を支える重要な役割を担っていると実感しました。 運営側から参加することで、適切な時間配分や時間管理方法はあるか、より質疑応答が活発になるためにはどうすれば良いか、など今までとは異なる視点でイベントのあるべき姿を考えるようになったと思います。 発表者がスムーズに発表できる環境を整えると同時に、参加者にとって意義のあるイベントになるように努めることが重要だと認識しました。 今後も、参加者の声を大事にして、さらに意義のあるイベントにしていけるよう改善を続けていければと考えています。 おわりに エブリーでは、TechTalk をはじめとする多くの取り組みを通じて、技術者が互いに刺激を受け、成長を続ける環境を大切にしています。 今後も新たな発見と交流の場となることを期待し、TechTalk を開催していく予定です。 また、これからも DevEnable グループとして「社内外から憧れる開発組織」を目指し、働きやすい開発組織作りを追求していきます。 他のイベントを開催した際には同様にイベントレポートをお届けできればと思うのでどうぞご期待ください!
アバター
お久しぶりです ,トモニテ開発部で Software Engineer(SE) をしている 鈴木 です. 私が普段実装している トモニテ相談室 のフロントエンドはTypeScriptを採用しているのですが,トモニテ相談室の実装中にTypeScriptでは検出することが出来ないミスをしてしまい,原因解明までに時間を要した経験があります. この経験からTypeScriptを普段より少し型安全にする手法を学んだので,本記事で具体例を交えながら紹介させていただこうと思います. はじめに TypeScriptは型を区別するための方式として構造的型付けを採用しています. したがって, type 宣言子による宣言は単に構造に対してエイリアスを張っているに過ぎず,トランスパイラはエイリアスの参照先の構造のみを検査しています. この自由度は名前的型付けとは対称的であり,TypeScriptがJavaScriptに対してシームレスに型システムを導入することが出来た要因の一つとなっています. 一方で,この自由度ゆえにエンジニアがミスをしてしまった場合にもトランスパイラが見逃してしまう可能性があります. どのようなミスを見逃してしまうのかを早速皆さんに共有させていただきたいところですが,逸る気持ちを抑え,まずは構造的型付けと名前的型付けの特徴を簡単に整理します. 構造的型付けと名前的型付け 型システムが型を区別するための方式には構造的型付け(Strucural Typing)と名前的型付け(Nominal Typing)の2種類があります. 前者は型の区別の際に型の"構造"に着目し,後者は型の区別の際に型に与えられた"名前"に着目します(両者とも読んで字の如くですね). したがって,以下のような型 T と U があったとき,構造的型付けでは型 T と U は等しいと見なされ,名前的型付けでは型 T と U は異なると判定されます. type T = number ; type U = number ; 以下のようなオブジェクト型の場合も同様です. type User = { id: number ; name: string ; } type Counselor = { id: number ; name: string ; } 構造的型付けが原因で見逃してしまうミス 以下のような,ユーザーIDを渡すと該当するIDを持つユーザーを返すTypeScriptの関数を考えます. function getUserById ( id: User [ 'id' ] ) : User { return { id: 1 , name: "鈴木" , } ; } 以下のように User['id'] 型の値を渡した場合にはもちろん想定通りの挙動をします. const userId: User [ 'id' ] = 1 ; const ret = getUserById ( userId ) ここで, getUserById に対して Counselor['id'] 型の値を渡すことを考えてみます. 引数 id は User['id'] 型であることから,これ以外の型の値を渡した場合にはトランスパイラが検出し,エンジニアにメッセージを出力して欲しいものです. しかし,期待に反してトランスパイラは以下のように Counselor['id'] 型の値を渡した場合も何もメッセージを出力すること無く,問題なくトランスパイルを終えます. const counselorId: Counselor [ 'id' ] = 1 ; const ret = getUserById ( counselorId ) これはTypeScriptが型を区別するための方式として構造的型付けを採用していることが原因です. 先述の通り, type 宣言子はあくまで構造に対してエイリアスを張るだけであるため, User['id'] も Counselor['id'] も number 型にエイリアスを張っているに過ぎず,トランスパイラは両者を区別しないのです! これは良し悪しではなく,単に言語仕様なので仕方のない事なのですが,サービス上の各モデルが共通で持つ id のようなプロパティは区別出来るとエンジニアのミスが減り,開発速度の向上に繋がります. つまり,TypeScriptをもう一歩型安全に近づけるために,TypeScriptで名前的型付けを再現し, id のような共通プロパティを区別出来るようにしたいのです. 構造的に型を区別するTypeScriptにそのような方法はあるのでしょうか? Branded Primitive Branded Primitive という手法を用いることでTypeScriptで名前的型付けを再現することが可能です! この手法はTypeScriptのgithubの wiki やオライリー・ジャパンから出版されている「 プログラミングTypeScript―スケールするJavaScriptアプリケーション開発 」(Boris Cherny 著、今村 謙士 監訳、原 隆文 訳)で紹介されており,弊社社内でエンジニア同士のコミュニケーションの際に用いる場合は Brand化 と称しています. number 型をBrand化する際には以下のようにします. type T = number & { readonly brand: unique symbol } ; type U = number & { readonly brand: unique symbol } ; 上記のように,型 T と U を区別したい場合,それぞれ number 型と 互いにプロパティを区別できるオブジェクト 型の 交差型 を定義するのがBrand化です(※1). このようにするとオブジェクト型の部分が異なることから構造も異なり, T と U は互いに異なる型になります. この時点で名前的型付けを再現出来ているのですが,更に number 型とオブジェクト型の交差型は number 型のサブタイプであるため, number 型が持つ toString などのようなメソッドにも問題なくアクセス出来るのもBrand化のメリットの一つになります. なお,型 T または U を持つ値を生成する際には型アサーションが必要となります(※2). 上述の User 型や Counselor 型をBrand化すると以下のようになります. type User = { id: number & { readonly brand: unique symbol } ; name: string ; } type Counselor = { id: number & { readonly brand: unique symbol } ; name: string ; } このように id の定義にBrand化を適用することにより,無事 User['id'] 型と Counselor['id'] 型を区別できるようになりました! Brand化を適用したnumber型の区別 ※1 オブジェクト型の部分は互いに区別出来ればどのような形状になっていても構いません. okunokentaro さんの Zennの記事 を学習の際に大いに活用させていただいたのですが,その記事で紹介されているジェネリクスを参考に以下のようなジェネリクスを定義するとBrand化の手間が少なくなるかと思います. ただし,型パラメータ T に同じリテラル型を渡してしまうと構造が一致し区別がつかなくなることには注意が必要です. type BrandedNumber < T extends string > = number & { brand: T } ; type User = { id: BrandedNumber < 'User' >; name: string ; } type Counselor = { id: BrandedNumber < 'Counselor' >; name: string ; } ※2 各所で型アサーションをするのは手間やミスに繋がってしまうため,以下のような生成関数を定義すると良いです. function UserId ( id: number ) : User [ 'id' ] { return id as User [ 'id' ] } const userId = UserId ( 1 ) まとめ 本記事では私の実体験を元にTypeScriptをもう一歩型安全にする手法を紹介させていただきました. TypeScriptは型を区別するための方式に構造的型付けを採用しており,この方式が持つ自由度ゆえに本来意図していない型を利用してしまった場合にもトランスパイラが検出出来ない可能性があります. Branded Primitiveという手法がTypeScript公式wikiに掲載されており,この手法を区別したい型に対して適用することによって上述のようなミスをトランスパイラが検出出来るようになり,エンジニアのミスを仕組みで解決出来るようになります. この記事が私と同じようなミスをしてしまった経験のある開発者の方々のお役に立てたら大変嬉しいです. ここまでお読みいただきありがとうございました!
アバター
はじめに 今回は Android アプリ開発において、健康に関するデータを一元管理し、他のフィットネスアプリや健康アプリと連携が行える ヘルスコネクト を用いた開発手法についてまとめたいと思います。 なお、以前 iOS のヘルスケアアプリ連携についてもまとめた記事を公開していますので、iOS 側にもご興味があればぜひ こちら の記事もご覧ください。 ヘルスコネクトとは ヘルスコネクトは API やライブラリではなく一つのアプリで、健康に関するデータを管理できる新しいプラットフォームとなります。 ヘルスコネクトで健康データを一元管理し、その情報を Google Fit などの健康アプリと連携することができるため、ヘルスコネクトに対応している健康アプリであれば複数アプリ間で簡単にデータを同期することができます。 ヘルスコネクトアプリが端末にインストールされることで、実際に健康データにアクセスする Health Connect API とやり取りするための API サーフェスが提供されるため、データの連携が容易となる仕組みとなっています。 ヘルスコネクトアプリ自体は Google がストアに公開しているもので、 こちら からダウンロードできます。 なお、Android OS 14 の端末の場合は標準でヘルスコネクトアプリがインストールされているので、手動でのインストールは不要です。 ※2024/3/13 時点ではまだヘルスコネクトアプリはベータ版となりますので、仕様が変更となる可能性があります。 事前準備 端末にヘルスコネクトアプリをインストールしておいてください。 なお、ヘルスコネクトアプリの Android 要件が OS 9 以上となっていますので、OS 9 以上のデバイスを準備してください。 環境構築・実装手順 では早速、環境構築と実装手順についてまとめていきたいと思います。 開発環境 IDE : Android Studio Iguana | 2023.2.1 開発言語 : Kotlin ライブラリの依存関係を追加 app レベルの build.gradle に以下を追加 dependencies { implementation "androidx.health.connect:connect-client:1.0.0-alpha11" } ヘルスコネクトクライアントの取得設定を追加 AndroidManifest.xml に以下を追加 <manifest <application ... </application> <queries> <package android:name="com.google.android.apps.healthdata" /> </queries> </manifest> 取得したい健康データの権限を追加 AndroidManifest.xml に以下を追加します。 <manifest <!-- 歩数の読み取り、書き込み権限 --> <uses-permission android:name="android.permission.health.READ_STEPS"/> <uses-permission android:name="android.permission.health.WRITE_STEPS"/> <!-- 身長の読み取り、書き込み権限 --> <uses-permission android:name="android.permission.health.READ_HEIGHT"/> <uses-permission android:name="android.permission.health.WRITE_HEIGHT"/> <application <activity <!-- 権限をリクエストする Activity に追加 --> <intent-filter> <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" /> </intent-filter> </manifest> uses-permission で読み取り、書き込みをしたい権限を個別に追加、また権限のリクエストを実施する Activity に intent-filter を追加します。 intent-filter を追加することでアプリで権限についての説明画面、及び許可不許可を設定する画面が表示されます。 使用できるデータの型と権限については こちら を参照してください。 ヘルスコネクトアプリがインストールされているかチェック ここからは実装となります。 健康データにアクセスするためにはヘルスコネクトアプリがインストールされていることが必須のため、インストールチェックを行います。 // インストールされているかチェック val availabilityStatus = HealthConnectClient.sdkStatus(requireContext(), "com.google.android.apps.healthdata") if (availabilityStatus == HealthConnectClient.SDK_UNAVAILABLE) return // インストール済みの場合は Health Connect Client のインスタンスを生成 val healthConnectClient = HealthConnectClient.getOrCreate(requireContext()) インストールされていない場合は、ヘルスコネクトアプリのダウンロードページに飛ばすなどのケアが必要です。 ユーザに権限のリクエストを実施 アプリが適切に健康情報にアクセスすることを明示するため、権限のリクエストを行います。 // AndroidManifest の uses-permission で宣言した内容と同じものを設定 private val PERMISSIONS = setOf( HealthPermission.getReadPermission(StepsRecord::class), HealthPermission.getWritePermission(StepsRecord::class), HealthPermission.getReadPermission(HeightRecord::class), HealthPermission.getWritePermission(HeightRecord::class) ) private val requestPermissionActivityContract = PermissionController.createRequestPermissionResultContract() private val requestPermissions = registerForActivityResult(requestPermissionActivityContract) { granted -> if (granted.containsAll(PERMISSIONS)) { // 全ての権限が許可されたケース } else { // 許可されていない権限があるケース } } private suspend fun requestPermission(client: HealthConnectClient) { val granted = client.permissionController.getGrantedPermissions() if (granted.containsAll(PERMISSIONS)) { // 全ての権限が許可されたケース } else { requestPermissions.launch(PERMISSIONS) } } リクエストに成功するとアプリ上で以下のような画面が表示されます。 レコードクラスについて 前項で Permission を指定する際に StepsRecord というクラスを使用していますが、こちらが Health Connect API が提供している歩数のレコードデータを取り扱うクラスとなります。 以降の項目でも紹介をしますが、データを書き込み・読み込みする際は StepsRecord に歩数のデータを設定してデータのやり取りを行います。 なお身長のデータについては HeightRecord を使用するなど、データの種別毎にレコードクラスが用意されています。定義されているレコードについては こちら を参照してください。 データを書き込み 実際にヘルスコネクトに歩数のデータを書き込んでみます。 以下は 2024/3/1 12:00 〜13:00 に 1000 歩歩いたという情報を書き込む例です。 private suspend fun writeStep(client: HealthConnectClient) { try { val startTime = LocalDateTime.parse("2024-03-01T12:00:00") val endTime = LocalDateTime.parse("2024-03-01T13:00:00") val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(Instant.now()) val stepsRecord = StepsRecord( count = 1000, startTime = startTime.toInstant(zoneOffset), endTime = endTime.toInstant(zoneOffset), startZoneOffset = zoneOffset, endZoneOffset = zoneOffset, ) client.insertRecords(listOf(stepsRecord)) } catch (e: Exception) { // エラーケース } } 上記を実行後、実際にヘルスコネクトアプリを確認すると、本アプリから情報が書き込まれたことが確認できます。 健康データを読み取り 次にヘルスコネクトから歩数のデータを読み取ってみます。 以下は先ほどの項目で書き込んだデータを読み取る例です。 private suspend fun readStep(client: HealthConnectClient) { val startTime = LocalDateTime.parse("2024-03-01T12:00:00") val endTime = LocalDateTime.parse("2024-03-01T13:00:00") val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(Instant.now()) val request = ReadRecordsRequest( recordType = StepsRecord::class, timeRangeFilter = TimeRangeFilter.between( startTime.toInstant(zoneOffset), endTime.toInstant(zoneOffset) ) ) val response = client.readRecords(request) response.records.forEach { record -> Log.d("Health Connect Test", "start time = ${record.startTime.atOffset(zoneOffset)}") Log.d("Health Connect Test", "end time = ${record.endTime.atOffset(zoneOffset)}") Log.d("Health Connect Test", "count time = ${record.count}") } } 上記を実行すると以下のようにログが出力されるため、先ほど書き込んだデータを取得できたことが確認できます。 環境構築・実装手順の紹介は以上となりますが、非常に簡単な実装だけでヘルスコネクト連携ができることが伝わったのでは、と思います。 おわりに 今回はヘルスコネクトを利用した健康データの連携についてさわりの部分を紹介しましたが、最少の工数で手間なく実装ができました。 ヘルスコネクトが公開される以前は Google Cloud で API を有効にする、認証情報を発行するなど手間が多く、不慣れだと実装の前段階でつまりやすく非常に手間がかかるものでしたが、 ヘルスコネクトを利用すればアプリの実装のみに閉じて開発が行えるため、手間も敷居もかなり下がったものと思います。 以前紹介した iOS のヘルスケア連携同様、両 OS とも簡単に実装ができる基盤が整いつつあるため、これを機に両 OS の開発に触れてみてはいかがでしょうか。 今回紹介した内容が少しでも皆さまのお役に立てれば幸いです。
アバター
はじめに 子育てメディア「トモニテ」でバックエンドやフロントエンドの設計・開発を担当している桝村です。 エブリーは、現在 GitHub Copilot Business を持つ Organization アカウントであるため、多くの開発メンバーが Github Copilot を業務で活用しています。 Github Copilot は、コーディング時に AI ペアプログラマーからオートコンプリート スタイルの候補を提示する拡張機能です。 github.com Github Copilot のユースケースとして、コーディングにおける補完はもちろん、コメントからのコード・テストの自動生成やコードの説明を求めるなど、様々な場面で活用しています。 今回は Github Copilot Chat について、その基本的な機能や使い方を整理しつつ、開発者体験が向上するような活用事例を考えてみました。 前提 個人、もしくは Organization アカウントで Github Copilot サブスクリプションを持っていること 拡張機能の Github Copilot / Github Copilot Chat がインストールの上、有効化されていること 筆者は VS Code を利用しているため、本記事は VS Code における Github Copilot Chat について記載 環境 Extension version: 0.13.0 VS Code version: Code 1.87.0 Github Copilot Chat とは Github Copilot Chat は、Github Copilot の拡張の一つであり、Github Copilot との対話を可能にするチャットインターフェースです。 Copilot Chat により、コーディング関連の問い合わせをしたり、回答を得ることが可能です。 docs.github.com 2024 年 1 月 9 日に IDE の Visual Studio Code と Visual Studio 向けに一般提供 (GA) を開始し、OpenAI の GPT-4 をベースにした自然言語処理モデルを利用してます。 Github Copilot Chat における質問をより効果的・効率的にする 3 つの機能 Github Copilot Chat では、質問をより効果的・効率的にするために、以下の 3 つの機能を提供しています。 エージェント スラッシュコマンド コンテキスト変数 これらの機能は単体もしくは組み合わせて利用することで、より効果的・効率的な質問や回答を得ることができます。 code.visualstudio.com エージェント エージェント とは、特定の領域に特化した回答を生成できる AI エージェントのことです。 入力フォームに対して、「@(アットコマンド)」を使ってエージェントを指定することができます。 エージェント 説明 @workspace ワークスペース内のコードやファイルについて回答 @terminal 統合ターミナルに関するコンテキストについて回答 @vscode エディタ (VS Code) 自体のコマンドや機能について回答 使用例:Go のプロジェクトで利用されている主な技術スタックについて尋ねる @workspace このプロジェクトで利用されている主な技術スタックは何ですか? スラッシュコマンド スラッシュコマンド とは、Copilot がより適切な回答を提供できるように、特定のアクションを実行するためのコマンドです。 特定のエージェント「 @workspace 」 「 @vscode 」を前提にしているコマンドもあり、その場合、エージェントを省略して実行できます。ex. @workspace /explain とするところを /explain だけで実行可能 入力フォームに対して、「/(スラッシュ)」を使ってスラッシュコマンドを実行することができます。 @workspace に対するスラッシュコマンド スラッシュコマンド 説明 /doc ドキュメントのコメントを追加 /explain コードの動作を説明 /fix 選択したコードの問題に対する修正を提案 /generate 質問に回答するるコードを生成 /optimize 選択したコードの実行時間を分析して改善 /tests コードの単体テストを作成 /new 自然言語の説明に基づいて新しいプロジェクトを作成 @vscode に対するスラッシュコマンド スラッシュコマンド 説明 /api VS Code の拡張機能に関する回答を生成 /search VS Code の検索機能により、ワークスペース内のコードやファイルを検索 エージェント共通のスラッシュコマンド スラッシュコマンド 説明 /clear チャットをクリア /help Github Copilot Chat のヘルプを表示 使用例:サーバーサイドエンジニアにおすすめの拡張機能を教えてもらう @vscode /api サーバーサイドエンジニアにおすすめの拡張機能を10個挙げてもらえますか? コンテキスト変数 コンテキスト変数 とは、質問時に渡したい追加の情報 (コンテキスト) を指定する変数です。 入力フォームに対して、「#(シャープ)」を使ってコンテキスト変数を指定することができます。 コンテキスト変数 説明 #selection エディタの選択箇所 #editor エディタの表示領域 #file:<ファイル名> 選択したファイル #terminalSelection ターミナルの選択箇所 #terminalLastCommand ターミナルで最後に実行したコマンドと結果 使用例:yarn dev でローカルでサーバーを起動した後、そのコマンドとその結果を説明してもらう @terminal #terminalLastCommand Github Copilot Chat を利用する 3 つの UI Github Copilot Chat へ質問をする際、以下の 3 つの UI を利用することができます。 Chat View Quick chat Inline chat これらの UI は、それぞれの特性によって、使い分けることができます。 Chat View Chat View とは、VS Code のサイドバーに表示されるチャットビューです。 大小を問わず、質問に対して AI によるサポートを受けることができます。 アクティビティバーからチャットビューにアクセスするか、 ⌃⌘I キーバインドを使用します。 Quick chat Quick chat とは、エディタの上部に表示されるチャットビューです。 完全なチャットビューセッションを開始したり、エディタでインラインチャットを開くことなく、簡単に質問をすることができます。 コマンド パレットで Chat: Open Quick Chat を実行するか、キーボード ショートカット ⇧⌘I を使用します。 Inline chat Inline chat とは、コード上に表示されるチャットビューです。 コーディング中にインラインで質問をすることができます。 どのファイルでも、キーボードの ⌘I を押すと、Copilot インライン チャットを表示できます。 開発者体験が向上する活用事例 (ワークスペース編) プロジェクトを新規作成してもらう 自然言語の説明に基づいて新しいプロジェクトを作成してもらいます。 @workspace /new FizzBuzz 問題を標準出力する Golang プログラムとテストコードを作成 また、ルートディレクトリに Golang プログラムを呼び出す main.go と go.mod 、README を作成してください 結果として、質問した通りのディレクトリ構造やファイルが作成されました。 また、概ね期待通りのプログラムが生成され、エラーなく実行できることを確認しました。 特定の指示による既存のコードの修正・差分表示・一括置換してもらう Copilot Chat に作成してもらった FizzBuzz 問題のプログラムに対して、エラーハンドリングを追加してもらいます。 エラーハンドリングを追加してください 概ね期待通りの修正が行われました。 また、差分表示では、修正前と修正後のコードの違いが色分けされて表示され、修正箇所が一目でわかりました。 選択したコードを説明してもらう 選択したコードを日本語で説明してもらいます。 /explain in Japanese 外部パッケージの処理の概要のみでなく、ソースコードについても丁寧に説明してもらうことができました。 また、色分けや改行がとても見やすく、コードの理解を助けてくれました。日本語も特に違和感ないですね。 コードのエラーや問題点への修正を提案してもらう 選択したコードのエラーや問題点への修正を提案してもらいます。 事前に FizzBuzz 問題のプログラムに対して、エラーを 5 つ追加しておきます。 @workspace /fix #file:fizzbuzz.go 一つの要求に対して、漏れなく全てのエラーに対する修正を提案してくれました。 各修正に対する説明も丁寧だと感じました。 開発者体験が向上する活用事例 (ターミナル編) 実行したコマンドがエラーだった場合、修正を提案してもらう 直前のコマンドがエラーだった場合、修正を提案してもらいます。 事前に FizzBuzz 問題のプログラムに対して、エラーを追加した上で、ターミナル上でコマンドを実行します。 すると、ダイアログに Explain using Copilot が表示されるので、それをクリックします。 @terminal #terminalLastCommand コマンドの実行結果を踏まえて、修正を提案してもらうことができました。 補足ですが、#とタイプすれば、 #terminalSelection が補完されるので、コンテキスト変数は覚えなくても良さそうでした。 CLI コマンドを教えてもらう 活用事例の最後になりますが、直接プロンプトで CLI コマンドを教えてもらいます。 @terminal このワークスペースを git で管理したい 期待通りのコマンドを教えてもらうことができました。 また、サジェストされた結果が気に入ったら、ターミナルのアイコンをクリックすればコマンドの内容がターミナルに貼り付けられるようでした。 Github Copilot Chat を使って感じたメリットや比較 個人的には、Github Copilot Chat を使うメリットとしては、 ChatGPT と比較して vscode などの IDE 内で開発が一定のところまで完結できることだと感じました。 画面の切り替えや ChatGPT ↔︎ エディタのコピペをする必要がないため、開発効率が向上する可能性があると思います。 とはいえ、 GPT-4-turbo をはじめとした他の対話型 AI サービスの方が、回答の精度や記憶力の点で優れている といった点も十分に考えられるため、適材適所に使い分けることが重要だと感じました。 他のメリットとしては、 Github Copilot と比較して特定の指示による既存のコードの修正・一括置換ができたり、ワークスペース内のコードに関する質問のみでなく技術的な質問をしやすいいったところだと感じました。 おわりに 今回は、Github Copilot Chat について、その基本的な機能や使い方を整理しつつ、開発者体験が向上するような活用事例を考えてみました。 Copilot Chat は、まだまだ機能が追加されていく可能性があるため、今後のアップデートにも期待したいと思います。 本記事が Github Copilot Chat を利用される方々の参考になれば幸いです。
アバター
はじめに こんにちは。 株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。 今回は、半年ほど前に実施した挑戦Week内で行ったRedashの運用環境整備について紹介します。 DAIでは、BIツールとしてRedashをEC2で運用していましたが、運用コストの削減と運用の効率化を目的にECSへの移行を実施しました。   背景 これまではEC2のdocker上でRedashを運用していましたが、以下のような問題がありました。 infra周りが管理されていない docker-compose.ymlが管理されていない コンテナがメモリを食いつぶして、サービスが落ちることがある 障害対応、バージョンアップ、ライブラリの追加などの運用が大変 特にライブラリの追加などでRedashに変更を加えたい場合、scpでファイルを転送しsshでログインしてコマンドを実行する、などの煩雑な作業が必要でした。 手作業なためミスが起こり得る状況のほか、それらの変更の履歴が残らないなどの問題からRedashの運用環境の改善を行うことにしました。 そこで、IaCによる環境構築、CI/CDの導入、運用の効率化を目的にECSへの移行を実施しました。 ECSへの移行 ECSへの移行により、以下のような構成に変更しました。 IaC terraformによるRedashの環境管理 CI/CD AWS CodeBuildによるRedashのビルド/デプロイ ecspresso によるECSサービス/タスク定義のデプロイ Redashの運用 ECSによるBlue/Greenデプロイ ECRによるRedashのコンテナイメージの管理 redash-infra ecspressoによるECSへのデプロイ ecspressoは、ECSのタスクやサービス定義の管理/デプロイを行うためのツールです。 ecspressoを利用することで、ECSのタスクやサービス定義をjsonで管理し、コマンド一つでデプロイが行えるようになります。 ecspresso.yml region : ap-northeast-1 cluster : redash-fargate service : redash-server service_definition : ecs-service-def.json task_definition : ecs-task-def.json timeout : "10m0s" ecs-service-def.json { " deploymentConfiguration ": { " deploymentCircuitBreaker ": { " enable ": true , " rollback ": true } , " maximumPercent ": 200 , " minimumHealthyPercent ": 100 } , " deploymentController ": { " type ": " ECS " } , " desiredCount ": 1 , " enableECSManagedTags ": false , " enableExecuteCommand ": true , " healthCheckGracePeriodSeconds ": 0 , " launchType ": " FARGATE ", " loadBalancers ": [ { " containerName ": " redash-server ", " containerPort ": 5000 , " targetGroupArn ": " arn " } ] , " networkConfiguration ": { " awsvpcConfiguration ": { " assignPublicIp ": " ENABLED ", " securityGroups ": [ " sg- " ] , " subnets ": [ " subnet- ", " subnet- " ] } } , " pendingCount ": 0 , " platformFamily ": " Linux ", " platformVersion ": " LATEST ", " propagateTags ": " NONE ", " runningCount ": 0 , " schedulingStrategy ": " REPLICA ", " tags ": [ { " key ": " Service ", " value ": " redash " } , { " key ": " Terraformed ", " value ": " 1 " } ] } ecs-task-def.json { " containerDefinitions ": [ { " command ": [ " server " ] , " cpu ": 0 , " secrets ": [ ] , " environment ": [ ] , " essential ": true , " image ": " ecr.image ", " logConfiguration ": { " logDriver ": " awslogs ", " options ": { " awslogs-create-group ": " true ", " awslogs-group ": " /ecs/redash ", " awslogs-region ": " ap-northeast-1 ", " awslogs-stream-prefix ": " ecs " } } , " name ": " redash-server ", " portMappings ": [ { " appProtocol ": "", " containerPort ": 5000 , " hostPort ": 5000 , " protocol ": " tcp " } ] , " ulimits ": [ { " hardLimit ": 65536 , " name ": " nofile ", " softLimit ": 65536 } ] } ] , " executionRoleArn ": " arn ", " taskRoleArn ": " arn ", " family ": " redash-server ", " ipcMode ": "", " cpu ": " 2048 ", " memory ": " 4096 ", " ephemeralStorage ": { " sizeInGiB ": 30 } , " networkMode ": " awsvpc ", " pidMode ": "", " requiresCompatibilities ": [ " FARGATE " ] , " tags ": [ { " key ": " Service ", " value ": " redash " } , { " key ": " Terraformed ", " value ": " 1 " } ] } タスク定義、サービス定義をjsonで管理し、ecspresso.ymlで定義した内容を元に、 ecspresso deploy --config ecspresso.yml でデプロイを行うことができます。 また、diffを出力することもできるため、変更点を把握しやすくなります。 移行後 CI/CD整備により、masterブランチへのマージをトリガーにRedashのビルド/デプロイが自動で行われるようになりました。 これにより、ライブラリ追加などの変更が発生した際に、手作業でのデプロイ作業が不要となりました。 またecspressoによるECSへのデプロイにより、ECSのタスクやサービス定義の管理が容易になり、diffを出力することで変更点を把握しやすくなり意図しない変更を防ぐことができるようになりました。 最後に 今回はRedashの運用環境の改善を行いました。 EC2での運用からECSへの移行、CI/CDの導入、IaCによる環境管理などを行い、運用コストの削減と運用の効率化を実現しました。
アバター
はじめに こんにちは。DELISH KITCHEN 開発部の村上です。 DELISH KITCHENでは、AmazonIVSを用いて去年ライブ機能をリリースしました。AmazonIVSやライブ配信基盤については以前こちらのブログで紹介しているので気になる方はぜひみてください。 tech.every.tv 今回はこのライブ機能の録画配信にAmazonIVSの録画機能を活用する機会があったのでその取り組みを紹介させていただきます。 なお、社内ではライブの録画配信機能をアーカイブ配信と呼んでいるので、これ以降はアーカイブ配信という言葉を使わせていただきます。 S3への録画機能 AmazonIVSでは S3への自動録画 を機能として提供しています。AmazonIVSではライブストリームに関する設定情報をチャンネルという単位で提供をしていますが、録画設定はチャンネルから独立しており、複数のチャンネルで同じ録画設定を紐付けることができるようになっています。 以下のような項目を自動録画の設定としてカスタマイズすることができます。 記録するビデオのレンディション サムネイルの記録 記録の間隔 解像度 保存方法 フラグメント化されたストリームのマージ 録画動画の格納先S3 コンソール上の設定画面 実際に録画を有効にしたチャンネルでライブストリームを開始すると指定したS3にこのような形で記録したものが保存されていきます。 /ivs/v1/<aws_account_id>/<channel_id>/<year>/<month>/<day>/<hours>/<minutes>/<recording_id> 保存されるものは大きく分けて二つのカテゴリに分かれています。 /events 開始、終了、失敗といった録画イベントに対応するJSON形式のメタデータファイル /media hls 配下には再生可能なHLSマニフェスト、メディアファイル thumbnails 配下にはライブ中に記録されたサムネイル画像 このようにAmazonIVSでは録画設定を作成し、チャンネルに紐づけるだけでそこで配信されるライブストリームを自動で録画し、そのまま配信可能な形にS3に保存することができます。 アーカイブ配信での活用 録画されたものをそのままアーカイブ配信に使う場合はすでにHLS配信可能な状態になっているため、あとはCloudFront経由でアクセスできるようにしてしまえば簡単に終わりそうです。しかし、今回DELISH KITCHENが提供するライブでは以下二つの理由からそのまま配信することができませんでした。 アプリでライブ配信をする前から配信テストでストリームを繋ぐ関係で録画内容に自動で不必要な映像が録画されてしまう アーカイブ配信を行う前に内容の編集を行いたいニーズがある そこで今回はこの順序で処理をすることによってIVSの録画機能を活用しつつ、アーカイブ配信まで行えるようにしました。 録画終了をEventBridge経由でAPI通知 MediaConvertのjobを作成して録画映像をmp4に変換し、ダウンロードと編集可能な状態にする 再編集したものをS3にアップロードして、EventBridge経由でAPI通知 MediaConvertのjobを作成して、hlsに変換し、アーカイブ配信をCloudFront経由で行う 本記事では前半のmp4変換するまでをAmazonIVSの録画機能が関わるところとして話していきます。注意点として、AmazonIVSの録画機能自体は録画自体を必ず成功させることが担保されているわけではないのでそこのみに依存することはせずに予備として配信ソフトウェアの録画機能だったり、別の仕組みを準備することは前提としています。 録画終了をEventBridge経由でAPI通知 AmazonIVSはEventBridgeと連携して録画の開始や終了、失敗をイベントとして検知し、他システムと連携することが可能になっています。イベントパターンを指定すると録画終了をモニタリングすることができ、適切なターゲットに通知を行います。 { " detail ": { " recording_status ": [ " Recording End " ] } , " detail-type ": [ " IVS Recording State Change " ] , " source ": [ " aws.ivs " ] } 通知内容には録画内容の保存先の情報やチャンネルやストリーム情報が入ります。 { " version ": " 0 ", " id ": " test-test ", " detail-type ": " IVS Recording State Change ", " source ": " aws.ivs ", " account ": " 11111111 ", " time ": " 2020-06-24T07:51:32Z ", " region ": " ap-northeast-1 ", " resources ": [ " arn:aws:ivs:ap-northeast-1:11111111:channel/AbCdef1G2hij " ] , " detail ": { " channel_name ": " Channel ", " stream_id ": " st-1111aaaaabbbbb ", " recording_status ": " Recording End ", " recording_status_reason ": "", " recording_s3_bucket_name ": " dev-recordings ", " recording_s3_key_prefix ": " ivs/v1/11111111/AbCdef1G2hij/2020/6/23/20/12/1111aaaaabbbbb ", " recording_duration_ms ": 99370264 , " recording_session_id ": " a6RfV23ES97iyfoQ ", " recording_session_stream_ids ": [ " st-254sopYUvi6F78ghpO9vn0A ", " st-1A2b3c4D5e6F78ghij9Klmn " ] } } 今回は recording_s3_bucket_name と recording_s3_key_prefix がわかっていれば、放映していたチャンネルIDと録画ファイルの格納先が特定できるのでこの二つにフィルターをかけてAPI通知を行いました。 MediaConvertのjobを作成してmp4変換処理を行う APIサーバーに通知された録画終了イベントをもとにMediaConvertのjobを作成してmp4変換を行っていきます。MediaConvertでは HLS入力をサポート しており、HLSのマニフェストファイルを入力として指定できるようになっています。AmazonIVSでは recording_s3_key_prefix に続く形で /media/hls/master.m3u8 にマニフェストファイルが保存されているのでこちらを入力とします。 これだけでもjobは作成可能なのですが、今回はAmazonIVSのイベントメタデータを活用して変換する録画ファイルの軽量化を行ったのでその紹介をします。 冒頭で説明したようにDELISH KITCHENが提供するライブではライブ配信をする前から配信テストでストリームを繋ぐ関係で録画内容に自動で不必要な映像が録画されるという現象が起こっていました。つまり、実際にユーザーに見える形のライブは30分でも配信テストも含めると1時間録画されていることもあり、このままmp4変換すると無駄に大きいファイルサイズでS3に格納してしまいます。そこでAmazonIVSのイベントメタデータファイルから以下のような処理を行いました。 recording_s3_key_prefix 配下の /events/recording-ended.json から実際の録画開始時間を取得 マスターデータとして保持しているライブ開始時間と録画開始時間の差分を算出 算出された時間だけ動画の冒頭をクリッピングする設定をMediaConvertのjob設定に追加 /events/recording-ended.json にはこれらの情報が保存されており、 recording_started_at に録画開始時間が保存されているのでこれが冒頭余分に録画された内容の差分検出に使うことができます。 { " version ": " v1 ", " recording_started_at ": " 2023-12-10T10:16:24Z ", " recording_ended_at ": " 2023-12-10T10:22:00Z ", " channel_arn ": " arn:aws:ivs:ap-northeast-1:11111111:channel/AbCdef1G2hij ", " recording_status ": " RECORDING_ENDED ", " media ": { " hls ": { " duration_ms ": 338839 , " path ": " media/hls ", " playlist ": " master.m3u8 ", " byte_range_playlist ": " byte-range-multivariant.m3u8 ", " renditions ": [ { " path ": " 480p30 ", " playlist ": " playlist.m3u8 ", " byte_range_playlist ": " byte-range-variant.m3u8 ", " resolution_width ": 480 , " resolution_height ": 852 } , { " path ": " 360p30 ", " playlist ": " playlist.m3u8 ", " byte_range_playlist ": " byte-range-variant.m3u8 ", " resolution_width ": 360 , " resolution_height ": 640 } , { " path ": " 160p30 ", " playlist ": " playlist.m3u8 ", " byte_range_playlist ": " byte-range-variant.m3u8 ", " resolution_width ": 160 , " resolution_height ": 284 } , { " path ": " 720p30 ", " playlist ": " playlist.m3u8 ", " byte_range_playlist ": " byte-range-variant.m3u8 ", " resolution_width ": 720 , " resolution_height ": 1280 } ] } , " thumbnails ": { " path ": " media/thumbnails ", " resolution_height ": 1280 , " resolution_width ": 720 } } } MediaConvertはInputClippingsという設定値で HH:mm:ss:ff 形式で動画のクリッピングを設定できるので検出した差分の時間を使って冒頭余分に録画した部分は切り取る設定をします。この設定を行うことによってmp4変換後のファイルを最大で半分まで軽量化することができ、コストカットも実現できました。 { " Inputs ": [ { " InputClippings ": [ { " StartTimecode ": " 00:20:04:00 " } ] , " TimecodeSource ": " ZEROBASED " , } ] } 仕組み構築でのTips 以上がアーカイブ配信でのAmazonIVSの録画機能の活用だったのですが、細かいところで少し工夫があったので最後にそちらの紹介をします。 自動録画の設定項目のチューニング 冒頭で説明したようにAmazonIVSの録画機能はいくつか設定項目があり、今回あえてデフォルトから変えた設定値があります。 記録するビデオのレンディション 今回のように自動で録画されたものをそのままアーカイブ配信に使わない場合、AmazonIVSで記録されるビデオのレンディションは全て保存する必要がありません。そこでデフォルトのすべてのレンディション保存からHDのみを保存するような指定をすることで不必要に録画データを保存することを避けることができます。AmazonIVSでの録画機能はそれ自体に追加料金はかからないですが、S3へ保存されるデータへの従量課金は他と同じようにあります。こうした小さな工夫にも思えますが、ライブの頻度、時間によっては長い期間で大きな差になるところだと思ってます。 フラグメント化されたストリームのマージ 基本的に自動録画の設定はデフォルトでストリーム配信ごとの録画となっています。しかし、配信中に予期せぬトラブルでストリームが切断されてしまう場合もあるでしょう。その場合に再接後に録画が分かれてしまうと不便です。そこでウィンドウでの再接続を有効化し、再接続ウィンドウで再開までの最大間隔を秒数で指定することでその時間だけストリームが終了しても録画を完全に終了するまで待機することができます。つまり、その間での再接続は同じ録画になるためトラブル時に便利な設定となっています。 MediaConvertでのuserMetadataタグの活用 今回説明では省きましたが、MediaConvertでjobを作成したあとはそのまま何もしないというより多くの場合では成功や失敗をまたEventBridge経由で通知し、アプリケーション側で適切な処理をしていくと思います。そこで課題になったのが大きく2点あります。 MediaConvertのjob作成時に判別していた内部のライブ情報やConvertの目的種別が通知されるjobIDなどでは判別できない MediaConvertが違う目的で複数あるとEventBridgeでは判別できずにどちらの場合でも同じターゲットに通知がいってしまう そこで userMetadataタグ の活用です。userMetadataタグにはMediaConvertのjob作成時に任意のkey,valueを設定できるようになっており、通知時に受け取りたい情報に含めることができます。 例えば以下のような情報をuserMetadataタグに入れておけば、任意の情報を通知時に渡すことができ、EventBridgeの発火もフィルタリングできます。 { " UserMetadata ": { " live_id ": " 77 ", " convert_type ": " recording " } , } EventBridge側のフィルタリング { " detail ": { " status ": [ " COMPLETE " ] , " userMetadata ": { " convert_type ": [ " recording " ] // メタデータでの種別でのフィルタリング } } , " detail-type ": [ " MediaConvert Job State Change " ] , " source ": [ " aws.mediaconvert " ] } おわりに 今回はライブ機能のアーカイブ配信におけるAmazonIVSの録画機能の活用についてご紹介させていただきました。AmazonIVSを使用することでライブ配信だけではなくアーカイブ配信でも活用ができたので、弊社独自の要件もあるとは思いますが参考になれば幸いです。 エブリーではまだまだ一緒にプロダクトを作っていける仲間を募集中です。テックブログを読んで少しでもエブリーのことが気になった方、ぜひ一度カジュアル面談でお話しましょう!! corp.every.tv
アバター
はじめに TIMELINE開発部の内原です。 本日はGo言語のテストにおける可読性について考えてみます。この記事を読んでいただいている皆さんにも、テストを書いていて以下のような問題を感じた経験があるのではないでしょうか。 既存のコードに機能追加をするためテストコードにもテストケースを追加しようとしたが、テストコードが複雑で読み解きづらく、テストを追加するのに苦労した テストケースの種類が多く、少しデータを追加しただけでも既存のテストが動かなくなる テストデータの登録方法が複雑で、テストコードの実装以前に手間取る 上記のような問題に対処するべく、実践的なシナリオに従ってGo言語のテストコードを実際に書きつつ都度改善していくことにします。 仕様(ver.1) ユーザ情報には名前、状態(有効、無効)とがある 有効なユーザ一覧を返却する関数 LoadActive() を実装する。その際並び順はIDの昇順とする データ構造と実行SQL type User struct { ID int `db:"id"` Name string `db:"name"` State int `db:"state"` ) CREATE TABLE users ( id integer NOT NULL AUTO_INCREMENT, name varchar ( 32 ) NOT NULL , state integer NOT NULL , PRIMARY KEY (id) ); SELECT * FROM users WHERE state= 1 ORDER BY id; テストコードの実装(抜粋) LoadActive() 関数のテストコードとしては以下のようになりました。 テストデータとしてActive, Inactiveのユーザを複数件登録し、Activeのユーザのみが返却されること、並び順がIDの昇順であることを確認しています func setupUser() { u := NewUserRepository() u.Create(model.User{Name: "user1" , State: Active}) u.Create(model.User{Name: "user2" , State: Inactive}) u.Create(model.User{Name: "user3" , State: Active}) } func teardownUser() { // データのクリーンアップ処理など } func TestUserLoadActive(t *testing.T) { t.Cleanup(teardownUser) setupUser() u := NewUserRepository() users, err := u.LoadActive() if err != nil { t.Fatalf( "expected no error but got %v" , err) } if len (users) != 2 { t.Fatalf( "expected 2 users but got %v" , len (users)) } if users[ 0 ].Name != "user1" { t.Fatalf( "expected user1 but got %v" , users[ 0 ].Name) } if users[ 1 ].Name != "user3" { t.Fatalf( "expected user3 but got %v" , users[ 1 ].Name) } } 仕様(ver.2) ver.1の仕様に対し、以下の機能追加をすることになりました。 新たにグループというデータ構造を設ける ユーザは1つ以下のグループに属することができるものとする(しないこともできる) LoadActive() が返却するユーザは、グループに属しているもののみとする データ構造と実行SQL type User struct { ID int `db:"id"` Name string `db:"name"` State int `db:"state"` GroupID * int `db:"group_id"` ) type Group struct { ID int `db:"id"` Name string `db:"name"` ) CREATE TABLE users ( id integer NOT NULL AUTO_INCREMENT, name varchar ( 32 ) NOT NULL , state integer NOT NULL , group_id integer NULL , PRIMARY KEY (id) ); CREATE TABLE groups ( id integer NOT NULL AUTO_INCREMENT, name varchar ( 32 ) NOT NULL , PRIMARY KEY (id) ); SELECT u.* FROM users u JOIN groups g ON u. group_id =g.id WHERE u.state= 1 ORDER BY u.id; テストコードの実装(抜粋) LoadActive() 関数のテストコードにテストデータを追加して、新たに追加されたグループの仕様についてもテストされるようにしました。 新たにユーザ用のレコードを追加し、Activeであってもグループに属していないため返却されない、という確認をしています。 この時点ではテストコード自体には手を入れず、テストデータの追加のみを行いました。それでも追加仕様に対する確認要件は満たせているためです。 func setupUser() { g := NewGroupRepository() group, _ := g.Create(model.Group{Name: "group" }) u := NewUserRepository() u.Create(model.User{Name: "user1" , State: Active, GroupID: &group.ID}) u.Create(model.User{Name: "user2" , State: Inactive, GroupID: nil }) u.Create(model.User{Name: "user3" , State: Active, GroupID: &group.ID}) u.Create(model.User{Name: "user4" , State: Active, GroupID: nil }) u.Create(model.User{Name: "user5" , State: Inactive, GroupID: &group.ID}) } func teardownUser() { // データのクリーンアップ処理など } func TestUserLoadActive(t *testing.T) { t.Cleanup(teardownUser) setupUser() u := NewUserRepository() users, err := u.LoadActive() if err != nil { t.Fatalf( "expected no error but got %v" , err) } if len (users) != 2 { t.Fatalf( "expected 2 users but got %v" , len (users)) } if users[ 0 ].Name != "user1" { t.Fatalf( "expected user1 but got %v" , users[ 0 ].Name) } if users[ 1 ].Name != "user3" { t.Fatalf( "expected user3 but got %v" , users[ 1 ].Name) } } 仕様(ver.3) ver.2の仕様に対し、さらに以下の機能追加をすることになりました。 グループにも状態(有効、無効)を設ける LoadActive() が返却するユーザは、有効なグループに属しているもののみとする データ構造と実行SQL CREATE TABLE users ( id integer NOT NULL AUTO_INCREMENT, name varchar ( 32 ) NOT NULL , state integer NOT NULL , group_id integer NULL , PRIMARY KEY (id) ); CREATE TABLE groups ( id integer NOT NULL AUTO_INCREMENT, name varchar ( 32 ) NOT NULL , state integer NOT NULL , PRIMARY KEY (id) ); SELECT u.* FROM users u JOIN groups g ON u. group_id =g.id WHERE u.state= 1 AND g.state= 1 ORDER BY u.id; テストコードの実装(抜粋) ver.2 の対応と同じようにテストデータのパターンを増やすこともできますが、今でもそれなりにレコード数があるのにさらに増やすとなると、ユーザの状態、グループ所属有無、グループの状態の組み合わせぶんレコードを作らなければならず、考えただけでも大変そうです。 だんだんとテストを書くのが辛くなってきました。というわけでアプローチを変えてみます。 そもそも LoadActive() が提供している機能はなんでしょうか? 指定の条件に合致したレコードを返却する レコードの並び順を定まったものにする 上記の2つであると考えられそうです。分かりやすくするため、上記それぞれについてテストを分けてみます。 1番目については、単に返却されるかされないかだけに着目すればよいので、1件のデータのみを対象とすることにします。 またその際テーブル駆動テストのアプローチを用いて、全組み合わせのテストデータを用意したとしても、テストコードが冗長にならないようにします。 // LoadActive の並び順についてのテスト func TestUserLoadActive_Order(t *testing.T) { setupUser := func () { g := NewGroupRepository() group, _ := g.Create(model.Group{Name: "group" , State: Active}) u := NewUserRepository() u.Create(model.User{Name: "user1" , State: Active, GroupID: &group.ID}) u.Create(model.User{Name: "user2" , State: Active, GroupID: &group.ID}) } t.Cleanup(teardownUser) setupUser() u := NewUserRepository() users, err := u.LoadActive() if err != nil { t.Fatalf( "expected no error but got %v" , err) } if len (users) != 2 { t.Fatalf( "expected 2 users but got %v" , len (users)) } if users[ 0 ].Name != "user1" { t.Fatalf( "expected user1 but got %v" , users[ 0 ].Name) } if users[ 1 ].Name != "user2" { t.Fatalf( "expected user2 but got %v" , users[ 1 ].Name) } } // LoadActive の返却条件についてのテスト func TestUserLoadActive_Condition(t *testing.T) { tests := [] struct { name string userState int64 hasGroup bool groupState int64 hasUser bool }{ { "active user,active group" , Active, true , Active, true }, { "active user,inactive group" , Active, true , Inactive, false }, { "active user,no group" , Active, false , Inactive, false }, { "inactive user,active group" , Inactive, true , Active, false }, { "inactive user,inactive group" , Inactive, true , Inactive, false }, { "inactive user,no group" , Inactive, false , Inactive, false }, } setupUser := func (userState int64 , hasGroup bool , groupState int64 ) { var groupID * int64 if hasGroup { g := NewGroupRepository() group, _ := g.Create(model.Group{Name: "group" , State: groupState}) groupID = &group.ID } u := NewUserRepository() u.Create(model.User{Name: "user" , State: userState, GroupID: groupID}) } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { t.Cleanup(teardownUser) setupUser(tt.userState, tt.hasGroup, tt.groupState) u := NewUserRepository() users, err := u.LoadActive() if err != nil { t.Fatalf( "expected no error but got %v" , err) } if tt.hasUser { if len (users) != 1 { t.Fatalf( "expected 1 user but got %v" , len (users)) } } else { if len (users) != 0 { t.Fatalf( "expected 0 user but got %v" , len (users)) } } }) } } さらなる改善 現時点でもそれなりに読みやすいテストコードにはなったと思いますが、まだテストデータの登録処理においていくつか課題があります。 テストでは意識する必要がなくとも非NULLなカラム(Group.Nameなど)にはなんらか値を指定しなければならない データの依存関係をテストコード内で意識しておかなければならない 作成処理のエラーハンドリングを省略しており、仮に登録に失敗していた場合テスト自体も正常に動作しなくなる 簡単にテストデータを作成するために factory-go というライブラリを利用することにします。 これはRuby on Railsでよく用いられる factory_bot というライブラリにインスパイアされたもので、使い方は似ています。 以下のようなFactoryを用意しておきます。 usersがgroupsに依存しているため、SubFactoryという機能を用いています。 var UserFactory = factory.NewFactory( &model.User{}, ).SeqInt64( "ID" , func (n int64 ) ( interface {}, error ) { return n, nil }).Attr( "Name" , func (args f.Args) ( interface {}, error ) { user := args.Instance().(*model.User) return fmt.Sprintf( "username-%d" , user.ID), nil }).Attr( "State" , func (args f.Args) ( interface {}, error ) { return Active, nil }).SubFactory( "Group" , GroupFactory).OnCreate( func (args f.Args) error { m := args.Instance().(*model.User) return insertUser(m) }) func insertUser(m *model.User) error { if m.Group != nil { m.GroupID = &m.Group.ID } _, err := // INSERT INTO usersする処理 return err } var GroupFactory = factory.NewFactory( &model.Group{}, ).SeqInt64( "ID" , func (n int64 ) ( interface {}, error ) { return n, nil }).Attr( "Name" , func (args f.Args) ( interface {}, error ) { group := args.Instance().(*model.Group) return fmt.Sprintf( "groupname-%d" , group.ID), nil }).Attr( "State" , func (args f.Args) ( interface {}, error ) { return Active, nil }).OnCreate( func (args f.Args) error { m := args.Instance().(*model.Group) return insertGroup(m) }) func insertGroup(m *model.Group) error { _, err := // INSERT INTO groupsする処理 return err } 上記のようなFactoryを用意しておくことで、テストコードの登録処理が以下のように簡略化できます。 MustCreate... の関数は登録処理に失敗するとpanicするため、正しいテストデータが準備できていないままテストが続行されるということはなくなる テストにおいて関心のないカラムについては指定する必要がなくなる(指定してもよい) データの依存関係についてテストコード側で把握しておく必要はなく、Factoryの使用方法を理解しておけば適切なデータ生成が行われる func TestUserLoadActive_Order(t *testing.T) { setupUser := func () { UserFactory.MustCreateWithOption( map [ string ] interface {}{ "Name" : "user1" }) UserFactory.MustCreateWithOption( map [ string ] interface {}{ "Name" : "user2" }) } // ... } func TestUserLoadActive_Condition(t *testing.T) { // ... setupUser := func (userState int64 , hasGroup bool , groupState int64 ) { var group *model.Group if hasGroup { group = GroupFactory.MustCreateWithOption( map [ string ] interface {}{ "State" : groupState, }).(*model.Group) } User.MustCreateWithOption( map [ string ] interface {}{ "State" : userState, "Group" : group, }) } // ... } まとめ 今回はGo言語におけるテストコードの可読性を上げるアプローチについて、実際にコードを交えながら考えてみました。 テストコードは挙動を担保する重要な役割を持っていますが、テストコード自体のメンテナンス性が下がると徐々に十分なテストが行われない状態に陥いりがちです。 そういった将来の問題を避けるためにも、自分がテストコードを書くタイミングで、他人が見て理解しやすいコードになっているかを意識しておくのが重要と考えています。
アバター
WWDC23で発表されているように、Xcode 15からPrivacy Manifestsという機能が追加されています。 Privacy Manifestsの実体は PrivacyInfo.xcprivacy という名前のplistファイルで、アプリやSDKのプライバシーに関する情報を記述します。 2024年春以降、Privacy ManifestsはApp Storeのレビューの対象になり、新規やアップデートの際に適切に対応しないとリジェクトされるようです。 Privacy Manifestsにはプライバシーに関する複数の情報が記述できますが、レビューで義務化されるものはその一部だけのようです。 この記事では、2024年春時点でアプリに求められる最低限の対応について調べました。 参考 developer.apple.com developer.apple.com developer.apple.com developer.apple.com developer.apple.com Privacy Manifestsの機能 Privacy Manifestsにはプライバシーに関する複数の情報を記述できます。 Privacy report Tracking domains Required reason APIs Privacy report (Privacy Nutrition Labels) PrivacyInfo.xcprivacy の NSPrivacyCollectedDataTypes が該当します。 App Storeでアプリを公開するとき、個人情報の収集とトラッキングについてのレポートが必要です。アプリには多くの場合サードパーティーSDKが組み込まれており、それらのSDKで収集/トラッキングされる情報についてもアプリ開発者が把握して、一括して表示する責任があります。 サードパーティーSDKで収集/トラッキングされる情報についてそれぞれ調査し、それらを統合してレポートを作成するのは手間がかかり、不正確になる可能性もありました。 Privacy Manifestsで提供されるPrivacy report は、この問題を解決するためのものです。サードパーティーSDKに含まれるPrivacy Manifestsには収集とトラッキングの情報が含まれており、Xcodeがそれらを自動的に統合して一つのわかりやすいレポートにまとめてくれます。 Privacy Manifestsの内容が自動的にApp Storeに反映され、手動で入力する必要がなくなると思っていたのですが、現時点ではそうではないようです。 Xcodeが出力したレポートを参照しながらApp Store Connectに入力する想定のようです。 2024年春時点では対応は必須でありません。 Tracking domains PrivacyInfo.xcprivacy では、 NSPrivacyTracking , NSPrivacyTrackingDomains で記述します。 App Tracking Transparencyによるトラッキングの制限をより確実に行うための機能です。 トラッキングをする前にトラッキング許可ステータスをチェックする必要がありますが、SDKの利用方法の誤りなどによって意図せずトラッキングが行われてしまう場合があります。 意図しないトラッキングを防ぐために、Privacy Manifestsにはトラッキングを行うドメインが含まれています。ユーザーがトラッキング許可を提供していない場合、iOS 17 は、アプリに含まれるPrivacy Manifestsで指定されているドメインへの接続を自動的にブロックします。 これによって、実装ミスなどにより意図せずトラッキングをしてしまう可能性をなくすことができます。 2024年春時点では対応は必須でありません。 Required reason APIs PrivacyInfo.xcprivacy では、 NSPrivacyAccessedAPITypes が該当します。 ユーザーがアプリにトラッキングの許可を与えていたとしても、フィンガープリンティングは許されません。 フィンガープリンティングに悪用される可能性のあるAPIのリストが公開されています。 developer.apple.com これらのAPIを使用する場合には、Privacy ManifestsにAPIを使用する理由を記述する必要があります。これに基づいて、APIが本来の目的に沿って適切に使用されているかレビューされます。 2024年春時点で対応必須です。 サードパーティーSDKの対応 一般的に使用されるサードパーティSDKのリストが公開されています。 developer.apple.com アプリにこれらのSDKが組み込まれている場合は、SDKのPrivacy Manifestsを含める必要があります。 SDKがPrivacy Manifestsに対応していることを確認して、必要ならSDKを最新版にアップデートしたり、場合によっては他のSDKへの変更を検討する必要があるかもしれません。 2024年春時点で対応必須です。 まとめ 以上をまとめると、2024年春時点でアプリに求められる最低限の対応は以下のようになると考えています。 Required reason APIs の対応 該当するAPIを使用している場合、 PrivacyInfo.xcprivacy を作成し、APIの使用理由を記述する必要があります。 サードパーティーSDKの対応 リストに該当するサードパーティSDKを利用している場合、SDKがPrivacy Manifestsに対応し、署名されている必要があります。SDKの対応状況を確認し、必要ならSDKを最新版にアップデートしたり、場合によっては他のSDKへの変更を検討する必要があるかもしれません。
アバター
エブリーで小売業界向き合いの開発を行っている @kosukeohmura です。 昨年、エブリーではネットスーパーの事業を株式会社ベクトルワン様から引き継ぎました。引き継いだシステムを運用していく中で、ネットスーパーの各種サイトや API に使用している 20 個超の SSL 証明書の有効期限を切らさないように更新していく必要があり、そのために監視を導入したお話をします。 引き継ぎ作業の概観については以前公開しました ゼロからはじめるシステム引き継ぎ - every Tech Blog に書きましたので、合わせて御覧ください。 背景とモチベーション システムを引き継いだ時点では SSL 証明書の更新の運用は素朴なものでした。具体的にはエンジニアが有効期限を切らさないようにたまにそれぞれのサイトの有効期限をチェックし、有効期限が近づいたものを発見次第手動で更新作業を行うというものです。抜け漏れが容易に起こり得ますし、更新が漏れた際はサービス停止せざるを得ないため、ミスの起こりづらい仕組みを導入する必要性を感じていました。そこでまずは有効期限に近づいたことを気付けるようにすることにしました。ゆくゆくは更新作業自体を自動化したいところですが、それを行ったとしても自動的に有効期限が近づいた際に検知する仕組みは依然として必要になると考えています。 お手軽に Google カレンダーやタスクツールへの手動登録・管理を考えましたが、タスクや予定の登録などの作業が面倒なのとオペレーションミスの可能性が許容できなそうだと思っていたところ、 Datadog で有効期限を外形監視できることを知り、早速利用することとしました。 Datadog での SSL の監視にかかる料金 Datadog の SSL 監視機能は正しくは SSL Tests といい、Datadog の Synthetic Monitoring というプロダクトの中の API Tests というテスト群の 1 種として存在します。 API Tests の料金 は 2024/02 現在月々 10,000 回のテスト実行ごとに $6.25 です。 SSL Tests はそう高頻度で実施しても意味が薄いので、比較的テスト回数が少なく済み、料金はお手頃になります。仮に 1 日毎の SSL Test 実行であればドメインごとに月々 30 テスト実行程度に収まるので、300 を超えるドメインを $6.25 で監視できることになります。一方 API Tests には SSL の他に HTTP や gRPC といったテストも存在しますが、こういった死活監視となると高頻度でテストを行うことになり料金が高くなります。仮に 1 分毎に 1 つの API のテストを行うとすると、月のテスト回数は 40,000 回を超えてくるため月々 $25 かかってきます。これは割高に感じました。 ちなみにエブリーでは Web サイトや API の死活監視に Pingdom の Uptime monitoring を使用していますが、そこでは月々 $10 で 10 個の URL / IP に対して 1 分毎の外形監視が可能です。 このように Datadog の API Tests ではテスト実行回数で価格が決まるので、行うテストの特性・頻度によって費用には大きく幅が出ます。 導入してみる 私自身こういった外形監視を一から設定することは初めてでしたが、 ドキュメント を読むと特に迷うこと無く設定できました。主な設定値を次に記します: テスト元のロケーション 1 つ (Tokyo) のみ。複数ロケーションからリクエストする意義は少ないと感じたため 有効期限のアサート閾値 30 日。運用次第で調整するかもしれませんが、最初は余裕を持って 再通知間隔 3 日 リトライ条件 リトライを行わないように設定。証明書の有効期限が近づいている場合何度テストしても同じ結果となるため テスト頻度 1 日に 1 度 アラート時の通知先は Slack とし、メッセージは下記のような内容としました。 {{#is_alert}} {{#is_renotify}} 引き続き、SSL 証明書の期限が 30 日以内もしくは SSL が正常でない状態が続いています。 SSL の状態を確認し、必要であればフローに沿って証明書の更新を進めてください。 - [SSL 証明書更新フロー](<社内ドキュメントへの URL>) {{/is_renotify}} {{^is_renotify}} SSL 証明書の期限が 30 日以内もしくは SSL の状態が正常ではありません。 {{/is_renotify}} {{/is_alert}} {{#is_no_data}} SSL のデータが取れていません。 {{/is_no_data}} {{#is_recovery}} SSL の状態が正常になりました。 {{/is_recovery}} 上記設定をそれぞれのドメインに対して繰り返した結果、下記のように今回の監視対象の 20 以上の SSL 証明書の期限を Datadog 上に一覧化できました!有効期限もひと目で分かり、30 日以内となったもののステータスは ALERT となっています。いい感じです。 SSL Test 一覧 Slack にも下記のように通知が届きます(ちょっと文言が変わっていますが)。 実際の Slack 通知 導入時に困ったこと ひとたび把握・設定してしまえば簡単に SSL の監視が実現でき、しかも料金もお手頃で満足度は高いのですが、少し思うところもあったので挙げてみます。 有効期限切れまでの残日数をアラート時のメッセージに入れられない Datadog には Template variables というアラート通知時のメッセージに現在のメトリクスを埋め込める機能がありますが、 ドキュメント を見る限り SSL Tests で埋め込み可能なメトリクスはなさそうでした。理想的にはアラートメッセージに有効期限切れまでの残日数を含めて SSL 証明書の期限切れまで {{ value }} 日です。 のような形にすることでアラートを受けたエンジニアが Datadog へ飛ばずとも残日数を把握できるようなメッセージにしたかったのですが、今回は無理そうなので諦めました。 同一の SSL Test を参照する形で多数のドメインのテストを行えない 今回 20 個の SSL Tests を作成しましたが、そのために 1 つの SSL Test を Clone してドメイン名を書き換えるという作業を 20 回実施しました。設定を頻繁に書き換えることは現時点で想定していませんが、例えばメッセージのテンプレートを変えたくなった場合に、20 個の SSL Tests を更新して回らなければならないということになります。1 つの SSL Test を複数のドメインに対して適用するようなことができれば DRY になり設定変更作業も最低限で済むと思ったのですが、現時点では (Web からの作業では) 一度作った SSL Test を Clone するしか道はなさそうでした。 これについては Terraform Datadog provider を利用し Datadog の設定をコード化することで DRY 化を実現できるとは思いますが、今回は工数に余裕がなく見送りました。(ちょっと脱線しますが、)これに限らず私たちのチームでは Datadog の設定内容のコード化は行っていないのですが、サービスに直接影響はなくともコード化のメリットは享受できるところも多いと今回感じたため、今後コード化してみたいと思っています。 最後に Datadog Synthetic Monitoring API Tests で 20 を超えるドメインの SSL を監視したお話でした。このように、エブリーではスーパーマーケットなどの小売業界へより良いソリューションを提供できるようにプロダクトの改善を進めておりますので、また何か紹介できればと思います。お読みいただきありがとうございました。
アバター
はじめに はじめまして。DELISH KITCHEN開発部の羽馬( @naokihaba )と申します。 私が所属するDELISH KITCHEN開発部では、現在Nuxt3へのアップグレードに向けた取り組みを進めています。 この記事では、私たちが行っているNuxt3へのアップグレードに向けた取り組みについて紹介いたします。 移行背景 私が所属するDELISH KITCHEN開発部では、いくつかのプロダクトでNuxt.jsバージョン2.xを利用しています。 しかしながら、Nuxt.js@2.xのサポートは2024年6月30日に予定されており、Nuxt3にアップグレードする必要が出てきました。 そこで、私たちはNuxt3へのアップグレードに向けて「Nuxt3に移行するべきか、それともNext.jsに移行するべきか」という議論を続けてきました。 Nuxt3への移行を選択した理由 近年、Next.jsは人気を集めており、弊社でもNext.jsを採用するプロダクトがあるため、Next.jsへの移行も選択肢の一つとして考慮しました。 しかしながら、Next.js@13.x で新しく導入された App Router がまだ発展途上であること、そして頻繁に更新されていることを理由に、Next.jsは一定の安定性が求められるシチュエーションにおいてはリスクを伴うかもしれないと考えました。 それに対して、Nuxt3への移行を選択した要因はいくつかあります。 Nuxt3やNuxt Bridgeのマイグレーションガイドが整備されている Nuxt3への移行についての情報が共有されている STORES 株式会社様の登壇資料 など、参考にできる資料が豊富に存在します。 Nuxt3のドキュメントが整備されている これらを総合的に考慮した結果、Next.jsへの移行と比べて、Nuxt3への移行コストが全体的に低いと結論付けました。 移行課題 Nuxt3への移行には、以下のような課題があります。 Nuxt.js v2 から Nuxt3 へのアップグレード Vue.js v2 から Vue3 へのアップグレード @Nuxt/axiosからfetch APIへの移行 VuexからPiniaへの移行とする or Vuex4へのアップグレード Webpack v4からv5へのアップグレード 関係しているパッケージ・ライブラリのアップグレードに伴う修正作業(PostCSS etc...) これらの課題に対して、私たちは次のようなアプローチを取ることにしました。 移行計画 Nuxt3への移行には、主に2つのアプローチが考えられます。 Nuxt Bridgeを利用したアップグレード Nuxt.js v2 から Nuxt3 への直接アップグレード Nuxt2からNuxt3への直接アップグレードも不可能ではありません。 しかしながら、関連するパッケージのアップグレードとそれに伴う修正作業と付随して、移行作業が複雑化することが予想されます。 また、大規模なリリースとなることが予見されるため、Nuxt Bridgeを利用した段階的なアップグレードを選択しました。 移行作業 ここでは、Nuxt3への移行作業の一部をご紹介します。 ただし、移行作業はまだ進行中であり、完了した部分のみをご紹介します。 移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。 NuxtBridgeへの移行 まず、Nuxt Bridgeへの移行作業について説明します。 Nuxt Bridgeへの移行に際して解決すべき課題は以下の通りです。 Nuxt.jsをバージョン2.15.8から2.17.2へアップグレード Vue.jsをバージョン2.6.0から2.7.0へアップグレード Nuxt 2.17.2 へのアップグレード Nuxt 2.17.2・Vue 2.7.0へのアップグレードについては概ねスムーズに進行しました。 しかし、Nuxt 2.17.2 へのアップグレードを行う際に、バージョン間のリリースノートを確認したところ、 Nuxt 2.16.0 から postcss が v8 にアップグレードされた点に注意が必要です。 注視すべきは、 postcss.preset.importFrom と postcss.preset.exportTo が廃止されていることです。 これにより、特定のソースから変数やミックスインをインポートする機能が失われています。 この問題を解決するため、 @csstools/postcss-global-data を利用することにしました。 PostCSS v8へのアップグレードに関する詳細な作業は、PostCSSの公式リポジトリのWikiにまとめられています。こちらを参照してください。 PostCSS Preset Env 8 PostCSS 8 for end users 具体的な設定変更については以下を参照してください。 // nuxt.config.js export default { build : { postcss : { plugins : { '@csstools/postcss-global-data' : { files : [ 'assets/styles/_variables.css' , ] , } , } , } , } } まとめ この記事では、Nuxt3へのアップグレードに向けた取り組みについて紹介しました。 Nuxt3への移行には、いくつかの課題がありますが、Nuxt Bridgeを利用した段階的なアップグレードを進めることで、移行作業を進めています。 移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。 エブリーで一緒に働くエンジニアを募集しています 最後になりますが、エブリーでは、一緒に働くエンジニアを積極的に募集しています。 この記事に興味を持っていただけた方や、エブリーに興味を持っていただけた方は、 エブリーの採用情報 をご覧ください。
アバター
はじめに こんにちは。DELISH KITCHENでデータサイエンティストをやっている山西です。 今回は、挑戦WEEKにて実践した DELISH KITCHENユーザーのクラスター分析事例 についてご紹介いたします。 挑戦WEEKとは 「弊社開発メンバーが通常業務から離れ、技術的に何かに集中して挑戦する」という位置付けの1週間です。 詳細はCTOの今井が下記記事にて説明しておりますので、よろしければ併せてご覧ください。 tech.every.tv 本記事の想定読者 Webサービスの分析従事者(PdM, データサイエンティスト, データアナリスト) クラスター分析の実務例に興味がある人 レシピ提供サービスの分析事例に興味がある人 サービスが手がけるドメイン知識を活用した分析事例に興味がある人 説明しないこと クラスタリングアルゴリズムの統計/機械学習的性質の詳細 扱ったデータの具体値に触れるような説明 やったこと 「ユーザーが普段見ているレシピの栄養情報を用いたクラスター分析」を行いました。 DELISH KITCHEN各レシピが持つ栄養情報 出来たもの 普段DELISH KITCHENにアクセスするユーザーを、3つの栄養クラスタ(レシピ嗜好別に分けたグループ)に分類しました。 各栄養クラスタの特徴をまとめたものが下表になります。 栄養クラスタ名 特徴 仮説 主食も取り入れ層 ・他クラスタに比べて、見ているレシピのカロリーが高い傾向 ・量としてのボリュームが多いレシピを良く見ている? ・DELISH KITCHENで主食主菜系を積極的に作っている層? 材料メイン層 ・脂質比率が相対的に高い ・糖質の相対的割合が低いカロリーが特筆して高いわけではない ・ご飯や麺物はあまり探してないが、付け合わせの主菜とかはよく探している? ・材料ベースで検索する傾向? ・糖質を控えている人も混ざっているかも スイーツトレンド層 ・他クラスターに比べて、見ているレシピひとつひとつのカロリー低め ・糖質高め, たんぱく質低め ・お菓子など, 全体量は少ないものの栄養構成としては糖質高めなものが好き? ここから、この分析をするに至った動機、および具体の分析の流れを順に紹介していきます。 動機 「DELISH KITCHENという料理動画メディアならでは活かせるデータの価値にあまり向き合えていないな」という課題感が、今回の”挑戦”につながりました。 普段は、機能利用率や継続率等のアプリ内行動指標、プレミアム課金機能の事業成果を測るKPI等、主にWebサービスとしての文脈で分析を行うことが多いです。 その一方で「料理」というコンテンツの肝となる体験に焦点を当て、より解像度高くユーザーニーズに迫るような分析もしてみたいという思いもありました。 そこで今回は「ユーザーが視聴しているレシピの栄養素情報」に着目し、これを探索的に分析して新たな示唆が得られないか実験してみました。 その結果行き着いたのがクラスター分析です。 クラスター分析とは ここでいうクラスター分析は、データをクラスタリングすることにより、データ内の隠れたパターンや構造に関する示唆を得ることを目的とした分析です。 クラスタリングとは、データを似た特徴を持つグループにまとめる作業です。こうしてまとめられたグループをクラスタと称します。 今回は計6種類の栄養項目(カロリー、たんぱく質、脂質..等)を用いて、ユーザーを「よく似た栄養構成のレシピを視聴する集団」にクラスタリングします。 こうして出来たクラスタを分析により観察することで、新しい知見を得られないか試してみます。 機械学習手法による自動クラスタリングの恩恵 しかし、判断軸が多い&正解が定められていないこの作業を仮に人力で行おうとすると多大な労力が必要となります。 そこで、 与えられたデータの中から機械的に法則を見出す(教師無し機械学習をする)ようなクラスタリングアルゴリズム を用いれば、この作業を自動化することが出来ます。 世には様々なアルゴリズムがありますが、今回はその中でも特に広く使用されている K-means法 を採用しています。 参考: ITエンジニアのための機械学習理論入門 第6章 k 平均法:教師なし学習のモデルの基礎 | クラウドエース株式会社 分析の流れ 今回の分析の流れの全体像を、図と共に解説します。 概観としては 「ユーザーが直近で見ているレシピの平均的な栄養素情報」を集計 このデータを使って、K-Meansアルゴリズムを用いたクラスタリング(ユーザーのグループ分け)を実施 出来たクラスタを観察し、ユーザーのレシピ嗜好に関する示唆を得られないか分析 という流れになります。 クラスター分析におけるデータの流れ こうして作成したクラスタを以後、 栄養クラスタ と呼んでいくことにします。 クラスタの観察 ここからが クラスター分析 の集大成です。 出来上がった3つのクラスタを定量、定性の観点を組み合わせつつ多角的に観察してみます。 どのように分類されたか まずは3つの栄養クラスタがどのように分類されたかを、「各クラスタのユーザーが視聴するレシピの平均栄養値」で可視化します。 ※ この時点ではまだクラスタに名前が無いため、0,1,2の番号で識別します。 上: 視聴レシピの平均カロリー(Kcal) 下:視聴レシピの平均たんぱく質対エネルギー比率(青)/平均脂質対エネルギー比率(橙)/平均糖質対エネルギー比率(緑) 図の注釈にあるように、 クラスタ0はカロリーが高め クラスタ1は脂質が高め クラスタ2は糖質が高め などの傾向が相対比較で見て取れます。 「どんなレシピに興味を持っていそうか」目視で把握 次に、各栄養クラスターが視聴しているレシピ名をもとにワードクラウドを作成してみます。 栄養クラスタ別のワードクラウド これも図内の注釈に書いての通りですが、どんなレシピやキーワードに興味がもたれやすいかがなんとなく見えてきました。 観察結果をもとに、栄養クラスタを命名する これらの観察結果を統合し、それぞれ以下の仮説をもとに名前を付けました。 クラスタ0: 平均カロリーが高め & 主食系のレシピも視聴されやすい → 主食も取り入れ層 クラスタ1: 脂質高め(≒糖質は相対的に低め)& 材料系の頻出語が多い → 材料メイン層 クラスタ2: 糖質高め & お菓子系の頻出語が多い → スイーツトレンド層 こうして、本記事冒頭 出来たもの のセクションにて紹介した栄養クラスタの概観を把握することができました。 (この分類がどこまで妥当かの判断は後続分析に委ねるものの)やってみた側としては「それっぽいな」という所感を得ました。 活用事例 前述の通り、これらの 栄養クラスタ は「ユーザーのレシピへの嗜好」をある程度反映出来ているようにも見えます。 その可能性を確かめるべく、「DELISH KITCHENアプリ内でのレシピ検索用推薦キーワードの出し分け」のロジックに栄養クラスタを利用する実験を行ってみました。 そして、これがユーザーの検索行動にどのような変化が生じたか、A/Bテストによる効果検証を行いました。 参考: tech.every.tv 対象面 DELISH KITCHENアプリ 検索サジェストの「注目のキーワード」 アプリで検索をする際に提示される10件のキーワード ※ 直近のDELISH KITCHENユーザー全体の検索行動を踏まえ、その流行が反映されるような集計ロジックで選定している 新規ロジックの実装とA/Bテスト 「注目のキーワード」の集計ロジック自体は現行と同じものを用いる 集計対象の集団を、ユーザー全体(control群) vs 栄養クラスターに絞り込んだもの(test群) という問題設定に落とし込み、A/Bテストを実施しました。 栄養クラスタ別に注目のキーワードを出し分けるA/Bテストの実施 そして、結果の良し悪しを観察するゴール指標として 注目のキーワードタップ率 ※を設定しました。 ※ A/Bテスト期間中に一度でも注目のキーワード欄をタップするユーザーの割合 結果 露出されたキーワード controlおよび各栄養クラスタ(test群)の露出キーワード例 クラスタリングの結果解釈時、ワードクラウドで見たような傾向が反映されています。 キーワードタップ率の変化 主食も取り入れ層(test1) , スイーツトレンド層(test3) で、キーワードタップ率が有意に変化しました。 一方、 材料メイン層(test2) では、タップ率の変化はほとんど認められませんでした。 A/Bテストやってみての所感 箇条書きですがまとめます。 栄養クラスタを用いることで、 露出される注目のキーワードの雰囲気 がガラリと変わりました。 今まで埋もれていた、 ユーザー嗜好別の注目 に今までよりも肉薄できた可能性があります。 3つのうち2つのクラスタでタップ率が大幅に向上したため、従来よりも ユーザーが求めるキーワードを先回りして提示できた 可能性があります。 ついでに、 こういう変化に反応しやすいクラスタとそうでないクラスタ が浮き彫りになりました。ここも深掘り分析しても面白そうな所感です。 まとめ この記事では、ユーザーのレシピに対する栄養嗜好を活用したクラスタリングの事例と、それをアプリのロジックに組み込むことでユーザーの行動変化を促した事例を紹介しました。 シンプルな栄養項目を用いただけでこのような成果を得られたことから、DELISH KITCHENが保有するデータの潜在的な可能性を改めて感じました。 今回開発した「栄養クラスタ」は、アプリのロジックだけでなく、ユーザーセグメント分析など様々な分野での応用が考えられます。 今後もレシピメディアとしてのドメイン知識にも目を向けつつ、データの眠る価値を引き出すための取り組みを継続していく予定です。
アバター
はじめに こんにちは トモニテ でバックエンド周りの開発を行っている rymiyamoto です。 今回はトモニテの新規事業として、2023 年 11 月 30 日にローンチした家族・家庭や恋愛に対する悩みをプロのカウンセラーと相談出来る新サービス トモニテ相談室 の開発に関わる中で API サーバーの開発環境での工夫について紹介していきます。 トモニテ相談室の通話周りの基盤の話は別の記事で紹介されていますので、こちらも合わせてご覧いただけると幸いです。 tech.every.tv 技術選定 新規でサービスを立ち上げるに当たっての技術選定は 2 点を意識して選びました できるだけ既存の技術をを使う 面倒なところは楽できるようにする できるだけ既存の技術をを使う こちらに関しては基本的にはトモニテの技術スタックを踏襲することにしました。 使用言語: Go フレームワーク: echo インフラ: AWS ECS どちらも現在のトモニテだけに関わらず社内での技術スタックとして定着しているものを選びました。 面倒なところは楽できるようにする トモニテ本体だとリリースしてから大きく内部のツールの変更はしていないままで、以下のようなアジリティが悪いところがありました。 テーブル定義後のモデル定義が手動で、 dbr だと eager loading が面倒 エンドポイントのドキュメント定義がソースからの定義で乖離しやすく扱いづらい( echo-swagger ) そのため、今回はトモニテ本体にも後々展開もしやすくある程度ある活発なパッケージを選んでいます。 ORM として スキーマファーストな sqlboiler oapi-codegen でドキュメント駆動開発 開発(ローカル)環境 ローカル環境は docker に固めて開発を行っており、go のイメージに対して、以下のようなツールを入れています。 ツール 用途 air ホットリロード sql-migrate マイグレーション sqlboiler ORM oapi-codegen ドキュメント駆動開発 mockgen モック生成 gofumpt フォーマット golangci-lint 静的解析 ARG GO_VERSION=1.21.4 FROM golang:${GO_VERSION} AS dev RUN go install github.com/cosmtrek/air@latest RUN go install github.com/rubenv/sql-migrate/...@latest RUN go install github.com/volatiletech/sqlboiler/v4@v4.15.0 RUN go install github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-mysql@v4.15.0 RUN go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.13.4 RUN go install go.uber.org/mock/mockgen@v0.2.0 RUN go install mvdan.cc/gofumpt@latest RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 COPY .air.toml / CMD [ "air" , "-c" , ".air.toml" ] モデル生成(sqlboiler)の活用 sqlboiler はスキーマファーストなので、一般的な ORM とは異なりソースからモデルを生成するのではなく、テーブルからモデルを生成を行うことができます。 そのため前もって DB にマイグレーションをかけておくことでモデルを生成することができます。 (今回は トモニテでも運用している sql-migrate でマイグレーションを管理することにしました) sqlboiler を使用するにあたって DB 情報や出力先を config.toml に記載しておきます # 生成コマンド実行時に既存のディレクトリを削除するかどうか wipe = true output = "internal/models" no-tests = true templates = [ "/go/pkg/mod/github.com/volatiletech/sqlboiler/v4@v4.15.0/templates/main" ] [mysql] dbname = "example" host = "db" port = 3306 user = "root" pass = "password" sslmode = "false" blacklist = ["gorp_migrations"] ここまで準備できてコマンドを実行するとモデルが生成されます。 (そのままコマンドを実行すると毎回 docker compose exec app XXX と打たないといけないので make コマンドを用意しています。) # model生成 .PHONY: model model: model docker compose exec app sqlboiler mysql --config ./asset/sqlboiler/config.toml --add-soft-deletes $ make model こうすることで、 internal/models 配下に DB の制約に応じたリレーションを考慮したモデルが生成されて利用ができるようになります。 ORM としての使い方は 公式ドキュメント を参照してください。 ドキュメント駆動開発(oapi-codegen)の活用 oapi-codegen は OpenAPI からコードを生成するツールです。 OpenAPI の定義を api/app.yaml に記載しておきます。 (記述は一般的な OpenAPI の構文が使えます) openapi : 3.1.0 info : title : Example API version : 1.0.0 servers : - url : http://localhost:1323/1.0/app paths : /notifications : get : tags : - notifications summary : お知らせ一覧を取得 operationId : getNotifications responses : "200" : description : OK content : application/json : schema : type : array items : $ref : "#/components/schemas/Notification" components : schemas : Notification : type : object required : - id - title - body properties : id : type : integer title : type : string body : type : string ここまで準備できてコマンドを実行するとコード (エンドポイントの定義) が生成されます。 # openapiからのコード生成 .PHONY: openapi openapi: openapi docker compose exec app oapi-codegen -generate server -package api -o ./internal/app/api/openapi_api.gen.go api/app.yml docker compose exec app oapi-codegen -generate types -package api -o ./internal/app/api/openapi_types.gen.go api/app.yml $ make openapi こうすることで、 internal/app/api 配下に OpenAPI に従ったエンドポイントの定義が生成されます。 その後 RegisterHandlersWithBaseURL (自動作成される関数)を main.go で呼び出してエンドポイントの登録を行うことで、エンドポイントの定義が完了するので、実際の処理を handler に書くだけで実装が完了します。 エンドポイントへの登録 internal/app/server.go package app import ( "github.com/rymiyamoto/server/internal/app/api" "github.com/rymiyamoto/server/internal/app/handler" "github.com/labstack/echo/v4" ) type server struct { *handler.Handler } func newServer() *server { return &server{ handler.NewHandler(), // 内部での依存解決は handler に任せる } } func RegisterHandlers(e *echo.Echo, basePath string ) { server := newServer() api.RegisterHandlersWithBaseURL(e, server, basePath) } main.go package main import ( "github.com/rymiyamoto/server/internal/app" "github.com/labstack/echo/v4" ) func main() { e := echo.New() // ... app.RegisterHandlers(e, "/1.0/app" ) e.Start( ":1323" ) } まとめ 基本的な大枠(言語やフレームワーク)はトモニテの技術スタックを踏襲しつつ、面倒なところは楽できるようにすることで開発効率を上げることができました。 導入当初はいくらツールの歴史があるといえどキャッチアップに時間がかかることもありますが、今後の開発効率向上には必要な投資だと考えています。 また、今回はローカル環境での開発を docker に固めて行っていますが、これにより開発環境の差異をなくすことができ、開発者の環境構築にかかる時間を削減することができました。 今後もトモニテ相談室の開発においては、技術選定や開発環境の構築についても工夫を重ねていきたいと考えています。
アバター
ドウデュース、武豊さん、さすがの競馬でしたね。 CTOの @imakei_ です。株式会社エブリーの2023年アドベントカレンダーの締めとなる25日目のブログです。 他のブログも力作が揃っておりますでの、まだの方はぜひ こちら をご覧ください! 本ブログでは、2023年の振り返りと、2024年に向けた思いを書きたいと思います。 2023年は、開発本部としても「挑戦WEEK」を始めたり、 7月から会社としても「挑戦」をテーマに掲げるなど、 「挑戦」をたくさん行った1年となりました。 2023年のハイライト 開発本部としての挑戦をいくつか振り返ります。 コンパウンドなプロダクトへの挑戦 弊社はDELISH KITCHEN、トモニテ、TEMELINEと3つのカンパニーに分かれ、 ドメインの異なる複数のプロダクトを運営しています。 これだけでもコンパウンドといえばコンパウンドなのですが、 さらに各ドメインでの価値提供を最大化すべく、 それぞれのドメインごとにコンパウンドなプロダクトを展開することに挑戦しています。 DELISH KITCHENでは数年前から展開している小売り向けのソリューションをRetailHubというサブブランドとし、 ネットスーパー事業の事業継承を受けるなど、事業を拡大、 より包括的な小売様向け・小売ユーザー向けのソリューションを提供し始めました。 また、これから更なるサービス展開を見越して基盤周りの刷新を行うなど、 コンパウンドなプロダクトで勝つべく、着実に下準備を進めています。 トモニテでは、より時代に即したサービスに向けてMAMADAYSから名称を変更したほか、 子育てをはじめとする家族・家庭の悩みを相談できるトモニテ相談室という事業を新たにリリース。 TIMELINEでは、昨年始まったクラウドファンディングのアップデートに加え、 クラファンに連携するメディアの立ち上げを行なっています。 それぞれのカンパニーで、既存のサービスをより伸ばしながらも、 複数の新たなサービスが立ち上がるというかなりチャレンジングな1年となりました。 開発組織の活性化 昨年(2022年)の個人的な反省から、今年は組織の活性化に向けていくつかの施策が動いた1年でもありました。 昨年の反省というのは、自分がCTOになってから、 「エンジニアリングは手段であり、事業を通じて価値提供することが大切である」 と至る所で口酸っぱく話してきたのもあり、 目の前の事業課題の解決に向けた動きがとても良くなったと感じる昨年になった一方で、 同時に技術面の遊びのようなものが減り、窮屈に感じることも多くなってしまっていたことです。 個人的に、非連続な事業成長を考えた時には技術での変革が不可欠だと考えており、 この状態は中長期的には致命的なのではないかと、「組織の活性化」は密かに今年の自分のテーマとしていた部分でもあります。 挑戦WEEKの開始 今年の3月から導入した「挑戦WEEK」ですが、この1週間は、事業部の通常業務から離れ、技術的な挑戦に集中する時間です​​​​​​​​。 昨年の11月にDELISHKITCHEN開発本部にて行われた技術課題の棚卸し会を発端に、技術に向き合うことが必要だと感じ、導入を進めました。 弊社の開発本部には組織活性化委員会という、より良い開発組織していくために動く組織があり、 上記を相談したところぜひやりましょう!ということで年明けからすぐに動いてくれました。 ビジネス側ともスケジュールを調整が必要であったにもかかわらず、すごいスピード感で進めてくれました。 そんな開催自体も「挑戦」だった「挑戦WEEK」ですが見事にやり遂げ、参加者の満足度も高く、 まだ3回の実施ですが早くも弊社の良い文化になろうとしています。 詳しくは 組織活性化委員長の國吉のブログ や 自分のブログ でも言及しておりますので、ぜひ一読ください。 勉強会の開催とTechBlogの再開 この半期ではありますが、カンパニー間の技術交流および開発本部全体のスキルアップを目的とし、 勉強会の開催とTechblogの更新をOKRに入れ、取り組むことを意思決定しました。 勉強会はこれまで有志で開催されてきましたが、それを開発本部全体に広げた形です。 こちらは毎月1人1回は発表をしようということで進めました。 また、今までブログには何度かチャレンジしてきましたが、月数回の更新で中途半端な形となってしまっていたのですが、 半年で1人2本以上の更新をしよう(毎週2本以上リリース!)ということを打ち出し、組織一体となって取り組みました。 正直メンバーにはかなり負荷をかけた部分ではありましたが、みんな協力的で、 勉強会は48回開催(毎週2回以上!!?)、ブログは60本と成果を上げることができました。 目標を掲げておいて、正直かなり厳しいと思っておりましたが、 見事にそれ以上の動きをしてくれたメンバーは本当に誇りです。 2024年に向けて 色々な「挑戦」をしてきた2023年ですが、その反面で出来なかったことも浮き彫りになった1年でした。 特に「今はどちらかしか出来ない」と選択を迫られ、片方を諦めないといけない場面も増えた一年だったと思います。 「もっと仲間がいれば。。。」と思った回数は数え切れないです。 そこで来年、2024年は、 『採用』 をテーマに動きたいと思ってます。 コンパウンドなプロダクト開発への挑戦 社内外に誇れる開発組織への挑戦 はまだまだ道半ばです。 これから一緒に最高な組織を作っていってくれる仲間を募集しています! ちょっとでも面白そうじゃんって思ってくれた方は、 X(旧Twitter)とかでも気軽に声かけてください! 美味しいご飯でも食べながら開発組織を語りましょう! https://twitter.com/imakei_
アバター
タイトル この記事は every Tech Blog Advent Calendar 2023 の24日目です。 DELISH KITCHEN 開発部で小売向き合いでFlutterのアプリ開発をしている野口です。 本記事では、弊社の開発しているFlutterアプリでユーザーがどの画面を表示したかのログを取るために、AndroidにおけるonResumeのタイミングでログを送る必要が出たのでその際に得られた知見について紹介します。 やりたいこと 要件としては、以下のタイミングでアプリが表示している画面情報を送る必要があります。 画面遷移時(Navigatorのpush、pop、replaceでの画面遷移) アプリの復帰時 これを行うにはAndroidで言うonResumeのような挙動が必要でこの挙動をFlutterで再現するのがやりたいことです。 AndroidのonResumeはどのタイミングで発火するものなのか 公式だとライフサイクルは以下の画像のようになっています。 Activityとは表示されている画面で、画面が表示されると画像のようなライフサイクルで動きます。 アクティビティのライフサイクルに関する簡略な図 https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ja onResumeのタイミングに絞ると以下の図のようになります。 AndroidのonResumeの挙動 onResumeの挙動をまとめると以下の2点になります。 画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する バックグラウンドからフォアグラウンドに復帰した際に発火する Flutterだとどうなるか 前提としてFlutterでは画面はwidget(部品のようなもの)で構成されていて、widgetを切り替えることによって画面遷移しています。なのでそもそもAndroidのActivityは画面のような概念がないため、ルーティングを設定をすることで、切り替えています。 1.画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する Flutterで再現するために NavigatorObserver を使用しました。 MaterialApp()のroutesにルーティングを指定します。 後述しますが、navigatorObserversにMyNavigatorObserver(NavigatorObserverを継承したクラス)を指定することで、ルーティングが検知できるようになります。ちなみにルーティングの指定をしないと、 didPush や didPop などで受け取れる route でルーティングが取得できなくなリます。 Widget build ( BuildContext context) { ... return MaterialApp ( title : 'sample' , home : Page1 (), routes : { '/page1' : (_) => Page1 (), '/page2' : (_) => Page2 (), '/page3' : (_) => Page3 (), }, navigatorObservers : [ MyNavigatorObserver (), ], ); } NavigatorObserverを継承したクラスでは、以下のタイミングで画面遷移を検知できるようになります。 didPushは Navigator.of(context).push() され、画面遷移した時に発火します。 didPopは Navigator.of(context).pop() され、画面遷移先から戻る時に発火します。 didReplaceは Navigator.of(context).pushReplacement() され、画面遷移した時に発火します。 class MyNavigatorObserver extends NavigatorObserver { @override void didPush ( Route route, Route previousRoute) { super . didPush (route, previousRoute); } @override void didPop ( Route route, Route previousRoute) { super . didPop (route, previousRoute); } @override void didReplace ({ Route < dynamic >? newRoute, Route < dynamic >? oldRoute}) { super . didReplace (newRoute : newRoute, oldRoute : oldRoute); } } NavigatorObserverのやり方はこちらが参考になりました! 参考: https://qiita.com/kurun_pan/items/b725e02051ab90759df4 ちなみに、routesを設定しなくても以下のようにpush時にルーティングを設定することもできます。 Navigator . of (context). push ( MaterialPageRoute ( settings : RouteSettings (name : '/page1' ), builder : (context) { return const Page1 (); }, ), ); 2. バックグラウンドからフォアグラウンドに復帰した際に発火する Flutterでバックグラウンドからフォアグラウンドの検知は didChangeAppLifecycleState でできます。 didChangeAppLifecycleState を使用するためにはStatefulWidgetでWidgetsBindingObserverを継承する必要があります。(厳密にはMixinsです、わかりやすいのでこちらの記事をご参照ください。) https://qiita.com/trm11tkr/items/b0c1c50b81d5c40d8bbf didChangeAppLifecycleStateの AppLifecycleState.resumed を使用することによってバックグラウンドからフォアグラウンドに復帰したことを検知することができます。 class App extends StatefulWidget { const App ({ super .key}); @override State < App > createState () => _AppState (); } class _AppState extends State < App > with WidgetsBindingObserver { @override void didChangeAppLifecycleState ( AppLifecycleState state) { if (state == AppLifecycleState .paused) { // アプリがバックグラウンドに移行した時 } else if (state == AppLifecycleState .resumed) { // アプリがフォアグラウンドに戻った時 } else if (state == AppLifecycleState .inactive) {   // アプリが一時的に非アクティブになる時 } else if (state == AppLifecycleState .detached) { // アプリが終了する時 } } } didChangeAppLifecycleStateについてはこちらが参考になりました! 参考: https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/v2-app-lifecycle また、onResumeのタイミングで画面情報のログを送りたいですが、 didChangeAppLifecycleState からは今どの画面にいるかわからないため、ルーティングのパスの情報を状態で保持する必要があります。 これは、グローバルで状態を保持できれば方法はなんでもいいと思いますが、以下のようにRiverpodで保持することにしました。 final routeNameProvider = StateProvider < String ?> ((_) => null ); 実装まとめ 上記の内容を実装としてまとめると以下のようになりました。 sendViewLog で表示している画面名をログに送るようにしているのですが、それを AppLifecycleState.resumed と NavigatorObserver で発火するようにすることで画面遷移した時とバックグラウンドからフォアグラウンドに復帰した時にログが送れるようにしています。そのタイミングで ref.read(routeNameProvider.notifier).state = routeName; で遷移した画面名を状態に保持しています。 また、 AppLifecycleState.resumed では表示している画面名が受け取れないので final routeName = ref.read(routeNameProvider.notifier); で保持していた画面名の状態を取得してログで送るようにしています。 import 'package:flutter/material.dart' ; import 'package:flutter_riverpod/flutter_riverpod.dart' ; final routeNameProvider = StateProvider < String ?> ((_) => null ); class App extends ConsumerStatefulWidget { const App ({ super .key}); @override ConsumerState < App > createState () => _AppState (); } class _AppState extends ConsumerState < App > with WidgetsBindingObserver { Future < void > sendViewLog ( String routeName) { // ログを送る処理を行う ref. read (routeNameProvider.notifier).state = routeName; // 受け取ったルート名を保持する }   @override   void didChangeAppLifecycleState ( AppLifecycleState state) { if (state == AppLifecycleState .resumed) {    // アプリがフォアグラウンドに戻った時 final routeName = ref. read (routeNameProvider.notifier); sendViewLog (routeName); }  }   return MaterialApp ( title : 'sample' , home : Page1 (), routes : { '/page1' : (_) => Page1 (), '/page2' : (_) => Page2 (), '/page3' : (_) => Page3 (), }, navigatorObservers : [ MyNavigatorObserver ((routeName) => sendViewLog (routeName)) ], ); } class MyNavigatorObserver extends NavigatorObserver { final Future < void > Function ( String ? ) sendViewLog; MyNavigatorObserver ( this .sendViewLog); @override void didPush ( Route route, Route previousRoute) { super . didPush (route, previousRoute); final routeName = route.settings.name; sendViewLog (routeName); } @override void didPop ( Route route, Route previousRoute) { super . didPop (route, previousRoute); final routeName = route.settings.name; sendViewLog (routeName); } @override void didReplace ({ Route < dynamic >? newRoute, Route < dynamic >? oldRoute}) { super . didReplace (newRoute : newRoute, oldRoute : oldRoute); final routeName = newRoute ? .settings.name; sendViewLog (routeName); } } 感想 AndroidではonResumeだけで行える処理がFlutterだとかなり手間がかかる処理になってしまいました。 ただ、今回の実装で画面遷移を検知した時、その画面の情報(パスや画面名など)を取得するためにルーティングの設定が必要ということがわかったのは大きな収穫でした。 Flutterはルーティングを気にしなくても作ろうと思えば作れてしまうので、ルーティングの重要性があまり分かっていませんでした。遷移先が一元管理できるからいいよね!くらいに思っていましたが、ルーティングがないとそもそも表示している画面がわからないのでルーティングは必須だということが理解できました。 go_routerとかだと 今回、ルーティングをNavigatorで行なっているため、このような実装になってますが、 go_router とか使えばもう少し楽に実装できるのかなと思いました。 例えば、go_routerはredirectというものがあり、画面遷移すると発火し、遷移先のパスが取得できるのでこれを使用した方が簡単になるかなと。 GoRouter ( redirect : ( BuildContext context, GoRouterState state) { sendViewLog (state.location); } ); https://zenn.dev/joo_hashi/books/fa5c73ffcbf71a/viewer/aae5cf とはいえフォアグラウンドの復帰時の処理は必要なのであまり変わらないですかね。 ちなみに本アプリでもgo_router使いたかったですが、開発当初に以下の問題でボトムナビゲーションバーを表示しながら画面遷移できなかったため使用を断念しました(涙) https://zenn.dev/flutteruniv_dev/articles/20230427-095829-flutter-auto-route ただ、現在は解決しているらしいのでgo_routerに書き換えるのもありかなーと思っています。 https://zenn.dev/flutteruniv_dev/articles/stateful_shell_route 終わりに FlutterでのonResumeの再現方法について紹介しました! 明日はAdvent Calendarの最終日です。CTOからのありがたいお話があると思うのでぜひご覧ください!
アバター
はじめに every Tech Blog Advent Calendar 2023 24日目の記事になります。 DELISH KITCHENでデータサイエンティストをしている山西です。 普段はDELISH KITCHENの企画/改善に向けた分析をPdMと連携しながら行っています。 今回はその経験談をもとに、分析用ログ策定の流れを改善&仕組み化した事例をノウハウとして紹介したいと思います。 経緯: ログがカオス DELISH KITCHENサービスの改善に向けた分析では、ユーザーのイベントログ(ユーザーがいつ、どこで、何を見たかの情報が含まれるもの)をデータとして利用しています。 これをSQL等の手段で集計し、分析を通して意思決定に向けた示唆が得られるような利活用をしています。 ところが、ログをいざ集計しようとなると困難に直面することが多々ありました。 これは、歴史的経緯から行き当たりばったりで設計されたものであったり、(データ利活用文化が発展途上だった頃)必ずしも分析観点で整理、設計がなされていなかったりしたことに起因します。 理想とする状態とのギャップをAs-Is/To-Beとして以下に整理しています。 As-Is To-Be 必要なログが存在しない 分析したい観点に必要なログが存在している ログはあっても集計したいカラム、項目が足りない 分析要件に応じて、必要な情報が発火されるようになっている 使われていない&用途も不明なログが散見される 必要の無いログは減らし、開発工数やコスト面での最適化を図る 企画書&設計書を読んでも、どのイベントとログが対応しているかよくわからない 関係者が齟齬なく解釈できるようなドキュメントとして整理されている ログはあるものの、集計難易度が高い 楽に集計できる(SQL習熟度が必ずしも高くないPdM、ビジネス層でも書けるくらいの難度感) ログ設計フローの見直し このギャップを踏まえ、目指すべき状態(To-Be)に向けて分析ログ設計フローを整理し、PdMとともにこれに則って進行していくようにしました。 以下がその全体図です。 全体の流れ 仮説を立てたうえで分析方針を決定し、その後に手段(ログ)を選定するという流れを意識しています。 ※ログの活用用途は他にも 探索的分析(EDA) 、 機械学習の特徴量 等々がありますが、本記事は仮説検証型の事例ベースで紹介します(思想自体はその他用途にも応用できると思います)。 ここから、各段階でやることを順に整理していきます。 1. 分析観点を整理する ログの具体に入る前に、まずは「何を分析したいか」を言語化し整理します。 施策(機能やデザインの改善/機能追加)によって、何が期待されるかを仮説として言語化する その仮説を踏まえ、どういう分析をしたいか方針を決める(指標の定点観察なのか、SQLや可視化を通じて深掘りするのか) KPI, 指標として何を観察すべきかを明文化する 分析観点の例 種別 例 1. 事業への影響を測るKPI 課金CVR, 広告影響指標 2. 汎用的に使用されるKPI 画面imp率, クリック率 3. 機能エンゲージメントを測る指標 あるコンテンツに接触した後の機能継続率 4. ファネル分析 特定の画面遷移の離脱率 1はビジネスへの貢献を示すために優先的に設定されがちです。 また、2は「とりあえず分母分子を置いて観察する」ように定義がしやすく”わかりやすい”ため、企画時に見逃されることは少ないです。 一方、3や4は事前の仮説立て、導線や機能の観察を怠るとおざなりになりがちです。 結果、「分析したいけどログがない」に陥ることが多々ありました。 上記のフォーマットは、この反省を踏まえて整理したものになります。 こうして、まず「何を分析したいか」が明確になりました。 2. 分析する面のイベント, ユースケースを洗い出す ここでは分析対象の面(ページや機能など)でどんなイベントが発生するか、そこでどんなユーザー行動が発生し得るかを整理します。 具体例とともに見ていきます。 まず、「画面を見た」「ボタンがクリックされた」等のユーザイベントを自然言語で表現し、一覧化します。 イベント一覧の抜粋 次に、ユーザー行動を想定し、その際発生し得るイベントを時系列整理したものを作ります。 ユースケース整理の例 このように、遷移の順番の多様性、およびユーザーのステータスの差異(課金有無など)がある場合、例え同じページを閲覧&機能を利用していたとしても、そこから発生していくイベントの順番や有無は異なります。 そのため、代表的なペルソナのユースケース別に、想定し得るイベントの発生順を整理しておきます。 これが、次工程での整理に役立ちます。 ※ 全ての組み合わせを網羅する必要は無いです。 なお、ここで採用している手法は イベントストーミング手法 に着想を得ています(以下記事に詳しいです)。 www.eventstorming.com blog.kinto-technologies.com 3. ログを仕込むべきイベントを見定める ユーザー行動をイベントの粒度で整理出来たら、「どのイベントにログを対応付けるか」を見定めていきます。 その判断軸となるのが1.で整理した分析観点です。観測ポイントをどこに置けば欲しい指標、やりたい分析の集計ができるかを想像しつつ行います。 以下が完成系のイメージです。 イベントとログの対応関係の整理 分析対象によってその検討内容は千差万別ですが、例えば 分子/分母にどのイベントを置けば、指標が表現できるか イベントが起きた、という事実以外に取得すべきものは何か(例: どんな料理レシピを見たかを知りたい場合は、その情報も必要) 離脱状況をファネル分析したい場合、どのイベントをチェックポイントとすべきか 等々に考えを巡らせます。 この際、先の工程で実施したイベント&ユースケースの整理が役立ちます。 仮に全てのイベントに機械的にログを仕込もうとすると大変ですが、こうして目的に応じて整理をすることで、設計&実装をより本質的なタスクに集中することが出来ます。 4. 設計叩き台を作る ログとして仕込むべきイベントが見定まったら、PdMとデータサイエンティスト間で要件をすり合わせしつつ、設計書の叩き台を作ります。 そのログがどのイベントに対応するものか どんな項目(カラム)が欲しいか どういう形式で情報を持つと、後の集計が楽になるか の視点をもとに、具体として埋めていきます。 ログ設計書の雰囲気 なお、この際、ログ実装としての優先度を決めておくと後に役に立ちます(どうしても分析に必要なものは「高」、後々役立ちそうなものは「中」など) 。 ここまでで、 「何」を分析したいか そのために「どのイベント」が集計対象となるか ログとして「どんな項目」を集計すべきか 分析要件として整理できました。 5. 開発側と議論しながら設計を完成させる ここまで準備できたら、PdM & データサイエンティスト & 開発エンジニアでログの設計を具体として話し合う場を設けます。 ログを実装してもらうことになるサーバーサイドおよびクライアントサイドのエンジニアに設計叩き台を共有し、 そもそも実装として実現可能か(技術的に可能か) 工数感はどんなものか(現実的に可能か) 等々の観点でレビューしてもらい、このまま実装に進むか、はたまた軌道修正すべきかを擦り合わせていきます。 「何がやりたいか」「そのために何が必要か」が優先度と共に事前整理&説明されていることで、納得感が生まれたり、技術的には難しい場合には代替手段を検討しやすかったりと、建設的な議論が可能になります。 ここを通過したら、あとは開発側の実装にバトンタッチです。 実装後、開発環境で意図通りにログが発火されるかをテストします。 それを見届けた後は、サービスがリリースされてそのログを使って分析を待つのみです。 やってみた所感 ログの抜け漏れが減った 何を分析したいか、そのためにはどういう項目がログとして実現されるべきか、という流れで整理するようにしたことで「必要なものがないから分析できない」「必要が無いログを過剰に仕込む」問題は自ずと解決されていきました。 分析計画の視野が広がった 企画の初期段階で「ログ」という具体に走らず「何を分析したいか」と抽象度を上げておくことが振り返りや意思決定の質に寄与すると実感いたしました。 最初からログの具体を設計しようとすると、自ずと視野が狭くなりがちです。 「難しさ」の事前対策がしやすくなった 当初想定したログ設計や集計が難しいことが見えてきた場合も、事前に対策しやすくなりました。 例えば、 出したいイベントログのloggingが技術的に難しい場合は、近い導線のイベントログで代替し、近似値として集計する イベントログだけだとSQLの集計が複雑する場合は、データチーム側で中間テーブルを用意しておき、PdMにはそれでクエリを書いてもらう 等の判断を、分析観点やユースケースに立ち戻りながら円滑に行えます。 挙動がイメージしやすくなった イベントとユースケースを一通り整理しておくことによって、どのタイミングでどのログが発火されるかを想像しやすくなりました。 これまで、後から企画書や設計書を読んでもログがどこの導線&イベントに対応しているかがわからなかったり、開発端末やMockで画面遷移を見ながら目視で動きを確認したりする場面が多々ありました。 それらが込み入ると、だんだん何を見ていっているのかがわからなくなってきたり、関係者間で知らないうちに齟齬が起きたりに様々な辛みにつながります。イベントとユースケースの整理は、その対処に一役買ったといえます。 機能追加に向けた現状整理がしやすくなった 既存イベント&想定ユースケースがログと紐づくように整理され、挙動がイメージしやすくなったことで、機能の改修や追加がある際に現状との差分を確認しやすく、影響範囲を見定めやすくなりました。 終わりに 「ただ機能に対応するログを仕込む」ではなく、「分析者の目線に立ってログを設計する」という意識変革とその実践事例を紹介しました。 一連の取り組みを通じて、ログ設計の最適化はもちろん、継続的に分析&改善サイクルの質も向上することが出来ました。 本記事がデータ分析の取り組みにおける一助となれば幸いです。
アバター