TECH PLAY

株式会社エブリー

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

411

はじめに こんにちは。DELISH KITCHEN 開発部 SERS グループ兼、CTO 室 DevEnable グループ所属の池です。 SERS グループでは主に小売向けプロダクトの開発を行なっており、DevEnable グループでは社内開発組織活性化に向けた活動を行なっています。 DevEnable グループについては以下の記事で紹介しているので、よければご参照ください。 tech.every.tv 本記事では今年度から初開催となるエンジニア新卒研修の取り組みについてご紹介します! エンジニア新卒研修を開催するに至った背景 エブリーでは、新卒社員全員を対象に内定者研修からはじまり、定期的な研修を行い、事業理解や業務におけるスキル獲得など早期の成長をサポートしています。 昨年までの研修ではエンジニアリングに特化した研修は行っておらず、スキル指導は配属後の OJT に依る部分が多いようなオンボーディング体制となっていました。これにより、配属後の実務において以下のような課題が生じていました。 マインドセット エブリーのエンジニアとして働く上で期待されるマインド・スタンスがわからない 領域外の自分ごと化 全体像を把握できずに専門領域外のことを自分ごと化できない 実務における前提知識の学習 インフラを体系的に学ぶ機会がない テストやアーキテクチャに関して馴染みがない そこで、今年度からエンジニア領域におけるオンボーディング体制を強化し、エンジニアとしての早期成長をサポートすべく、エンジニアを対象とした新卒研修を開催することにしました。 エンジニア新卒研修の目的と方針 目的 エンジニア新卒研修の主な目的は次の通りです。 『オーナシップを持ってプロダクト課題の解決に動けるエンジニアへの土台を作る』 上述した課題感を解消するとともに、この目的を達成するための施策を検討しました。 方針 目的に基づき、研修中と研修後に分けて次のような方針を決めました。 研修中 エブリーのエンジニアとして求められるマインドを理解する 専門領域を超えてエブリー全体で使われている技術スタックを理解する 研修後 内部のオリジナルコンテンツによる研修での支援が難しい領域については、配属後のスキル支援環境を提供する エンジニア新卒研修のカリキュラム DevEnable グループと開発部の役員・部長・マネージャーを中心にカリキュラムを策定しました。 今年からの取り組みであるため、コンテンツも役員・部長・マネージャーを中心にゼロから全て作成したものとなっています。 全体で実施期間 5 日です。 マインド研修 CTO からのメッセージ(1 時間) インシデントへの向き合い方(30 分) 技術スタックの把握(講義 + ハンズオン) バックエンド/インフラ(1.5 日) モバイル(1 日) Web(1 日) データ(1 日) ランチ会 (研修後) AWS JumpStart 2024 for NewGrads ここからは各講義について概要を説明します。 CTO からのメッセージ この講義では、CTO が今までのキャリアを通じて大切にしているマインドセットを中心講義しました。 CTO の成功談や失敗談、どういう行動が評価されてきたかなど、エブリーの CTO ならではの経験談がふんだんに盛り込まれた内容となっており、配属に向けて大きな刺激となる講義でした。 CTO講義の様子 インシデントへの向き合い方 以前から、インシデント対応は新卒社員にとって精神的なハードルが高く、入っても何をやっていいかわからず、主体的に取り組みにくいという声が多く上がっていました。 また、新卒社員に限らず会社全体としても同様の課題感を持っていたということもあり、インシデント対応におけるマインド理解を研修の題材として選定しました。 この講義は、そのような課題を解消すべく、エブリー開発部におけるインシデントに対する向き合い方・マインドを教える講義です。 内容は次の通りで、インシデントにおける行動指針を学べる内容となっています。 インシデントが起きたらまずどうすればよいか 関係ありそうな人間を巻き込む インシデントが起こってそう 野次馬でも参加しましょう 別の部署でインシデントが発生している 関係なくても参加しましょう 小さくてもインシデントはインシデント インシデントかどうかは上長が判定するのでとりあえず報告しましょう(割れ窓理論 インシデントは終わってからも大事 ポストモーテム バックエンド/インフラ バックエンド/インフラ講義では、次のような目標を設定しました。 エブリーで共通的に用いられる技術や知識について、一通り触れて理解する 自分たちが開発するシステムが具体的にどのような環境で動いているかを理解する なぜ今の構成になっているかを理解する パフォーマンス観点で取り組み方について理解する ハンズオンを通して上記目標の内容を理解できる形式となっています。 ハンズオンはいくつかのパートに分かれています。 Go を利用した簡易的な API サーバをもとにシステム開発を体験 API を操作・改修 テストコード実装 パフォーマンス改善 デプロイ 手動でプログラムを AWS 上のサーバに配置し、インターネット上に公開された状態を構築する手動デプロイ ECS を用いた半手動デプロイ terraform や CI/CD を用いた自動デプロイ モバイル モバイル講義は座学とハンズオンを通して学べる形式となっており、次のような内容を行いました。 環境構築およびパッケージ構成、マルチターゲットの説明 画面遷移 UIKit, SwiftUI ViewController, ViewModel, View を作成し、画面遷移できるようにする API 接続 Network と Model、非同期処理 API に接続して情報を取得し、Model 変換を行う View 作成 Figma を参考に View を作成。ViewModel と接続し、Model を View に反映 分析 アプリログ収集、データフロー、ログ設計、Crashlytics モバイル講義の様子 Web Web 講義では、次のような目標を設定しました。 『フレームワークによらない web の基礎知識を理解し、今後の web 開発のベースにする』 (座学)web 開発でベースとなる知識を身につける (座学)web 開発で意識するポイントを理解する (ハンズオン)実際の web 開発のイメージをつける 以下のような講義内容となっています。 エブリーでの web 開発 エブリーの web 開発で利用される技術スタック Web 開発の歴史 Web 開発で知っておきたい基礎知識 Web 開発で意識されるポイント ハンズオン:仮想の簡易的な DELISH KITCHEN アプリを用いて、デザインをもとに画面を作成 Web講義の様子 データ データ講義では、データ領域の各分野毎に講義を行うようなカリキュラムとなっています。 データエンジニア データエンジニアとは エブリーで扱うデータ 一般のデータベースとの違い データサイエンティスト [業務理解]エブリーのデータサイエンティストが何をやっているか知る [協業の視点]エンジニアリングとデータサイエンスの違いを知る データストラテジスト データストラテジストとは データストラテジストの具体的な業務内容 ハンズオン Databricks SQL ランチ会 研修期間中は、以下の目的のもと、毎日各領域ごとのエンジニア社員とのランチ会を行いました。 人的ネットワークの構築 実務イメージを深める 各領域の社員とのランチ会を行うことで、全ての領域において気軽に話せるようなネットワークを作ることができました。 受講者の声 今後のエンジニア新卒研修の改善に向けて、受講後のアンケートを通じて、受講者からのフィードバックを収集しました。 全体の満足度に関する質問項目では、受講者全員から最も高い評価を得ることができました。 続いてポジティブな声の一例を紹介します。 マインドセット 新卒でもインシデントを発見した場合は報告する 自分が苦手だと思う部分があったので、配属してから実際その技術を使用するまでに、しっかりと自習しておく 領域外の自分ごと化 クライアントチームやデータチームが何をしているか理解できたことはかなり良かった。今後一緒に仕事するときに相手のことを考えながら業務に取り組めるので、よりスムーズに業務が進められると思う。 実務に必要な前提知識の学習 tfstate の知識などが早速タスクで役に立った 利用している技術・ツールについての全体像が掴めた 研修の目的としていた課題感の解消に関連するようなコメントがあり、大枠の目的は達成できたと思っています。 しかし、一方で次のようなネガティブな声もあり、改善点も見つかりました。 最低限必要な知識などを事前に共有することで、もう一段階踏み込んだ講義内容になると感じた。 研修で使用する各種ツールの使い方についての研修か資料があると取り組みやすいと思いました。 今回の研修は、受講者の専門領域外を含めて全ての領域を学ぶような研修だったこともあり、初めて扱う技術やツールが多く出てきます。 また、運営側で受講者の前提知識の基準を高く設定していた部分もあったため、講義に必要な前提知識を持っていないと理解が困難な内容が一部ありました。 次回開催時にはアンケート結果を踏まえてより新卒社員にとって学びの多い研修になるように改善していく予定です。 おわりに 本記事では初開催となったエンジニア新卒研修について紹介しました。 初めての取り組みで改善すべき点も多くありましたが、全体的には開催して意義のある取り組みだったと思います。 プロダクト開発全体に関する解像度を高める機会になったことや、新卒自身が技術スキルにおける課題を把握できたこと、ランチ会で各領域のエンジニアとの接点を構築できたことなど、配属に向けたエンジニアとしての早期成長の土台作りに繋がったと感じています。 エンジニア新卒研修を含め、今後もスキルアップのためのり組みや体制を整えていく予定です。 他の取り組みを開催した際には同様にレポートをお届けできればと思うので、ご期待ください。
アバター
はじめに ML Kit とは ML Kit Document Scanner API とは 主なクラスについて GmsDocumentScannerOptions (オプション) スキャナーのモード 最大ページ数 フォトギャラリーからのインポートの可否 結果のフォーマット GmsDocumentScanning (スキャナーの開始) GmsDocumentScanningResult (結果処理) まとめ 余談 未実装のキャプチャーモード はじめに トモニテでAndroid開発を担当している岡田です。 先日、ML Kit Document Scanner API のベータ版がリリースされました。 公式ドキュメントやサンプルアプリを参考に、今回はAndroidでの実装方法・内容をご紹介したいと思います。 以下に参考にしたサイトのリンクを示します。是非覗いてみてください。 android-developers.googleblog.com developers.google.com github.com ML Kit とは Googleの機械学習の機能を、Android/iOSアプリとして提供するモバイルSDKです。 例えば顔検出やバーコードスキャンなどの機能を簡単に実装することができます。 ML Kit Document Scanner API とは 紙の資料をカメラでスキャンして、デジタル資料として読み込むことができる、ドキュメントスキャナーSDKのAPIです。 用意されたスキャナーは自動キャプチャ・切り抜き・自動回転検出機能だけでなく、フィルター機能など編集もできます。 つまり、すごいリッチなスキャナーを簡単に実装できるということです。 スキャナーの使用感については 公式のサンプルアプリ でご確認ください。 実装の簡単な流れは以下の通りです。 スキャナーのオプションを決める スキャナーを呼び出す スキャナーから結果を取得する 主なクラスについて 主に以下の3つのクラスで構成されています。 GmsDocumentScannerOptions GmsDocumentScanning GmsDocumentScanningResult それぞれ名前の通り、スキャナーのオプション、スキャン開始、スキャン結果に関するクラスになっています。 これらについて、実際に確認してみたいと思います。 GmsDocumentScannerOptions (オプション) GmsDocumentScannerOptions でスキャナーに関してのオプションを設定できます。 公式ドキュメントのコード では、以下のように紹介されています。 val options = GmsDocumentScannerOptions.Builder() .setGalleryImportAllowed(false) // フォトギャラリーからのインポートの可否 .setPageLimit(2) // 最大ページ枚 .setResultFormats(RESULT_FORMAT_JPEG, RESULT_FORMAT_PDF) // 結果のフォーマット .setScannerMode(SCANNER_MODE_FULL) // スキャナのモード .build() 設定できるオプションは以下の通りです。 スキャナーのモード 最大ページ枚 結果のフォーマット フォトギャラリーからのインポートの可否 それぞれ見ていきます。 スキャナーのモード setScannerMode() を用いて、スキャナーの設定ができます。 現段階では3種類のモードが要されています。 public static final int SCANNER_MODE_BASE = 3; public static final int SCANNER_MODE_BASE_WITH_FILTER = 2; public static final int SCANNER_MODE_FULL = 1; SCANNER_MODE_BASE 基本的な編集機能(ページの切り抜き、回転、並べ替えなど)が使用できます。 SCANNER_MODE_BASEのプレビュー画像 SCANNER_MODE_BASE_WITH_FILTER SCANNER_MODE_BASEモードに画像フィルタ(グレースケール、自動画像補正など)が追加されます。 SCANNER_MODE_BASE_WITH_FILTERのプレビュー画像 SCANNER_MODE_FULL(デフォルト) SCANNER_MODE_BASE_WITH_FILTERモードの機能に加えて画像クリーニング機能(汚れや指の消去など)が追加されます。 SCANNER_MODE_FULLのプレビュー画像 最大ページ数 setPageLimit() を用いて、最大のページ数を指定できます。 int型 で指定します。 以下は最大ページ数を2とした場合のスクリーンショットです。最大数に達すると、ページを追加する "+" アイコンが出ないことがわかると思います。 最大ページ数のプレビュー画像 フォトギャラリーからのインポートの可否 フォトギャラリーからのインポートの可否を設定できます。 setGalleryImportAllowed() を用い、Booleanで指定します。 結果のフォーマット setResultFormats() を用いて、出力結果のフォーマットを指定できます。 現段階では、JPEGかPDF、またはその両方を選択できるようです。 public static final int RESULT_FORMAT_JPEG = 101; public static final int RESULT_FORMAT_PDF = 102; GmsDocumentScanning (スキャナーの開始) スキャナーは GmsDocumentScanning を用いて、以下のように記述できます。 コードは公式のドキュメントとサンプルを参考にしました。 GmsDocumentScanning.getClient(options) // optionsは先ほど紹介したGmsDocumentScannerOptions .getStartScanIntent(activity) .addOnSuccessListener { intentSender -> scannerLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) } .addOnFailureListener { // 失敗した際の処理 } メソッドチェーンでわかりにくいので、順を追って説明します。 はじめに、 GmsDocumentScanning の getClient() を呼び出します。 getClient() は GmsDocumentScannerOptions を引数にとり、 GmsDocumentScanner を返します。 public final class GmsDocumentScanning { @androidx.annotation.NonNull public static com.google.mlkit.vision.documentscanner.GmsDocumentScanner getClient(@androidx.annotation.NonNull com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions options) { /* compiled code */ } private GmsDocumentScanning() { /* compiled code */ } } 返ってくる GmsDocumentScanner はInterfaceです。 getStartScanIntent() というメソッドが用意されています。 こちらは Activity を引数にとり、 Task<IntentSender> を返します。 Task が返されるので、 addOnSuccessListener と addOnFailureListener が使えます。 成功時に IntentSender が返ってきます。 IntentSender はスキャナーを起動するために使用します。 public interface GmsDocumentScanner extends com.google.android.gms.common.api.OptionalModuleApi { @androidx.annotation.NonNull com.google.android.gms.tasks.Task<android.content.IntentSender> getStartScanIntent(@androidx.annotation.NonNull android.app.Activity activity); } scannerLauncher は後述する、 ActivityResultLauncher<IntentSenderRequest> 型の変数です。 こちらは終了したActivityの結果を受け取り、処理します。 GmsDocumentScanningResult (結果処理) GmsDocumentScanningResult を用いて、結果を処理できます。 公式ドキュメント にて、以下のように記述されています。 val scannerLauncher = registerForActivityResult(StartIntentSenderForResult()) { result -> { if (result.resultCode == RESULT_OK) { val result = GmsDocumentScanningResult.fromActivityResultIntent(result.data) // ここで結果を受け取る result.getPages()?.let { pages -> for (page in pages) { val imageUri = pages.get(0).getImageUri() // imageUriを用いた処理 } } result.getPdf()?.let { pdf -> val pdfUri = pdf.getUri() val pageCount = pdf.getPageCount() // pdfUriやpageCountを用いた処理 } } } } GmsDocumentScanningResult の fromActivityResultIntent() を用いて、結果を受け取ります。 Intent を引数に取り、 GmsDocumentScanningResult として返してくれます。 @androidx.annotation.Nullable public static com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult fromActivityResultIntent(@androidx.annotation.Nullable android.content.Intent data) { /* compiled code */ } 返される GmsDocumentScanningResult ですが、 Page と Pdf を持っています。 public abstract class GmsDocumentScanningResult implements android.os.Parcelable { ... public static abstract class Page implements android.os.Parcelable { @androidx.annotation.NonNull public abstract android.net.Uri getImageUri(); public Page() { /* compiled code */ } } public static abstract class Pdf implements android.os.Parcelable { public abstract int getPageCount(); @androidx.annotation.NonNull public abstract android.net.Uri getUri(); public Pdf() { /* compiled code */ } } } 現時点で page は画像の Uri 、 Pdf はページ数と Uri を取得できるようです。 それぞれ結果に合わせて、処理を記述できます。 説明については、以上になります。 まとめ ML Kit Document Scanner API を用いると、簡単に高品質なドキュメントスキャナーが実装できました。 実装の簡単な流れは以下の通りです。 スキャナーのオプションを決める スキャナーを呼び出す スキャナーから結果を取得する 余談 未実装のキャプチャーモード com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions のコードには CaptureMode なるものが存在しました。 public static final int CAPTURE_MODE_AUTO = 1; public static final int CAPTURE_MODE_MANUAL = 2; 現在はオートのみだが、今後はマニュアルで選択できるような機能が追加されるかもしれない……? アップデートが楽しみです。今後も追っていきたいと思います!
アバター
こんにちは 開発本部データ&AIチームでデータエンジニアを担当している塚田です。 今回は、 AWS Certified Data Engineer - Associate を受験しましたので、準備と感想とエブリー(業務)でどのように活かせそうかをまとめたいと思います。 なお、 AWS 認定プログラムアグリーメント に則り試験内容については触れませんのでご承知おきください。 本試験について AWS Certified Data Engineer - Associate は AWS(トレーニングと認定) で以下のように説明されています AWS Certified Data Engineer - Associate は、コアデータ関連の AWS サービスに関するスキルと知識や、データの取り込みと変換、プログラミングの概念を適用しながらのデータパイプラインのオーケストレート、データモデルの設計、データライフサイクルの管理、データ品質の確保といった能力を検証します。 データ分析というよりかはデータパイプライン全般に対してのスキルを評価するものだと感じました。 AWSで適切なサービスを利用してスケールのしやすさや保守運用・セキュリティなどの能力を評価すると 試験ガイド にも記載されているので、体験したものと差異はないように感じています。 なぜ、受験しようと思ったか 現在の業務範囲に通じるものがあること が一番大きな理由ですが、他に理由があるとすれば以下が上げられます。 AWS Certified Data Engineer - Associate は2024年4月現在、比較的新しい試験であること 今まで取得したすべてのAWS Certifiedの有効期限が切れてしまったこと 試験準備 公式問題集 を解く ある程度AWSサービスを利用したことがあったため事前準備なしで回答しました(何度でも受験可能です) 悩んだ問題や間違えたものは解説を読んで理解できたかを再度解くことで確認しました どのような粒度の問題が出るかなどイメージすることができとても有用でした 上記以外にも各資格試験で公式問題集があるので合わせて解いて周辺知識の補完に利用しました 試験ガイド を読む 聞いたことがない・実際利用するとしたときに手が動かせないと思ったサービスは サービス別資料 から該当のサービス資料を確認し、実際にAWSコンソール上で操作してみました あまり特筆する部分はありませんが、今までの経験と未経験の部分を勉強によって補足するイメージで準備を行いました。 受験・受験後 余裕を持って予約した時間・会場で受験しました 80分程度で問題を一周し再確認などして20分残して退出しました その場では結果は受け取れず、後日連絡が来る形でした 結果としては合格はしたものの、まだまだ経験が必要だなと感じるものでした 業務で活かせるか 何かインプットしたらアウトプットしていきたいと思うところですが、 今回は本試験を通じて得たものについてどのように活かせるかを考えたいと思います。 現在、エブリーのデータ&AIチームではDatabricksを活用したデータフローの作成やML・ABテストの運用を行なっています。 tech.every.tv AWSをフルに活用している構成ではありませんが、 セキュリティやコストについてはどのプラットフォームを利用したとしても意識するべきことだと感じていて 知識としてアップデートでき、そういった重要性について再認識することができました。 まとめ データエンジニアとしてはデータを安全に使いやすく必要なセキュリティを担保していくことはもちろんですが、 データ&AIチームではAI・MLをプロダクト導入していくことも推進しています。 tech.every.tv tech.every.tv 今回はAWSの資格試験を切り口に記事を書かせていただきましたが、 ここで得た知識以外にも新しい技術、考え方を柔軟に取り入れていきデータをより利活用できる環境を作っていきたいと考えています。
アバター
DELISH KITCHEN 開発部で小売向き合いでFlutterのアプリ開発をしている野口です。 本記事では、弊社で開発しているFlutterのアプリをFlutter Webでリリースできるかどうかの調査を行った時の知見についてお話しします。 FlutterアプリをWebで動かすとは Flutterはマルチプラットフォーム開発できるので、Android / iOS / Web / Windows / macOS / Linuxで同じソースコードで開発できます。なので、iOS、Android用に作成したアプリでもリリースできます。 一般的なWebサイトを作るときは、HTMLやCSS、JavaScriptを使用しますが、FlutterはiOS、Androidと同じ見た目になるように、HTML、CSS、Canvasなどを使用して描画してくれます。また、FlutterはDartという言語で書かれていますが、それをJavaScriptに変換してくれています。 ただ、パッケージを使用した場合、モバイル特有の機能(ネイティブコードでないと実現できないもの)などDartで書かれてない可能性があるため、パッケージの公式ドキュメントを見てWebに対応しているか確認する必要があります。ここにWebの記載があればWeb対応しているパッケージだと判断できます。 https://pub.dev/packages/flutter_riverpod riverpod FlutterアプリをWebで動かすにあたっての課題 まとめると以下のような課題がありました。 パッケージがWebに対応しているか Platform.isAndroid Platform.isIOSの分岐エラー 具体的な対応 パッケージがWebに対応しているか 対応していないもの そもそもWebに対応してないパッケージがあるので、その場合は代替を探すか、Javascriptで書くか、Webではその機能を諦めるかをしないといけません。 今回は以下のパッケージが使用できませんでした。 - firebase_crashlytics(そもそもWebはクラッシュしないのでいらない) - path_provider - flutter_html - adjust_sdk - flutter_appauth - dart_jsonwebtoken 対応していたが、途中で動かなくなったもの isar isarはv3ではエラーが出て動かなくなっていました。 エラー内容 Error: The integer literal 288085404374050446 can't be represented exactly in JavaScript. Try changing the literal to something that can be represented in JavaScript. In JavaScript 288085404374050432 is the nearest value that can be represented exactly. id: 288085404374050446, https://pub.dev/packages/isar issue も出ており、v4では動くようになっているようですが、公式ドキュメントに ISAR V4 IS NOT READY FOR PRODUCTION USE とあるので本番環境で使用するのは避けたほうが良さそうです。 https://pub.dev/documentation/isar/4.0.0-dev.14/ 対応としては、 isarはローカルデータベースを扱うためのパッケージなので代替になるパッケージに書き換えるが良いかと思います。 v4がstableになるのが待てるのであれば待った方がいいですが、、、v4のPrereleaseが出てから時間が経っており、いつstableになるかわからない状態なので、一旦考えない方針にしています。 バージョンを上げれば対応されるもの flutter_secure_storage エラー内容 Unsupported operation: Platform._operatingSystem 使用しているバージョンではWebが対応していないため、5.0.0に上げれば解決します。 Platform.isAndroid Platform.isIOSの分岐エラー エラー内容 Unsupported operation: Platform._operatingSystem Webで実行時にPlatform.isAndroid Platform.isIOSがあると起こるようです。 この記事のように、Webの分岐を入れるか、universal_platform( https://pub.dev/packages/universal_platform )を使用することで対応できるかと思います。 https://zenn.dev/ryo_ryukalice/articles/140a64f894afad Flutte Webを採用して開発運用を行う上でのビジネス上のリスク(考慮事項) ビジネス上のリスクは以下が挙げられるかなと思います。アプリの複雑度によってリスクの重みは変わるかもしれないですが、これらが許容できればいい選択肢かなと思います。 やりたいことを実現するためのWebに対応しているパッケージがない isarのようにWebに対応していたパッケージが、更新されなくなりWebが動かなくなる 2が原因でflutterのバージョンを上げづらくなる iOS、AndroidアプリをFlutter Webで動かす iOS、Androidアプリ Flutter Webアプリ Flutter Webを動かした結果、画像のようになりました。 見た目としてはiOS、Androidアプリがブラウザのサイズに合わせてそのまま大きくなっています。 このままでも見た目はそんなに悪くないかなと思いますが、商品情報が大きすぎるなどの場合はレスポンシブ対応か、モバイルのサイズに統一するなどすると良くなると思います。 動作が重くなる様子はなかったのでリリースはできるかなと思いました。 まとめ Flutter Webで開発をする際の主な考慮点は使用するパッケージがWebに対応しているかどうかということがわかりました。 ただ、Webに対応していても動かなくリスクがあるので、Webだけは使えない機能が出る可能性もありそうですね。 個人的にはシンプルなアプリであれば基本的には動きそうなので用途によっては良い選択なのではと思いました。
アバター
はじめに エブリーでソフトウェアエンジニアをしている本丸です。 先日、弊社からヘルスケアアプリ「 ヘルシカ 」がリリースされたのはご存知でしょうか?ヘルシカは弊社のサービスであるDELISH KITCHENのヘルスケア機能を切り出したサービスなのですが、ヘルシカの裏側で認証・課金の共通基盤が動いています。 今回はこの認証・課金の共通基盤(社内でDAPと呼んでいるため、以降はDAPと表記します)についてお話しできればと思います。なお、実装の詳細には触れず概要の説明に留める予定です。 システムの概要 DAPとは DAPとは、認証・課金の共通基盤で、IdP(IDプロバイダー: ユーザーIDを保存および検証するサービス)としての役割と、課金を管理する役割を持っています。 DAPという名称は、一般的に使われるものではなくいわゆる造語なのですが、社内やチーム内で認識を合わせるために命名されたという経緯があります。 DAPの目的は、複数サービスでのユーザーの管理を一元化することです。 下図はDAPとそれに関わるものを表した概要図です。 DAPではSNSを用いた認証をサポートしているので、LINEやAppleといった外部のプラットフォームを利用します。以降は、DAP内の認証サーバーのことをInternal IdP、認証に利用する外部のプラットフォームのことをExternal IdPとします。 矢印は、依存の方向を示していてPaymentはInternal IdPに依存しているという関係になっています。DAPはExternal IdPや外部の課金プラットフォームに依存しており、弊社のサービスがDAPを利用するという形になっています。 認証サーバとしての役割 認証サーバーとしての役割は、ユーザーがどのアカウントと紐づくのかの認証を行うというのが主な役割になります。サインアップの時は図のようなフローになるのですが、全て説明すると長くなってしまうので要点だけお話しします。 Internal IdPとExternal IdPの間の認証情報 Internal IdPはExternal IdPに認証を委譲しています。Internal IdPはExternal IdPからIDトークンを受け取るのですが、このIDトークンの中にIDトークンの発行者や一意の識別子などが含まれており、それをもとにどのユーザーなのかの判断を行います。 Internal IdPとApplication ServerとClientの間の認証情報 ClientからApplication ServerのAPIを呼び出す時にはAccess Tokenを認証済みかどうかの判定に利用します。このAccess TokenはInternal IdPで発行しています。Application ServerはClientからAccess Tokenを受け取った時に、Internal IdPを通して認証を行い、認証が成功した場合に後続の処理を行うことになります。 Web View 図の中で、web viewに言及している箇所があるのですが、これはClientがスマホのアプリの時の挙動を示したものです。ClientとExternal IdPの間で直接認証する場合は、External IdPが用意してくれているSDKを利用した方が便利ではあるのですが、Internal IdPを経由させる目的でweb viewから認証を行うようにしています。 課金サーバとしての役割 課金サーバとしての役割は主に2つです。 1つ目はユーザーが商品を購入した際にappleから受け取ったレシートを検証して、商品の有効性を確かめることです。 2つ目はレシートとユーザー状態の管理・更新です。DAPではappleからレシートの情報が更新された時に通知を受け取り、それをトリガーとしてユーザーの状態の更新を行なっています。 レシートとユーザー状態の管理については、弊社ブログの過去記事にもありますのでよければご覧ください。 https://tech.every.tv/entry/2022/04/07/170000 RFCに則った実装 少し、話は逸れるのですがDAPの中のInternal IdPに関してはRFCやOIDCのドキュメントに則った実装が基本方針になっています。 社内用にカスタマイズされたドキュメントではないので、一見とっつきにくくもあるのですが、Internal IdPに関しては下記の理由などでドキュメントに則った方が良いという判断になったようです。 - IdPなので社内特有のロジックが入り込みにくい - 社内ドキュメントよりドキュメントのメンテナンスが維持されやすい - セキュリティ的な要件も満たせる 動作確認の困難さ 要件として、従来のDAPの仕様に則っていないサービスと新規のDAPの仕様に則っているサービスでユーザーのアカウント・課金状態を紐づける必要がありました。 この連携パターンが、従来のサービスでSNS連携されているか、新規サービスでSNS連携されているかなどに加えて課金状態の確認まで必要だったため、かなり複雑に感じました。 動作確認の段階でどのようなパターンがあるか洗い出してテストをしたのですが、動作確認の段階で考慮漏れなどが見つかり修正に追われるといったこともありました。複雑なシステムを作るときは想定されるパターンをあらかじめ洗い出してから開発すべきだったかなというのが反省です。 まとめ 記事にしたこと以外でもリリースまでに色々と大変なこともあったのですが、なんとか致命的なバグはなく動いているようなので一安心といったところです。 複雑なシステムなので概要を話すだけの形にはなってしまいましたが、認証・課金基盤でどのようなことをやっているのかの導入になれば幸いです。 最後に宣伝になりますが、このDAPを裏で利用しているヘルシカというサービスがリリースしたのでよければ使ってみてください。 参考資料 https://datatracker.ietf.org/doc/html/rfc6749 https://datatracker.ietf.org/doc/html/rfc7519 https://datatracker.ietf.org/doc/html/rfc8252 https://openid.net/specs/openid-connect-core-1_0-final.html
アバター
目次 はじめに 1年目を振り返る 入社前について 実際に入社してから 2年目に突入して まとめ はじめに こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司( ktanonymous )です。 4月1日をもって新卒入社してから1年が経ちました。 そこで、今回の記事では、これまでの振り返りと2年目を迎えた今感じていることについて書きたいと思います(文字ばかりですがご容赦ください)。 1年目を振り返る 入社前について 1年目を振り返る前に、入社前の経緯について少しだけ触れておこうと思います。 大学では工学部の電気情報物理工学科に所属し、情報分野だけでなく物理学や電磁気学なども学んでいました。 また、大学院ではマルチエージェントシミュレーション 1 に関する研究を行っていました。 講義や研究を通してAIやコンピュータの基礎を学んでいく中で、情報分野/エンジニアリングに興味を持つようになりました。 しかし、実際にものづくりをした経験は無く、エンジニアリングに関しては専門的な知識がほとんどありませんでした。 そのため、大学院時代にはプログラミングの基礎を学ぶために、Pythonを中心に学習を始めました。 また、 A Tour of Go も少しだけやりました。 そのほかにも、友人と一緒に簡単なwebアプリを作ったり、ハッカソン型のインターンに参加したりして、 少しでも開発経験を多く積めるよう努力をしました。 そして、就職活動をする中で、イベントの中でエブリーと出会い入社まで至りました。 実際に入社してから 入社してからは、1週間の新卒研修を受けた後、実際の業務に携わることになりました。 OJTですぐに実際の業務に携わり、入社直後に事業貢献できるスピード感はベンチャー企業ならではだと思います。 トモニテではバックエンドにはGo、フロントエンドにはJavaScript/TypeScript(フレームワークはReact/Next.js)を使って開発をしています。 初めのうちは、既に実装されているAPIの改修やコンポーネントの表示ロジックの改修など、バックエンド/フロントエンドどちらかのみのタスクを担当していました。 徐々にタスクの幅も広がり、新規APIを作成したり画面を作成したりすることも増えました。 それからは、APIと画面を実装して疎通させたりLPを1から作成したりもしました。 また、サービスのリブランディングという大きなプロジェクトも経験することができました。 リブランディングプロジェクトを通じてサービスの目指す方向性を改めて考えることができ、 サービスに込めた想いをチームの一員として実現していくことに対する責任感も強くなりました。 社内では、事業向き合いの業務がメインでありつつ、積極的な技術的挑戦の機会を提供するための施策も用意されています。 11月に社内で行われた挑戦weekでは、ChatGPTを利用した社内ChatAppのテンプレート機能の実装にも挑戦しました。 実務未経験から約1年を経て、今では、新機能の開発および開発のリードを担当させていただけるようにもなりました。 自分が開発をリードすることになるため、新機能の開発に必要となるAPIやDB設計なども担当し、 モバイルアプリ開発側との連携やプロダクトマネージャーとの仕様・工数の調整なども行っています。 今開発している新機能では複雑なロジック・仕様も含まれていて、実装は簡単ではありませんが、 サービスを大きく成長させる機能にするために、チーム全体で協調しつつ開発に取り組んでいます。 この新機能も近いうちにリリースされる予定ですので、リリースされたら是非使い倒していただけると幸いです。 責任範囲の広いタスクは大変ではありますが、難しいタスクに挑戦できることや施策をリードする側として動くことで プロダクトに対する責任感も高まり、技術的な視点だけでなくマネジメント・ビジネス的な視点を持つことの重要性も感じることができました。 CI/CDやインフラ周りの知識についてもまだまだ浅識なので、隙を見て地道なキャッチアップを続けています。 また、 冒頭 でも書いている通り、最近はDev Enableグループも兼任させていただいています。 Dev Enableグループでは、開発本部を横断し、組織の活性化・成長環境の提供・発信・広報の強化・採用など、さまざまな課題解決の推進を目指します。 詳しくは以下の記事をご覧ください。 1年目は実務経験の無い状態からのスタートでしたが、 自分のスキルアップ・マインドの醸成のために積極的にチャレンジし、新しい技術や知識を吸収することができました。 任せてもらえる領域も広がり、自身の成長を大きく感じられる1年でした。 2年目に突入して 4月1日には新卒社員の入社式も行われました。 後輩を迎え、改めて自分が2年目を迎えたことを実感しました。 4月に入り、新卒社員のオンボーディングプロジェクトにも携わっています。 また、今年度から新卒エンジニア向けの研修プログラムも予定されています。 これは、Dev Enableグループが主導で行っているプロジェクトの一つです。 サービス開発を通じての事業貢献だけではなく、組織の活性化やエンジニアの成長など会社全体への貢献もできるようになり、 エブリーの一員としての責任感もより一層強くなってきました。 今後も、自分のスキルアップはもちろん、トモニテ/エブリーの成長に貢献できるよう積極的にチャレンジしていきたいと思います。 まとめ 今回の記事では、4月を迎えた今だからこそ書ける内容だと思い、新卒1年目を迎えての振り返りと2年目を迎えて感じたことについて書いてみました。 就職活動中、ハッカソン型のインターンシップに参加することはありましたが、実務経験は一切ない状態での入社だったので経験豊富な同期や先輩方に負けないよう必死でした。 この記事を書くことで、自分のこれまでの挑戦を振り返ることやこれからについて改めて考えることができる良い機会になりました。 まだまだ未熟で、今でも日々のキャッチアップや新しい技術の習得に励んでいますが、1年目を振り返ると、非常に大きく成長できた1年だったと感じています。 2年目も、サポートしてくれる周囲の人への感謝を忘れずに、1年目以上に成長できるようにチャレンジしていきたいと思います。 最後まで読んでいただき、ありがとうございました。 マルチエージェント・シミュレーションとは? | MAS COMMUNITY ↩
アバター
はじめまして、データストラテジストの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 を監視したお話でした。このように、エブリーではスーパーマーケットなどの小売業界へより良いソリューションを提供できるようにプロダクトの改善を進めておりますので、また何か紹介できればと思います。お読みいただきありがとうございました。
アバター