GCP版Dataformで冪等性を担保する設計ポイント3つ

データエンジニアの遠藤です。

TVer Advent Calendar 2023の24日目の記事になります。

はじめに

本年(2023年)、Google Cloudのビッグデータ基盤として展開されるBigQueryでは、データガバナンスツールであるDataformがGA(Generally Avaialble)になりました。

cloud.google.com

このDataformの登場により、BigQuery上でデータを利活用しやすいように変換する(データマートを生成する)システムの構築が容易になりました。

本記事では、Dataform上において、定常実行やリトライ実行を容易にするために、冪等性が担保される設計のテクニックを3点紹介します。(Dataformの基本的な使い方については触れませんのでご注意ください)

1. SQLX内のクエリに変数を用いる

DataformはSQLXと呼ばれるファイルでデータ変換処理内容を管理します。 SQLXファイルにはデータ更新設定とクエリを記載しますが、クエリ中で変数を利用することができます。

まず、 includes/dataform.jsonvars 内でSQLXファイル内で用いる変数を定義します。

{
  "warehouse": "bigquery",
  "defaultDatabase": "dev-project",
  "defaultLocation": "us-central1",
  "vars": {
    "certain_date": "2023-01-01"
  }
}

dataform.jsonで定義した変数は以下のようにSQLXのクエリ内に表記します(以下の例ではWHERE句の中でdataform.jsonで定義した変数 certain_date を用いるように設定しています)。

config {
    type: "table",
    schema: "result_dataset",
    name: "result_table",
}

SELECT column_a,
       column_b,
       column_c,
       partition_column
FROM ${ref("source_table")}
WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'

これにより、パラメータによって可変になるクエリ表現が可能になります。

2. 定期的な実行はDataform APIから行う

一般的に、Google Cloud上のDataformは以下の方法で実行することができます。

  • ワークスペース内のGUIで「実行を開始」をクリックする
  • リリース構成・ワークフロー構成を設定する
  • Dataform APIにジョブ実行リクエストを送る

処理を冪等にするためには、SQLX内の変数のようなパラメータを毎回変えながら定期的に実行する仕組みが必要です。

この要件を十分に満たすには、上記で3番目に挙げた実行方法「Dataform APIからの実行」が最適です。

なぜなら、「Dataform APIからの実行」は、APIでのリクエスト情報を適切に設定することで、dataform.json内の設定をオーバーライドして実行することが可能であるからです。

cloud.google.com

「Dataform APIからの実行」は、以下の2つをDataform APIで処理することで実現します。

まず、コンパイル結果を作成するため、以下のDataform API「compilationResults.create」を実行します。

cloud.google.com

API「compilationResults.create」は、「CodeCompilationConfig」オブジェクトに以下の情報を設定することで、「CodeCompilationConfig」内の設定情報を優先しながらコンパイル結果を作成します。

  • 出力結果格納先BigQueryプロジェクト(defaultDatabase)
  • 出力結果格納先データセット名の接尾辞(schemaSuffix)
  • 出力結果格納先テーブル名の接頭辞(tablePrefix)
  • 変数(vars)

このAPIリクエストでは、コンパイル結果が正常に作成されると、compilationResult というコンパイル結果IDが返されます。

次に、このコンパイル結果IDを用いて、以下のDataform API「workflowInvocations.create」を実行すると、作成したコンパイル結果でジョブを実行します。

cloud.google.com

このように、「Dataform APIからの実行」は、パラメータ制御の自由度が高くなるため、backfill実行も容易に行うことが可能です。

なお、Google Cloudでは、Dataform APIによる一連の実行はCloud Composer・Cloud Workflowsといった他のジョブ管理ツールから行うように推奨されています。

3. クエリ結果を積み上げる場合はpre_operations処理を追加する

Dataformにおける出力結果の格納方法は以下の方法が可能です(SQLXのconfigで設定します)。

  • VIEW化(結果の出力はせずにVIEWで設定するのみにとどめる)
  • 洗い替え(格納先のテーブルにデータが存在する場合、上書きして更新する)
  • 積み上げ(格納先のテーブル上の既存データはそのままにしながら、クエリ結果を新たに追加する)

結果格納の設定が「積み上げ」の場合、同じジョブが複数回実行されると、出力結果が重複して格納されてしまいます。

そのため、ジョブを複数回実行しても出力結果が冪等になるためには、SQLX内に新規にpre_operations項目を設けてそこにDELETE文を設定することで解決します。

config {
    type: "incremental",
    schema: "result_dataset",
    name: "result_incremental_table",
}

pre_operations {
    DELETE FROM ${self()} WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'
}

SELECT column_a,
       column_b,
       column_c,
       partition_column
FROM ${ref("source_table")}
WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'

pre_operations内のDELETE文は、1回目の実行では何も影響がないですが、2回目以降の実行では重複を避ける処理として効果を発揮します。

おわりに

本記事では、GCP版Dataform上に載せるシステムにおいてジョブが冪等になるためのポイント3点を紹介させていただきました。

  1. SQLX内のクエリに変数を用いる
  2. 定期的な実行はDataform APIから行う
  3. クエリ結果を積み上げる場合はpre_operations処理を追加する

以上の3点を考慮すれば、Dataform運用の効率性が高まるかと思いますので、ぜひ参考にしてみてください。

レコメンドエンジンで日本を元気に

こんにちは、TVer レコメンドエンジン担当の由井です。 こちらは TVer Advent Calendar 2023 の23日目の記事です。

なぜレコメンドなのか?

今年の5月からTVerにジョインして、レコメンドエンジンの開発に携わらせて頂いていますが、そもそもなぜ自分がレコメンド開発に携わることになったのかや、レコメンドエンジンにかける想いを、初心を忘れないためにも、つらつらと書かせてもらえたらと思います。 ただのポエムですのでイブ前ということで気軽に読んで頂けたらと思います。

ヨーロッパでの再発見

