every Tech Blog

株式会社エブリーのTech Blogです。

Androidのヘルスケアアプリ連携について

はじめに

今回は 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 Chat の機能・使い方を整理しつつ開発者体験が向上する活用事例を考えてみた

Github Copilot Chat の機能・使い方を整理しつつ開発者体験が向上する活用事例を考えてみた

はじめに

子育てメディア「トモニテ」でバックエンドやフロントエンドの設計・開発を担当している桝村です。

エブリーは、現在 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 CodeVisual 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 でローカルでサーバーを起動した後、そのコマンドとその結果を説明してもらう

コンテキスト変数の使用例1

@terminal #terminalLastCommand

コンテキスト変数の使用例2

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 キーバインドを使用します。

chat view

Quick chat

Quick chat とは、エディタの上部に表示されるチャットビューです。

完全なチャットビューセッションを開始したり、エディタでインラインチャットを開くことなく、簡単に質問をすることができます。

コマンド パレットで Chat: Open Quick Chat を実行するか、キーボード ショートカット ⇧⌘I を使用します。

quick chat

Inline chat

Inline chat とは、コード上に表示されるチャットビューです。

コーディング中にインラインで質問をすることができます。

どのファイルでも、キーボードの ⌘I を押すと、Copilot インライン チャットを表示できます。

inline chat

開発者体験が向上する活用事例 (ワークスペース編)

プロジェクトを新規作成してもらう

自然言語の説明に基づいて新しいプロジェクトを作成してもらいます。

@workspace /new FizzBuzz 問題を標準出力する Golang プログラムとテストコードを作成

また、ルートディレクトリに Golang プログラムを呼び出す main.go と go.mod 、README を作成してください

プロジェクトの新規作成1

プロジェクトの新規作成2

結果として、質問した通りのディレクトリ構造やファイルが作成されました。

また、概ね期待通りのプログラムが生成され、エラーなく実行できることを確認しました。

特定の指示による既存のコードの修正・差分表示・一括置換してもらう

Copilot Chat に作成してもらった FizzBuzz 問題のプログラムに対して、エラーハンドリングを追加してもらいます。

エラーハンドリングを追加してください

コードの修正・置換

概ね期待通りの修正が行われました。

また、差分表示では、修正前と修正後のコードの違いが色分けされて表示され、修正箇所が一目でわかりました。

選択したコードを説明してもらう

選択したコードを日本語で説明してもらいます。

/explain in Japanese

コードを説明1

コードを説明2

外部パッケージの処理の概要のみでなく、ソースコードについても丁寧に説明してもらうことができました。

また、色分けや改行がとても見やすく、コードの理解を助けてくれました。日本語も特に違和感ないですね。

コードのエラーや問題点への修正を提案してもらう

選択したコードのエラーや問題点への修正を提案してもらいます。

事前に FizzBuzz 問題のプログラムに対して、エラーを 5 つ追加しておきます。

エラーを追加

@workspace /fix #file:fizzbuzz.go

エラーを修正

一つの要求に対して、漏れなく全てのエラーに対する修正を提案してくれました。

各修正に対する説明も丁寧だと感じました。

開発者体験が向上する活用事例 (ターミナル編)

実行したコマンドがエラーだった場合、修正を提案してもらう

直前のコマンドがエラーだった場合、修正を提案してもらいます。

事前に FizzBuzz 問題のプログラムに対して、エラーを追加した上で、ターミナル上でコマンドを実行します。

すると、ダイアログに Explain using Copilot が表示されるので、それをクリックします。

ターミナルからエラーを修正1

@terminal #terminalLastCommand

ターミナルからエラーを修正2

コマンドの実行結果を踏まえて、修正を提案してもらうことができました。

補足ですが、#とタイプすれば、#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 を利用される方々の参考になれば幸いです。

Redash運用環境改善の取り組み

はじめに

こんにちは。 株式会社エブリーの開発本部データ&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による環境管理などを行い、運用コストの削減と運用の効率化を実現しました。

ライブの録画配信にAmazonIVSを活用する

はじめに

こんにちは。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の録画機能を活用しつつ、アーカイブ配信まで行えるようにしました。

  1. 録画終了をEventBridge経由でAPI通知
  2. MediaConvertのjobを作成して録画映像をmp4に変換し、ダウンロードと編集可能な状態にする
  3. 再編集したものをS3にアップロードして、EventBridge経由でAPI通知
  4. 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_namerecording_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のイベントメタデータファイルから以下のような処理を行いました。

  1. recording_s3_key_prefix 配下の /events/recording-ended.json から実際の録画開始時間を取得
  2. マスターデータとして保持しているライブ開始時間と録画開始時間の差分を算出
  3. 算出された時間だけ動画の冒頭をクリッピングする設定を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点あります。

  1. MediaConvertのjob作成時に判別していた内部のライブ情報やConvertの目的種別が通知されるjobIDなどでは判別できない
  2. 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

Go testにおける可読性を保つ方法を考える

はじめに

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() が提供している機能はなんでしょうか?

  1. 指定の条件に合致したレコードを返却する
  2. レコードの並び順を定まったものにする

上記の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言語におけるテストコードの可読性を上げるアプローチについて、実際にコードを交えながら考えてみました。

テストコードは挙動を担保する重要な役割を持っていますが、テストコード自体のメンテナンス性が下がると徐々に十分なテストが行われない状態に陥いりがちです。

そういった将来の問題を避けるためにも、自分がテストコードを書くタイミングで、他人が見て理解しやすいコードになっているかを意識しておくのが重要と考えています。