自分は、元々、ヨーロッパの歴史や新しい事を経験する事が好きだったため、あまり計画せずに現地企業に転職したという過去がありまして、数年間の経験を通じて現地の文化や価値観に触れることができ、その素晴らしさを経験してきましたが、同時に日本の持つ素晴らしさも再発見することができました。例えば、日本は周りの人間と上手く協力して行動することができたり、新しい価値観を受け入れる寛容さを持ち合わせていると思います。そこから派生したアニメや漫画、その他伝統文化といった日本発祥のコンテンツは、今、世界中から高い支持を得ることができています。

TVerとの出会い

そんな日本に貢献したいという思いが強くなり、日本国内で大きなインパクトを持つ企業で働きたいと考えていた時、

「テレビを開放して、もっとワクワクする未来を。TVerと新しい世界を、一緒に」 という理念を持つTVerの素晴らしいメンバーに出会う機会がありました。

レコメンドエンジンで日本を元気に

今、日本は直近30年の統計で見ると、先進諸外国と同様の成長率を維持できていない現状があります。

私見ではありますが、日本では、かつてのような期待に胸膨らむような夢のある明るいニュースよりも暗い気持ちになってしまうニュースがどうしても増えてしまっているのではと感じています。

日本にはアニメや漫画など、まだまだ世界に誇れる素晴らしいものが確かに存在しているにもかかわらず、必ずしも良い結果に結びついていない原因としては、

ユーザーと情報、ユーザーとユーザー、情報と情報といった因果の無限の網の目のような複雑な関係が絡み合っていて、それら一つ一つの火花ともベクトルとも言えるような小さな方向性の単位が、大きな社会の方向性を作り出しているんだと思います。

そしてその大きな川の流れのような社会の方向性が、日本の行く先を決定づけているのだとするならば、それらの火花の方向性を変えるためには、小さな火花の方向を少しずつ揃えていく必要があって、自分が携わるレコメンドエンジンは、個人的には、それぞれの火花であるベクトルの方向性を揃えていくことで、やがてはユーザー全体の大きな流れを良いベクトルに揃えることができる素晴らしい技術だと考えています。

自分はレコメンドエンジン開発を通じて、小さな火花を揃えて、より大きな火花にしていき、ひいては日本のユーザー全体の火花を揃えることで、日本全体がもっとわくわくできて、もっと元気になるような日本のエンジンを作りたいです。

MLOpsへの取り組み

一方、話は変わって、自分がPLとしてAWS上で現在取り組んでいるMLOpsは、多くのメンバーが関わる必要のある非常に大きな枠組みですので、自分ひとりでは成し遂げることができず、だからこそ、スペシャリストであるメンバー一人一人の力が必要だと考えています。

幸い、今、自分の周りには、リスペクトに値するようなスキルとマインドを持った本当に素晴らしいスペシャリストの方々がいらっしゃって、様々な想いを持って業務に従事されています。

それらの想いは、個別の数値目標として表現されているのかもしれませんが、ここで視点を1歩引いて、その想い一つ一つを深掘りしてみると、それぞれが、「サービスを良くしてユーザーに良いものを届けたい。TVerを良いサービスだと思ってもらいたい」というピュアな熱い想いを持って行動されている方たちなんだなという気づきがありました。

チーム1人1人の想いの火花を同じ方向に揃えることができれば、1人では決して成し得ない大きな力となり、1つ1つはたしかに小さな1歩しかもしれませんが、私達のその1歩1歩が、やがてユーザーの心に響くサービスの形として繋がっていく。だからこそ、小さな火花を大事にしながら毎日の作業に励んで行きたいと思います。

カンパニーという言葉の語源について

話は変わりますが、私自身、歴オタでして、それが高じてヨーロッパに移住を決意したことがあるような無計画なタイプの人間なのですが、特に語源学(Etymology)という、それぞれの単語がどのような歴史的な経緯で生まれ、変遷していったのかを研究する学問なのですが、自分をそれを追いかけるのが週末の楽しみという珍しい趣味を持っています。

その中で、自分が好きな言葉の一つに、カンパニー「Company」という言葉があります。日本では「会社」という意味が最も一般的に通じる意味かもしれません。 カンパニー(company)は、12世紀中頃、「共にパンを食べる仲間」という意味の後期ラテン語のcompanioから派生した言葉であり、これは、ラテン語の「共に、一緒に」を意味するcomという言葉と、「パン」を意味するpanisを組み合わせた言葉となります。

com + panis = copanio(共にパンを食べる仲間)

ここでの「パン」とは、教会のミサで食べるパンのことであり、companyとは、同じ教会に通い、同じ信仰を持ち、苦難を共にして助け合う人たちの集団のことを、元来指していた言葉でした。

その後、大航海時代に入ると、同じ船に乗り、リスクを承知で海の向こうに出かけて貿易をする夢追い人たちが現れました。

彼らは、未知の海に漕ぎ出すリスクもそれを乗り越えた後の莫大な利益も公平に分け合う仲間たちという意味で、自分たちのことを「カンパニー」と呼びました。 それがやがて「会社」という意味に転じていったそうです。

つまり、「カンパニー」というのは、ただの「会社」ではなく、「リスクに対して、それぞれができることを力を合わせて乗り越え、共に利益を得ていく集団」という意味を持っているんだと思います。

TVerの中では、

「我々は仲間である。1人で早く。よりも、みんなで遠くへ。」

という言葉があります。 自分が好きな言葉で、カンパニーという言葉にも通じる良い言葉だと思います。

最後にレコメンドへの抱負

時は流れ、カンパニーという言葉の定義は移り変わっていき、本来の意味でのカンパニーと呼べる気概を持った企業が、果たしてどれほど存在するのかは定かではありませんが、 だからこそ私達は、大きな変化の時代の中でも、メンバーそれぞれの火花の方向性を揃えて同じ船に乗り、共に困難を乗り越えていくことで、今後もユーザーに素晴らしい価値を提案し、社会から選ばれるカンパニーであり続けられたらと思います。

テレビとTVerと私

1、はじめに

皆さん、こんにちは、3月からコネクテッドTVタスクにJoinし、ビジネス領域を担当している井出と申します。

この記事は、TVerアドベントカレンダー22日目の記事です。

qiita.com

2、今日は何の話を?

突然ですが、皆さんはテレビという言葉からどういった言葉を連想されますでしょうか。

いわゆる、ドラマやバラエティなどの地上波の番組を思い浮かべる方もいれば、テレビという機器を思い浮かべる方もいるのではないでしょうか。 (昨今は、テレビを持っていない方や、モニターや、プロジェクターなどで代用している方もいると思います。)

今回は、いわゆる地上波を指すテレビではなく、 機器でいうテレビ、その中でも、 『コネクテッドTV』についてお話ししたいと思います。

このブログに書いてあること

  • コネクテッドTVの定義や、概要
  • TVerにおけるコネクテッドTVに位置付け
  • 私の部署の業務内容や今後の展望

3、コネクテッドTVとは

コネクテッドTVとは、テレビ自体がインターネットに接続されているスマートテレビはもちろん、 Fire TV、Chromecastなどのストリーミングメディアプレイヤー、 PlayStationXboxなどのゲーム機、プロジェクター、セットトップボックスなどの外部機器を接続している場合のテレビも含んでいるものを指します。 (※TVerはゲーム機にはまだ対応しておりません、ごめんなさい!)

自分の家のテレビや、モニターがインターネット接続に対応していない場合も、外部機器を接続することで、コネクテッドTVとして、動画サービスなどのエンタメを堪能することができます。

コネクテッドTVの広告について

下記の記事を引用させていただくと、 www.screens-lab.jp

米国では、

先行している米国では、2020年のeMarketerの調査によると、2024年までには182.9億ドル(約2兆3800億円※)に達すると予測されています。 ちなみに2021年の米国の総広告費はおよそ2800億ドルと推計されており、コネクテッドTV広告はその4%程度を占めることになります。

日本では、

日本におけるコネクテッドTVの広告市場も順調に拡大しています。今年2022年の3月に発表されたSMN、AJA、デジタルインファクトによる調査結果によると、2021年のコネクテッドTVの広告市場規模は344億円。 米国に比べると金額的にはまだまだ小さいものですが、前年比で337%の成長をみせています。新型コロナウイルスの流行で、コネクテッドTVの視聴が拡大したこの年、広告市場も大きく伸びました。 今後もさらなる成長が見込まれています。2025年には1695億円と、約5倍もの成長が予測されています。

と、期待されている広告出稿先であるといえそうです。

4、TVerとコネクテッドTV

それでは、TVerにおけるコネクテッドTVとの関わり方についてご紹介したいと思います。

対応状況は?

TVerがコネクテッドTVに対応したのは、2019年4月で、当初はFire TVシリーズソニー社製のAndroid TVだけでしたが、 スマートテレビで9社、ストリーミングメディアプレイヤーで2社、プロジェクターで3社、セットトップボックスで3社と 大幅に増え、 現在では、リンク先の通り多くの機器に対応しています。 現在も日々対応機器を拡大すべく、取り組んでおります。 過去に遡って対応させていただくこともあるので、もしかしたら、今まで諦めていたあなたの機器も対応しているかもしれません。 ぜひチェックしてみてくださいませ! https://help.tver.jp/hc/ja/articles/222120868

こちらでは接続方法などもご案内しています! tver.jp

TVerにおけるコネクテッドTVの立ち位置

今では、リリース当初1.9%ほどだったデバイス別再生割合は、2023年1月には、31%達し、15倍以上に成長しました。

tver.co.jp

TVerを使う3人に1人は、コネクテッドTVを利用して、視聴しているということになります。 現在では、 コネクテッドTV領域でのビジネスをさらに強化すべく、コネクテッドTV専門部署もできました(冒頭にも触れましたが、私もそこに所属しております)

5、コネクテッドTVタスクとして

タスクとはTVer内の用語で部署を指しています。

ここまでコネクテッドTV、また、コネクテッドTVとTVerについて触れてきました。 次に、コネクテッドTVタスクとして、個人として、今後どうコネクテッドTVと関わっていくかについて書いていきたいと思います。

コネクテッドTVタスクが取り組んでいること

コネクテッドTVタスクは、コネクテッドTVの普及率の増加も伴い、テレビ業界のビジネスをより発展させていく中で、これからは配信にも力を入れていく必要があり、スマートフォンタブレットやPCとは別に、コネクテッドTVを独立した領域として切り出し、専門的に対応していこうと、チームが立ち上がりました。 一つの部署にCTVの交渉役や、ビジネスを検討するなどをするビジネス担当と、開発のディレクターによる技術担当に分かれておりまして、簡単にいうと、 マーケティングと開発を1部署で行なっている小さな会社のような構成になっています。

ビジネスチーム(仮)が何をやっているか

※明確なチームとして分かれているわけではなく、担当しているだけなので、(仮)と書いています。

開発チームについては、ここで詳しく話しているようなので、この場では、ビジネス担当側が何をやっているかについて話しておきたいです。 note.com

ビジネスチームは、

  • 各メーカー様とアライアンスの調整
  • コネクテッドTV領域の外部へのマーケティング活動全般
  • 社内のCRM担当との調整

などを行なっております。

全てを説明すると多岐にわたっていきますし、一般的なマーケティングの話と変わらない部分もあるので、 特殊性がありそうな部分だけ触れていきたいと思います。

コネクテッドTVのマーケティングについて

1、SNS広告や、パートナー様出面での訴求について

コネクテッドTVを擁する各メーカーさんとは、協業によりTVerを使ってもらえるユーザーさんの獲得を行っています。 獲得するための枠は、多岐に渡りますが、 認知獲得を目的にする場合とユーザーさん自体の獲得を目的とするものに、大別されます。

YouTubeや、Xなどを活用して、認知を獲得することを試みたり、ユーザー獲得をすることまでは、一般的なWEBサービスなどのマーケティング手法と同様です。

ただ、YouTube、Xなどは、スマートフォンアプリや、PCサイトなどに直接遷移することで獲得までできますが、 コネクテッドTVでの利用を目的とする場合、なかなか直接遷移させることができません。 そこで、登場してくるのが、 アライアンスパートナーであるデバイスメーカーさんが持っている媒体(アプリが並んでいるHOME画面など)です。

例えば、こんな媒体があり、

advertising.amazon.com

TVerとしては、 単一の作品を訴求したり、TVerサービス自体を訴求することで、TVerをお使いいただけるようトライアンドエラーを繰り返しています。

もう少し、私が考えていることに触れると、 そもそも、TVerをテレビで使いたいなと思っているか、 もっというとTVerをテレビで使えると知っていないと、 コネクテッドTV上の広告で、TVerの広告をみても、 ピンときません(TVで見られると思っていないので、起動しようという動機付けに至りません)。

コネクテッドTVでTVerが利用できることの認知向上は、 何もせず、そのまま待っていても、 爆発的な何かが起きない限り、大きく伸ばすことはできません。 そのため、認知向上施策を日々実行、検討しております。 その件はいつかまた書き残したいと思います。

2、各メーカー様リモコンボタンについて

皆さんのお持ちの端末にも、動画配信アプリへダイレクトにアクセスできるボタンがついている場合もあるのではないでしょうか。 いつからそう言った取り組みが始まったのかと調べたところ、 どうやら、日本では、2015年から取り組まれているようです。 toyokeizai.net

テレビリモコンに専用ボタンを設置した先駆けはアメリカのネットフリックスだ。同社は2011年、北米市場で発売されたスマートテレビなどのリモコンに自社ボタンの設置を開始。日本市場向けにも、日本でのサービス開始前の2015年2月から、東芝が販売する一部機種が早くも「ネットフリックスボタン」に対応した。

TVerでは、9社のデバイスメーカーさんの端末にボタンを掲載していただいています。(2023年12月現在) マーケティング活動として、家にいつも置いてあるリモコンに掲載されることで、テレビで利用できるという認知、気付いた時にすぐ起動できることで、TVCMや、SNS広告などをみた後すぐ視聴に繋げられることがメリットです。 リテンションとして、すでにTVerを利用していただいている方には、 より、簡単にTVerを利用いただけるようになるといったメリットがあります。

リモコンボタンへの掲載について、今後も推進していく所存でございますので、皆さんのお家にもTVerのロゴがついたリモコンボタンが来る日が来るかもしれません。 ぜひその際は、たくさんPushして、TVerをお楽しみいただけると嬉しいです。

6、おわりに

簡単にコネクテッドTVのビジネス領域の話をしてきました。 最後に少しだけ、展望について書いてみたいと思います。

自分自身の思いとして、 自分の仕事を通して、エンドユーザーに、 感動体験を届けることを仕事の軸にしています。 TVerでもたくさんの感動をユーザーに届けられるよう仕事に取り組んでいます。 UIUXの改善、リモコンボタンからのダイレクト起動、作品との出会い、など TVerのコネクテッドTV体験を今よりもよく、 そして、テレビを解放するために、日々邁進してまいります。 ぜひ、コネクテッドTVの領域にも注目してみてください!

ちなみに採用も頑張っています、ご興味ある方はぜひ! カジュアル面談しましょう! herp.careers

以上です。長々と稚拙な文章にお付き合いいただきありがとうございました。 また、別の話題でどこかで書いてみたいと思います。 引き続き、TVerを宜しくお願いします!

URL_PARSE 再発明

日々、データ分析をしている森藤です。遅くなってしまいすみません。本記事は TVer アドベントカレンダー 17日目の記事です。 (10日の記事も今度書きます)

qiita.com


TVer のデータを分析の中で大きな割合を占めるものにユーザジャーニーの分析や外部からの流入の分析があります。 これらはどちらも URL の解析が必要になるのですが、 URL はだいたいにおいて Google Analytics の utm パラメタや hash の値が乗っており、 Facebook などは fbclid みたいなのが乗ったりと、これらを削除する作業が必要になります。

具体的には内部の回遊としては、

  • https://tver.jp/episodes/XXXXXX?utm_campaign=C1&utm_medium=M2&utm_source=S3#V1

などのログがあったり、

  • https://example.jp:8080/dir1/dir2/index.hml?param1=val1&param2=val2#hash_value

など外部からの流入があります。

これを BigQuery でパースする際、 BigQuery にも プリイン関数 があるのですが、いまいちリッチではなく、 HOST くらいしかないのです・・・

他にもコミュニティベースの URL_PARSE もあるのですがこちらも、いまいち使い勝手が良くないんですよね

SELECT 
    bqutil.fn.url_parse(url, "QUERY") AS query,
FROM
    UNNEST([
      url
    ]) AS url

僕は「クエリパラメタ」も欲しいんです! というか JavaScriptnew URL() で手に入るオブジェクトくらい全部が欲しいのです!

developer.mozilla.org

JS URL で手に入る結果
JS URL で手に入る結果

顧客が本当にほしかったもの

理想的にはこちらがほしいです

SELECT 
   url,
   URL_PARSE(url).protocol,
   URL_PARSE(url).host,
   URL_PARSE(url).port,
   URL_PARSE(url).pathname,
   URL_PARSE(url).search,
FROM
    UNNEST([
      url
    ]) AS url

まとめ

これを使うと MDN と同じように URL の好きなところを切り出したり取り出すことができます。 searchParam もとれますので、

(SELECT param.value FROM UNNEST(URL_PARSE(url).searchParam) AS param WHERE param.key = "utm_source") 

として取り出したりもできます! もちろん、コミュニティ UDF を使っても取り出せますよ! get_value

bqutil.fn.get_value("utm_source", URL_PARSE(url).searchParam)

URL の処理を楽しんでやっていきましょう!

実際のクエリ

作成したクエリ

以下のようなクエリを作成しました。コミュニティに投げ込みます。 ちょっと正規表現が鼻につく感じは自覚しているのですが、テストケースは全部通っています

CREATE TEMP FUNCTION url_parse(url STRING)
AS (
    STRUCT(
        REGEXP_EXTRACT(url, r"^([^:]+:)") AS protocol,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://([^@/:]+):[^@/]+@"),
            NULL
        ) AS username,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:([^@/]+)@"),
            NULL
        ) AS password,
        CONCAT(
            IF(
                REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@([^:/?#]+)"),
                REGEXP_EXTRACT(url, r"^[^:]+://([^:/?#]+)")
            ),
            IFNULL(IF(
                REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@[^/?#]+(:\d+)"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^/?#]+(:\d+)")
            ), "")
        ) AS host,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@([^:/?#]+)"),
            REGEXP_EXTRACT(url, r"^[^:]+://([^:/?#]+)")
        ) AS hostname,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@[^/?#]+:(\d+)"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^/?#]+:(\d+)")
        ) AS port,
        url AS href,
        REGEXP_EXTRACT(REGEXP_REPLACE(url, r"^[^:]+://([^@/:]+:[^@/]+@)", REGEXP_EXTRACT(url, r"^[^:]+://")), r"^([^:]+://[^/?#]+)") AS origin,
        IFNULL(NULLIF(REGEXP_EXTRACT(url, r"^[^:]+://[^/]+(/[^?#]*)"), ""), "/") AS pathname,
        REGEXP_EXTRACT(url, r"[^?#]\?([^#]+)") AS search,
        (SELECT ARRAY_AGG(STRUCT(
            SPLIT(param, "=")[SAFE_OFFSET(0)] AS key, 
            SPLIT(param, "=")[SAFE_OFFSET(1)] AS value
        )) FROM UNNEST(SPLIT(REGEXP_EXTRACT(url, r"[^?#]\?([^#]+)"), "&")) AS param) AS searchParam,
        REGEXP_EXTRACT(url, r"[^?#]\#(.+)") AS hashval
    )
);

テストケースも合わせて掲載しておきます

WITH 
test_data AS (
    SELECT 
        FORMAT("%s//%s%s%s%s%s%s", protocol, basicauth, hostname, port, pathname, search, hashval) AS url,
        protocol,
        basicauth,
        hostname,
        port,
        pathname,
        search,
        hashval
    FROM
        UNNEST(["tverapp:", "https:"]) AS protocol
    INNER JOIN
        UNNEST(["", "hoge:fuga@"]) AS basicauth
    ON
        TRUE
    INNER JOIN
        UNNEST(["example.com"]) AS hostname
    ON
        TRUE
    INNER JOIN
        UNNEST(["", ":8080"]) AS port
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "/", "/index.html", "/dir1", "/dir1/", "/dir1/index.html"]) AS pathname
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "?param1=val1"]) AS search
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "#hash"]) AS hashval
    ON
        TRUE
)

New Relic Change Trackingを使ってアプリケーションのパフォーマンスが変化した要因を特定しやすくする

TVer広告事業本部の髙品です。 こちらはTVer Advent Calendar 2023の21日目の記事です。

本記事では、New RelicのChange Trackingという機能について書きたいと思います。

本記事を書く背景

Change Trackingを説明する前に、本記事を書く背景をお話させてください。

私は、2023年11月にTVer広告事業本部のエンジニアチームに参加しました。広告事業本部のエンジニアチームは、主に「TVer」で配信される広告プロダクト「TVer広告」の配信システム・広告周辺領域のシステムを開発・保守しています。TVerのエンジニア組織に関心がある方は、弊社採用サイトのプロダクト職向けのページをご覧ください。

さて、入社したばかりの私は現在オンボーディングの最中でして、広告のドメイン用語や既存システムの構成をやっと覚えたばかりなので、業務を絡めた記事を書くことができない状況です。そんな中で、アドベントカレンダーに参加するために捻り出したネタが、いまチームで導入を進めているNew RelicのChange Trackingでした。

New Relicを使って私が目指すチームのあり方

私はこれまでインフラエンジニア、SREとして働いてきたので、システムの運用が比較的得意です。今までの経験を活かしてチームに貢献したいと考えたときに、システムの信頼性と開発の生産性を共に最適化できるチームをつくりたい、と考えました。そのために取り組みたいことの一つに、チームがオブザーバビリティツールを使いこなせるようにする、というものがあります。

SREやDevOpsに関心があるチームでは、何らかのオブザーバビリティツールを利用していると思いますが、私が所属するチームはNew Relicを使っています。New Relicは高機能なツールですが、所属チームではまだ一部の機能しか使えておらず、活用しているとは言えない状況です。また、普段からNew Relicを見ているメンバーは数名に限られています。

私は、信頼性と生産性を共に最適化できるチームにおいて、システムの状態はメンバー全員の関心事であるべきだと考えているので、チームの全員にNew Relicの画面を見てほしいと思っています。そこで、チームでNew Relicのダッシュボードを見て、アプリケーションのレイテンシースループット、エラー率といったパフォーマンス指標の傾向を知ることでシステムの状態を把握する定点観測の時間を作ろうとしています。いずれは、SREのサービスレベルの考え方をチームのシステム運用に導入することを見据えての活動です。

前置きが長くなりましたが、ダッシュボードを見てシステムの状態を把握しようと思うときに便利な機能が、New RelicのChange Trackingです。ここからは、Change Trackingの便利な点と、この機能の導入手順を紹介します。手軽に導入できるので、New Relicのユーザーにはぜひ試してほしい機能です。

Change Trackingの便利な点

まず、Change Trackingの便利な点を一言で述べると、アプリケーションの変更(アプリケーションのデプロイイベント)をグラフ上に表示することで、変更によるパフォーマンスへの影響があったかどうか分かりやすくなる、というものです。

あとで導入手順を紹介する際にキャプチャを貼りますが、New Relicのブログの説明を読んでいただくと、この機能について理解し易いと思います。リンク先の画像を見るとResponse time(ms)とThroughputのグラフの上に縦方向の点線が入っていますが、この時点でシステムに変更が加えられたことを意味しています。もし、ある点線を境にグラフに変化が生じた場合、その点線が指している変更と関連性があることがすぐに分かります。

とてもシンプルな機能ですが、これだけでグラフから読み取れる情報量が増えます。グラフを集めたダッシュボードを眺めていると、パフォーマンスは安定していることもあれば、悪くなったり良くなったりすることもあるでしょう。特に悪化した場合は原因を調査する必要がありますが、単にグラフがそこにあるだけでは、ある時点から悪くなった、ということしか読み取れません。そこから先は、アプリケーションのソースコードが置かれたリポジトリや、ログ・メトリクスなどが保存された場所などを見て回って、パフォーマンス悪化要因となるイベントがないか探すことになります。ところが、グラフ上にアプリケーションの変更も一緒に記録されていると、New Relicの画面からすぐに関連のあるイベントを特定することができます。

New Relicにアプリケーションの変更を記録するときはアプリケーションのバージョンの情報を送る必要があります。一般的にはコミットハッシュやタグがバージョン情報として使用されます。こちらのNew Relicのドキュメントに載っている1つ目のキャプチャのように、点線にマウスオーバーするとアプリケーションのバージョンが表示されるため、パフォーマンスを変化させた可能性がある変更がすぐに分かります。Change Trackingを使用することで、パフォーマンス悪化の要因を特定して修復するまでにかかる時間(MTTR)を短縮できる可能性があります。

Change Trackingの導入手順

基本的にはこちらのNew Relicのドキュメントに記載の手順に従えば、難なく導入できると思います。特にJenkinsもしくはGitHub Actionsを使用している場合は、New Relic社が提供しているpluginやactionを追加してパラメータを渡すだけで使うことができます。

本記事では、NerdGraph(GraphQL)APIを使用するユーザーに参考になる手順として、Bitbucket pipelinesからChange Trackingを使う方法を紹介したいと思います。紹介する手順は、いくつかの環境変数を除けばCIツールに依存しない方法です。

さて、NerdGraph APIにデータを送信するためにHTTPクライアントを準備する必要がありますが、多くの環境で追加インストールが不要であると思われるcurlコマンドを使います。New RelicのAPIエンドポイントにJSONをPOSTする必要があるので、まずはcurlコマンドを組み立てていきます。

ここからはcurlコマンドを組み立てる手順です。まず、New Relicにログインして、画面左のアイコン群から虫眼鏡を選んで、検索窓に「Apps」と入力して選択してください。Your appsと表示されているアプリの中に「NerdGraph API Explorer」があるので、選択して開いてください。私はこのアプリにスターをつけているので、Favorite appsに移動しています。

NerdGraph API Explorerを探す

するとGraphQLのクエリを組み立てる画面が開くので、mutationの一覧から「changeTracking」を探して「changeTrackingCreateDeployment」にチェックを入れてください。

changeTrackingCreateDeploymentにチェックを入れる

続いて、以下のキャプチャと同じようにチェックを入れてください。オプション属性ですが、本記事ではdeepLinkにもチェックを入れています。必須属性はentityGuidversionです。指定可能な属性についてはこちらのNew Relicのドキュメントを参照してください。キャプチャではtestと入力していますが、実際のentityGuidはNew Relicにおいてアプリケーションを特定するために振られた一意のID,versionはコミットハッシュ、タグ等が入ります。

mutationに属性を追加する

画面左上の「tools」を押して、「Copy as CURL」を押すとクリップボードに以下のようなコマンドがコピーされます。

curlコマンドを生成する

curl https://api.newrelic.com/graphql \
  -H 'Content-Type: application/json' \
  -H 'API-Key: your USER key' \
  --data-binary '{"query":"mutation {\n  changeTrackingCreateDeployment(\n    deployment: {entityGuid: \"test\", version: \"test\", deepLink: \"test\"}\n  ) {\n    entityGuid\n    version\n    deepLink\n  }\n}", "variables":""}'

API-Keyヘッダーの値、entityGuidversionに適切な値が入力された状態でコマンドを実行すると、entityGuidに対応するNew Relic APM, Browserのアプリケーションのグラフ上に点線が引かれたはずです。このようにNerdGraph API Explorercurlコマンドを生成すると楽ですから、使ってみてください。

ところで、Bitbucket pipelinesのようなCIツールでこのcurlコマンドを実行するときは、各属性値はパラメータにしておかないと使い物になりません。Bitbucket pipelinesで使いやすいようにコマンドを以下のように修正します。(ついでにシェルスクリプトファイルにします)

#!/usr/bin/env bash
set -euo pipefail
curl -X POST https://api.newrelic.com/graphql \
     -H "Content-Type: application/json" \
     -H "API-Key: $NEW_RELIC_USER_KEY" \
     --data-binary '{"query":"mutation {\n changeTrackingCreateDeployment(deployment: {version: \"'"$BITBUCKET_COMMIT"'\", entityGuid: \"'"$NEW_RELIC_ENTITY_GUID"'\", deepLink: \"'"$BITBUCKET_GIT_HTTP_ORIGIN"/commits/"$BITBUCKET_COMMIT"'\"}) {\n deploymentId\n entityGuid\n deepLink\n }\n}"}'

BITBUCKET_のprefixを持つ変数は、Bitbucket pipelines固有の変数です。使用可能な変数はBitbucketのこちらのドキュメントを参照してください。他のツール使う場合は、適当に書き替えてください。

$NEW_RELIC_USER_KEYはBitbucket pipelinesのリポジトリ変数やデプロイ変数に、保護された変数としてセットしておきます。機密情報なので扱いに注意してください。

$BITBUCKET_COMMITはCI(パイプライン)を起動したブランチのコミットハッシュが入ります。

$NEW_RELIC_ENTITY_GUIDも、$NEW_RELIC_USER_KEYと同様にします。

$BITBUCKET_GIT_HTTP_ORIGINには、リポジトリのURLが入ります。 $BITBUCKET_GIT_HTTP_ORIGIN+commits+$BITBUCKET_COMMITを連結することで、コミットに含まれる内容が記されたURLを作成することができます。URLを動的に生成してdeepLinkの値に格納することで、New Relicの画面からリポジトリに移動しやすくします。

変数展開についても補足しておきます。シングルクォート内ではすべての文字が特別な意味を持たなくなるため、"$BITBUCKET_COMMIT"と書くと変数展開が行われません。そのため、本記事では環境変数をシングルクォートで囲うことで変数展開されるようにしています。上記のJSON程度の長さであれば、個人的にはこの方法で構わないと感じますが、より複雑な構造のJSONをPOSTするときは他の書き方にしないと、可読性が悪くなると思います*1

もう一つ、tipsを書いておきます。curlコマンドを、CIの定義ファイル内の別々の箇所に繰り返し書くのは筋が悪いです。コマンドを修正するときに、修正漏れしてしまう懸念があります。本記事では、処理を共通化するためにシェルスクリプトファイルに書いておき、各環境のCIを実行するときにsourceコマンドで呼び出すようにします。例えばBitbucket pipelinesでは以下のように記述できます。

- step:
    name: Send deployment event to NewRelic ChangeTracking
    deployment: develop
    script:
      - source ./scripts/newrelic_change_tracking.sh
- step:
    name: Send deployment event to NewRelic ChangeTracking
    deployment: production
    script:
      - source ./scripts/newrelic_change_tracking.sh

deployment: envの記述により、事前定義されたデプロイ変数が読み込まれます。デプロイ変数は、CIを実行するときに環境固有のパラメータを参照したいケース等で便利です*2。本記事の文脈では、$NEW_RELIC_USER_KEY$NEW_RELIC_ENTITY_GUIDは環境固有の値なので、これらをデプロイ変数としてセットしておくと、処理そのものはシェルスクリプトファイルに書いて共通化しながら、スクリプトの実行時に環境固有の値を参照することができます。

以上を設定してBitbucket pipelinesでCIを実行すると、以下のようにNew Relicに変更が記録されます。

NewRelic APMの画面

おわりに

いかがでしょうか。Change Trackingが便利であることを伝えきるには情報が不足しているかもしれませんが、簡単に導入できることは伝わったと思いますので、試していただけたら幸いです。

最後に、個人的な決意表明ですが、所属チームにSREやDevOpsの文化・考え方を浸透させるために、New Relicを活用してきたいと思います。

Xcode Cloud 触ってみた

本記事はTVer Advent Calendar 2023の19日目の記事です。

はじめに

こんにちは、TVeriOSアプリ開発を担当しています小森です。
Xcode Cloudの発表からしばらく経ちましたが、 CI/CDサービスを検討するに当たってXcode Cloudを初めて触ってみましたので、
本記事でXcode Cloudについてのセットアップ方法と、触ってみた感想をまとめたいと思います。
Xcode Cloudを検討されている方の参考になれば幸いです。

Xcode Cloudとは

Xcode CloueはAppleが提供するAppleプラットフォームのためのCI/CDサービスです。

developer.apple.com

利用手順について

XcodeのReport NavigatorのCloudタブからWorkflowの作成をして実行します。

Workflowについて

下記に簡易的なWorkflowの設定についてまとめます。 詳細情報は公式ドキュメントを参照してください。

developer.apple.com

Workflow 編集画面

General

ここではWorkflow名、ワークフロー説明文、リポジトリ、対象のProject or Workspaceの設定を行います。

Environment

ここでは実行するXcode VersionとmacOS Versionの選択と環境変数の設定を行います。 事前定義されている環境変数はこちらを参照してください。

developer.apple.com

Start Conditions

Workflowの起動トリガーの設定を行います。 他のCI/CDサービスと同じようにブランチの変更、プルリクエストの変更、タグの変更、スケジュール実行などに対応しています。

Actions

ActionはBuild, Test, Analyze, Archiveの4つから追加できます。 各Actionは並列実行することが可能です。

Post-Actions

Post-ActionsはTestFlight External Testing, TestFlight Internal Testing, Notarize, Notifyの4つから追加できます。 NotifyはSlackとEmailに対応しています。

触ったみた個人的感想

  • とにかくシンプルで楽

Xcodeに組み込まれている機能ということもあり、セットアップはとても楽で簡単でした。
また、証明書やProvisioningProfileなどの管理が自動で対応されるのでfastlane matchを導入してごにょごにょなどする必要がありません。
そして、Test FlightやAppStoreConnectの連携も簡単にできます。
AppStoreConnectからWorkflowの実行も可能なので開発者以外も容易にWorkflow実行が可能なのは魅力的です。

  • メンテナンスしにくいかも

ほかのCI/CDサービスだとWorkflowの設定をyamlファイルなどで記述し管理することができますが、 Xcode Cloudの場合は、XcodeもしくはAppStoreConnectからのGUIでの編集かApp Store Connect API経由での編集のみになります。
チームでメンテナンスしていくためにApp Store Connect APIも用意していると公式ドキュメントには記載されていますが ソースコードでWorkflowの設定を管理することが可能な他のCI/CDサービスと比較するとちょっとメンテナンスしにくそうだなという印象を持ちました。

developer.apple.com

  • キャッシュ機能がイマイチ

Deriverd Data配下のみがキャッシュ対象でSwift Package Managerを使用してインストールしたライブラリのみキャッシュされるみたいです。
サードパーティ製のツールやライブラリを導入する場合は、Xcode Cloudが提供しているHomebrewを使用し、 任意のタイミングで実行されるcustom build scriptsを使用してインストールする必要があります。
そのインストールしたツールやライブラリをキャッシュすることはできないので毎回インストールしなければならずイマイチだなという印象を持ちました。
ただ、Git LFSをサポートしてるので、CocoaPodsなどに関してはPodsディレクトリをGit管理下に置くアプローチを採用していればキャッシュ意識する必要がないので問題なく運用可能だと思います。

まとめ

今回はXcode Cloudの簡易的なセットアップ方法と触ってみた個人的感想を述べた記事でした。 個人開発をする上ではXcode Cloud一択で十分ではないかと思いますが、他のCI/CDサービスと比較するとメンテナンス性やキャッシュ機能などが少しネックかなと考えております。
ただ、料金に関しては比較的安く機能に関してもシンプルで使いやすい印象を受けたのでデメリットを補う案を模索しながら引き続き採用検討を進めたいと思いました。

SnapHelperがどうやってSnappingを実現しているのか

本記事は TVer Advent Calendar 202320日目の記事です。

はじめに

こんにちは、TVerAndroidアプリ開発をしています石井です。

AndroidViewでコンテンツの一覧などを表示する際にRecyclerViewがよく使われると思いますが、カルーセルのようなUIにするためにはどうすれば良いでしょうか。

一般的にはRecyclerViewにLinearSnapHelperをアタッチすることで、カルーセルのようにコンテンツを中央寄せさせるUIを作ることが可能です。
ただし、LinearSnapHelperはあくまでも中央へのSnappingしか提供していないため、左寄せや右寄せなど中央以外にしたい場合は独自にSnapHelperを継承した実装をしなければなりません。独自に実装するとして、各関数がSnappingに対してどのような役割を担っているか理解が必要です。

では、どのようにしてこのSnappingは実現しているのでしょうか。

今回はLinearSnapHelperを例にしつつ、SnapHelperがどのようにしてSnappingを実現しているのかを深ぼっていこうと思います。

LinearSnapHelperを用いた中央寄せ

前提にはなりますが、そもそもSnappingがどういった挙動なのか、どう実装すればSnappingができるのかを簡単に見てみましょう。

今回はよくある横スクロールで挙動を確認していきたいので、横スクロールのRecyclerViewを作成します。

val layoutManager = LinearLayoutManager(
    requireContext(),
    LinearLayoutManager.HORIZONTAL,
    false,
)
recyclerView.layoutManager = layoutManager

それではLinearSnapHelperでSnappingを実装していきます。

val snapHelper = LinearSnapHelper()
snapHelper.attatchToRecyclerView(recyclerView)

これで簡単に中央にSnappingするRecyclerViewが完成しました。

Snappingの仕組み

では、どのようにSnappingを実現しているのでしょうか。

SnapHelperには3つの抽象関数があります。

  • calculateDistanceToFinalSnap
  • findSnapView
  • findTargetSnapPosition

特に関連しそうな calculateDistanceToFinalSnapfindTargetSnapPosition にフォーカスして、まずはSnapHelperの実装を見ていきます。

SnapHelper側ではFlingが実行されると snapFromFling が実行されます。

private boolean snapFromFling(
    @NonNull RecyclerView.LayoutManager layoutManager,
    int velocityX,
    int velocityY
) {
    // 省略
    int targetPosition = findTargetSnapPosition(
        layoutManager,
        velocityX,
        velocityY
    );
    if (targetPosition == RecyclerView.NO_POSITION) {
        return false;
    }

    smoothScroller.setTargetPosition(targetPosition);
    layoutManager.startSmoothScroll(smoothScroller);
    return true;
}

findTargetSnapPosition で計算されたpositionの位置まで startSmoothScroll でスクロールが実行されていくのが分かります。また smoothScroller に対象の位置をsetしているのもわかります。

次に calculateDistanceToFinalSnap の方を見ていきましょう。

@Nullable
@Deprecated
protected LinearSmoothScroller createSnapScroller(
    @NonNull RecyclerView.LayoutManager layoutManager
) {
    if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
        return null;
    }
    return new LinearSmoothScroller(mRecyclerView.getContext()) {
        @Override
        protected void onTargetFound(
            View targetView,
            RecyclerView.State state,
            Action action
        ) {
            if (mRecyclerView == null) {
                // The associated RecyclerView has been removed so there is no action to take.
                return;
            }
            int[] snapDistances = calculateDistanceToFinalSnap(
                mRecyclerView.getLayoutManager(),
                targetView
            );
            final int dx = snapDistances[0];
            final int dy = snapDistances[1];
            final int time = calculateTimeForDeceleration(
                Math.max(Math.abs(dx), Math.abs(dy))
            );
            if (time > 0) {
                action.update(dx, dy, time, mDecelerateInterpolator);
            }
        }

        @Override
        protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
            return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
        }
    };
}

先ほどsetした対象の位置に対して onTargetFound が実行され、 calculateDistanceToFinalSnap で計算された距離分だけ動いていくことがわかります。

まとめると以下のことがわかります。

  • findTargetSnapPosition で対象となるViewの位置を計算
  • 計算した位置までスクロール
  • 対象となるViewが画面上に出てきたら onTargetFound が実行される
  • 微調整を行なってSnappingを実現させる

LinearSnapHelperの中央寄せ

では最後にLinearSnapHelperの実装を見て、どのように中央へSnappingをしているかを見てみます。

@Override
public int[] calculateDistanceToFinalSnap(
    @NonNull RecyclerView.LayoutManager layoutManager,
    @NonNull View targetView
) {
    int[] out = new int[2];
    if (layoutManager.canScrollHorizontally()) {
        out[0] = distanceToCenter(
            targetView,
            getHorizontalHelper(layoutManager)
        );
    } else {
        out[0] = 0;
    }

    if (layoutManager.canScrollVertically()) {
        out[1] = distanceToCenter(
            targetView,
            getVerticalHelper(layoutManager)
        );
    } else {
        out[1] = 0;
    }
    return out;
}

private int distanceToCenter(@NonNull View targetView, OrientationHelper helper) {
    final int childCenter = helper.getDecoratedStart(targetView)
            + (helper.getDecoratedMeasurement(targetView) / 2);
    final int containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
    return childCenter - containerCenter;
}

distanceToCenter で対象となるViewの中央とコンテナの中央間の距離が計算されます。

findTargetSnapPosition の方はコード量が多いので割愛しますが、スクロールした時のスクロール量とViewの大きさをもとに最も中心に表示できるViewの位置を返すロジックになっていました。

改めてSnapHelper含め、LinearSnapHelperを振り返ると以下のようになります。

  • findTargetSnapPosition でスクロール量から対象となるViewを事前計算
  • 対象のViewまで startSmoothScroll を使ってスクロール
  • 対象となるViewが画面上に出てきたら onTargetFound が実行される
  • calculateDistanceToFinalSnap で対象のViewの中心とコンテナの中心間の距離を計算
  • その分移動させることで、Viewの中心とコンテナの中心が重なり中央へのSnappingが実現できる

まとめ

今回はSnappingの挙動からSnapHelperのSnappingに関わる部分を掻い摘んでざっくりと解説させていただきました。Snapping自体は珍しい挙動ではなく様々なサービスで利用されています。 SnapHelperの挙動について理解を深めておくことで多様なSnappingの実装が簡単にできるようになりますし、SnapHelperに依存せず同様な処理を実装できるのではないかと思います。