dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

1px の変化も見逃さない!ビジュアルリグレッションテスト導入で快適フロントエンド開発

こんにちは!dely でフロントエンドの開発をしています @all__user です。
今回は kurashiru のフロントエンド開発に導入されたビジュアルリグレッションテストについてご紹介したいと思います。

【反応を多くいただいた点について記事の最後に追記しました】

目次

ビジュアルリグレッションテストとは

ある変更を加える前後でスクリーンショットを作成し、それらを比較することで意図しない挙動が無いかを検証するテスト手法です。
最終的に描画されたピクセルに少しでも変化があれば検出することができるため、技術スタックを選ばず包括的にテストできます。
検出できるものは限られていますが、スタイルのチェックはもちろん、機能や動作を検証するためのテストとしても非常に優れています。

  • 検出できるもの⭕
    • お気に入りボタンが表示されない
    • 2ページ目以降がエラーページになる
    • 画像が荒い
  • 検出できないもの❌(検証に向かないもの)
    • お気に入りボタンがクリックできない
    • 2ページ目以降へ遷移できない
    • 画像のalt属性が設定されていない

現在の kurashiru のように Rails のテンプレートと Vue を併用しているような状況でも、全体をカバーできます。技術スタックの移行フェーズでは特に効果を発揮すると思います。
テストはどこにどれだけのコストを割くかというバランスが非常に難しいと感じます。
まず最初に導入するテストとしてビジュアルリグレッションテストはとてもおすすめです。

導入の背景

kurashiru は Rails アプリケーションとして Sprockets + Slim + jQuery + CoffeeScript + SCSS で開発されてきました。
ログイン機能の開発をきっかけに部分的に SPA (Single Page Application) を導入し、現在は Webpacker + Vue + TypeScript + SCSS での開発へと移行中です。

tech.dely.jp

そのようにして SPA 化を進めていく中で、機能開発が優先され、なかなかテストに手を付けられないという状況が続きました。
すでにある Capybara + RSpec によるテストケースだけでは、SPA 部分の開発が既存部分へ与える影響や共通モジュールを変更する際の影響の検知を担保できず、テストケースを増やして対応しようとすると、非常にコストがかかるだろうと考えられました。

なんとかしなければと思いつつ開発は続き、規模が少しづつ大きくなるにつれ、手動テストでの動作検証コストが無視できない大きさになってきました。
そして、今まさにテストが必要というところまで来ていると判断しビジュアルリグレッションテストを導入することにしました。

フロントエンドのテスト?

フロントエンドのテストは難しいです。
一口にテストと言っても何を検査し担保したいのかによってテスト手法もまちまちです。

  • ロジック
  • 操作
  • 要素、テキスト
  • スタイル
  • ブラウザ間の差異

HTMLの構造や見た目に依存したテストは、正しくテストを書く難易度も高く、しかも変更により壊れてしまう可能性も高いため、得られるメリットがコストに見合わないと感じることもあります。

SPA移行前後の比較

今回一番達成したかった目的は、SPA移行前後のコードに対する検証です。
何かしらのテストの必要は感じていましたが、移行前後のコード全てに対してテストを追加するコストはかけられません。

ビジュアルリグレッションテストは期待される動作の定義と検証を簡単に行うことができます。
期待される動作の定義はスナップショットを撮るだけです。
現在の状態を期待される動作として定義し、移行後の状態と比較することで、移行前後のコードの挙動に変化がない(またはある)ことを確認できます。

ツール

調べてみたところ様々なツールがありました。
PhantomJS は開発が終了しているため、それをベースにしたツールは今回選択肢から外すことにしました。
また、マルチブラウザのテストを考慮すると Selenium ベースが望ましかったのですが、学習コストやフロントエンドエコシステムとの相性の観点から Node.js, Puppeteer を採用している BackstopJS を採用することにしました。

reg-suit

スナップショットの比較とレポートの作成に特化したツールです。
スナップショットの生成をどのように行うかは自由なため、Headless Chrome を使ったり Selenium ベースのツールを使うなど柔軟に対応できそうです。
CIにも組み込みやすそうです。

github.com

Loki

Storybook に特化したツールです。
スナップショットの生成からレポートの作成、結果の承認まで全部入りのツールです。
ページ全体のスナップショットを利用するテストとは違い、コンポーネントの単体テストという位置づけです。
kurashiru でも最近 Storybook が導入されたのでいずれ試してみたいです。

github.com

Wraith

Ruby ベースのツールで、GitHubのスターも一番多いようです。
スナップショットの生成からレポートの作成、結果の承認まで全部入りのツールです。
メインは PhantomJS ベースのようですが Chrome にも対応しているようです。
BBC News が作っています。

github.com

BackstopJS

スナップショットの生成からレポートの作成、結果の承認まで全部入りのツールです。
今回はこのツールを採用しました。
Node.js 製でメインのブラウザに Puppeteer を採用しています。
Puppeteer の API をそのまま利用できるので、テストケースを async / await で書くことができます。
hideSelector や removeSelector で特定の要素を非表示にしたり取り除いたりすることができたりと、いい感じのパラメータが多く用意されています。

github.com

テストのフロー

f:id:delyjp:20180802000550p:plain

GitHub + CodeBuild + BackstopJS

CodeBuild の GitHub 連携の機能を利用し、特定のルールにマッチするブランチ名が push された時にテストが走るように設定しました。
CodeBuild ではテストに使用する Docker イメージを指定することができるのですが、BackstopJS が提供している Docker イメージがあり、これを利用しています。
これで CI 環境 で Puppeteer を動かすための手間はかかりません。
一つだけ注意が必要なのは、そのままだと日本語フォントが入っていないため、フォントの入ったイメージを作るか、install フェイズなどでフォントを入れる必要があります。

version: 0.2

phases:
  install:
    commands:
      - apt-get update -y
      - apt-get install -y apt-transport-https
      - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
      - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
      - apt-get install -y yarn
      - apt-get install -y fonts-ipafont-gothic fonts-ipafont-mincho # 日本語フォントをインストール
      - apt-get install -y python-dev
      - curl "https://bootstrap.pypa.io/get-pip.py" | python
      - pip install awscli

  pre_build:
    commands:
      - REGRESSION_TEST_BRANCH_NAME=$(git branch -a --contains $CODEBUILD_SOURCE_VERSION)
      - mkdir -p ./.yarn-cache
      - yarn install --cache-folder ./.yarn-cache

  build:
    commands:
      - yarn reg:testcase-gen # スプレッドシートから backstop.json を生成
      - backstop reference # リファレンスのスナップショット
      - backstop test # テストのスナップショット

  post_build:
    commands:
      - aws s3 cp --recursive backstop_data/html_report s3://xxxxxxxxxxxxxxxx/$CODEBUILD_BUILD_ID/html_report/
      - aws s3 cp --recursive backstop_data/bitmaps_reference s3://xxxxxxxxxxxxxxxx/$CODEBUILD_BUILD_ID/bitmaps_reference/
      - aws s3 cp --recursive backstop_data/bitmaps_test s3://xxxxxxxxxxxxxxxx/$CODEBUILD_BUILD_ID/bitmaps_test/
      - yarn reg:slack # Slackに通知

cache:
  paths:
    - '.yarn-cache'
    - 'node_modules/**/*'

ステージング環境

Reference と Test には kurashiru の開発で利用しているステージング環境を使用しています。
開発者が任意のブランチをデプロイできるようになっています。
データベースの内容によってスクリーンショットに差が出ないように、ビジュアルリグレッションテスト用のインスタンスは同じデータベースを参照するようにしています。

テストケースは Google スプレッドシートで管理

BackstopJS のテストケースは backstop.json というファイルの scenarios プロパティで設定します。

{
  "scenarios": [
    {
      "label": "recipes_show",
      "onBeforeScript": "src/on_before.js",
      "url": "https://example.com/recipes/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "referenceUrl": "https://example.com/recipes/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "delay": 3000,
      "removeSelectors": [
        ".adsbygoogle",
        ".google-ads"
      ],
      "onReadyScript": "src/on_ready.js",
      "selectorExpansion": true,
      "misMatchThreshold": 0.1,
      "requireSameDimensions": true
    }
  ]
}

もちろんこのまま利用してもよいのですが、backstop.json が巨大になるとメンテナンスが大変そうなので、Google スプレッドシートでテストケースを管理することにしました。

f:id:delyjp:20180802000659p:plain

一行が一つのテストケースに対応しており、上記の JSON の各種パラメータを設定できるようにしています。
また、パタメータとは別に自由に設定できるタグ用のカラムを追加しました。
このタグを利用して、特定のテストケースのみを実行したり、どの画面か、管理サイトかなどで色分けできます。

テスト開始前にこのスプレッドシートのデータを取得し backstop.json を生成します。

各テストケースが見やすく編集もしやすくてとても便利なのですが、テストケースが Git の管理下ではなくなるというデメリットもあります。

結果を S3 にアップロードして Slack に通知

BackstopJS はテスト結果を HTML + アセット群というかたちで生成します。
このようにスクリーンショットの比較結果をとても見やすく表示してくれます。

f:id:alluser:20181207182333p:plain

f:id:alluser:20181207182750p:plain

この HTML + アセット群を静的サイト向けにセットアップした S3 にアップロードし、Slack に URL を通知します。

まとめ

SPAへの移行では実際に多くの意図しない挙動を1px単位で見つけることができました。
このタイミングで導入できてとても良かったです。

ビジュアルリグレッションテストは他のテストを代替するようなものではないですが、技術スタックに依存しないことと、テストケースが壊れにくいというところが大きな利点かと思います。
ビジュアルリグレッションテストを導入して快適なフロントエンドライフを送りましょう!

【追記】

多くの方に読んでいただきとても嬉しいです🙌反応をいただいた点について追記しました!

運用が大変ではないか?

現状、リグレッションテストに関しては、ユニットテストやE2Eテストのように、コミット度にテストを回して、テストケースが全てパスしないとマージできない、という運用はしていません。

雰囲気としては、

「今回だいぶ変更箇所多くなったなー、一応リグレッションテスト見ておくか」
「うわ、差分めっちゃ出てる、こりゃよく分からんね...」
「そうすね、今回はざっと見て問題なさそうだったら、新しいほうをリファレンスにしちゃって下さい」
「了解です!大丈夫そうです!」
「マージ!」

こんな感じです。

テスト実行の有無は都度判断し、もやは有益でない差分については無視しても構わないくらいの運用です。
通常のテストとは違いこのくらいの運用でも十分効果があります。

1pxの違いにそこまで工数かける?

上記のような運用方法が前提であれば、そもそもこの点は解消するかもしれません。

一つの例として1pxの線が挙げられると思います。
デザイン上1pxの線が使われている部分はkurashiruにも多くあります。
1pxのズレで大きなデザイン崩れになることは無いかもしれませんが、1pxの線でも、その線が消えてしまうと大きく意味が変わってしまう、ということはよくあると思います。

その線が誤って消えてしまったりすると、

「すいません!ここの線が消えちゃってました!」
「あっ!すいませんすぐ修正しますー(たぶんあの変更だ...)」

のように、そのページを見て気づいた人からの報告ベースでの修正ということはよくあります。(それ自体は良いと思います🙌)
要は、開発者や関係者が手動で目grepで確かめないといけなかった部分を、ある程度置きかえられるので、ページ数にもよりますが、費用対効果としては悪くないと思います。

広告が差し込まれたり変わっただけでテストが壊れるのでは?

これはその通りなのですが、BackstopJSの機能で対応可能です。
テストケースごとに特定のセレクタにマッチした要素をvisibility: hiddenにしたり、display: noneすることができます。
下のリンク先にあるhideSelectorsremoveSelectorsというオプションです。

github.com

他にも色々な機能があるので、ある程度のケースには対応できると思います。

スモールスタートではじめるSSR

こんにちは。delyでフロントエンドを担当している@all__userです。

今回はkurashiruでSSR(Server Side Rendering)を導入した事例についてご紹介したいと思います。

目次

要約

  • SPAにしたい。SEOのことを考えるとSSRはしておきたい
  • でも全然リソース足りない
  • Railsを温存しつつスモールスタートでSSRできるようにした

という内容です。

経緯・背景

kurashiruはもともとRails単体のアプリケーションでしたが、フロントエンドにVue.jsを採用し、現在では多くのページがSPAへと移行しました。
SPAではSEOの観点からSSRが必要であるとよく言われます。
kurashiruでも調査・議論を重ねた結果、SSRを導入することになりました。

SSRの導入

フロントエンドをSPA(Single Page Application)に置き換えていくタイミングでSSRを導入し、結果としては懸念していたSEOへの悪影響も無く1、無事SPAへと移行することができました。

tech.dely.jp

SPAをやるにあたってとにかく一番頭をもたげたのはSSRどうするか問題でした。

SPAのSEO対策

正直に申しますと、個人的には「SSRはあったほうがいいけど、必須ではない」という主張の持ち主でした。
その内容は概ね以下のようなものです。

  • fetch as googleでレンダリングされていれば問題ない
  • 初期レンダリングコストの削減が目的なら導入・運用コストに見合わない

しかし、いくら調べてみても議論を重ねてみても、SEOに悪影響が無いとは言い切れませんでした。
kurashiruにとってSEOが悪化することはわずかな可能性でも避けたいことでしたが、それと同時に不要なものを導入して複雑化させるのも避けたいことでした。

そんな中、Google I/O '18の中でUser-Agentによってクローラーを識別し、クローラーに対してのみSSRした結果を返すダイナミックレンダリングという手法が紹介されていました。

www.youtube.com

www.suzukikenichi.com

このダイナミックレンダリングの説明の中で、GooglebotによるJavaScriptレンダリングは、現時点で完全ではないことが明示されています。
また、ダイナミックレンダリングがクローキングには当たらないことを示す内容でもありました。

developers.google.com

GooglebotがSPAを完全にレンダリングできない可能性がある以上、SPAへの移行はSSRを前提に考える必要がありました。

SSRのコスト

一般的なSSRの構成は、ユーザーからのリクエストをSSRサーバーで受け、APIサーバーに必要なデータを問い合わせ、ページをレンダリングし、その結果を返すというものです。

一般的なSSRの構成で考えた場合

この構成を採用する場合、以下のような懸念点がありました。

  • metaタグの仕組み(title、description、OGPなど)をRailsからSSRサーバーに移行する必要がある
  • 全ユーザーを対象とする場合はスケールやキャッシュの仕組みなどをNode.jsサーバー用に構成し直す必要がある
  • 非SPAのページと共存する場合、ALBなどの前段で振り分ける必要がある
  • Node.jsサーバーの面倒は誰が見るの問題

kurashiruは単体のRailsアプリケーションとしてすでに十分大きかったため、SSRサーバーにフロントの機能を移すだけで大きなコストがかかります。
社内にNode.jsやSSRの経験が豊富にあれば、この構成を採用する可能性もありましたが、私自身にNode.jsの実戦経験は無かったですし、他のメンバーの技術スタックやリソースを考えても、この構成への移行コストは高く、その時点では採用するメリットはありませんでした。
仮にリソースを割いて移行できたとしても、「もしダメでもやめればいい」という判断がしにくくなる可能性がありました。

Rendertronの採用は見送り

ダイナミックレンダリングの手法として紹介されているうちのひとつが、Rendertronを使った方法です。

github.com

話し合いの中で以下の懸念点が出てきました。

  • キャッシュ効率が高いことが前提となっている
  • クローラーが同じURLを再訪したときしかキャッシュヒットしない
  • 同じURLを再訪する頻度が少なそう
  • だとすると、マシンパワーが必要なためレスポンスタイムが心配
  • お金がかかりそう
  • あくまでクローラーに対してのレンダリング用で、ユーザーにも転用する未来が見えにくい

これらの懸念からRendertronの採用を見送りました。

kurashiruのSSR構成

大枠はRendertronによるダイナミックレンダリングの構成と同じです。
この構成にすることで、既存のRails資産を流用しつつ、部分的にSSRを導入できます。

kurashiruで採用したSSRの構成

  1. Webサーバーがリクエストを受けると、User-Agentからクローラーかどうかを判別し、クローラーの場合はSSRに必要な情報(リクエストパスなど)をJSONにまとめてSSRサーバー(Node.js)にPOSTします。
  2. SSRサーバーがPOSTリクエストを受け取ると、ページのレンダリングに必要なデータをAPIから取得します。
  3. SSRサーバーでデータの取得が完了すると、vue-server-rendererを使用してクライアントサイドと同じコードを実行し、body要素直下にマウントされるルートコンポーネントをレンダリングします。
  4. SSRサーバーでレンダリングが完了すると、レンダリング済みのHTML文字列をJSONにまとめ、Webサーバーに返します。
  5. Webサーバーがレンダリング済みのHTMLを受け取ると、body直下にそのHTMLを埋め込み、完成したHTMLをブラウザに返します。

ルートメタフィールドを利用したデータ取得の仕組み

クライアントサイドと同じように、レンダリングの過程でAPIサーバーから必要なデータをフェッチしますが、クライアントサイドと異なる点として、レンダリングを1ライフサイクル(ルートコンポーネントのbeforeRouteEnter, beforeCreate, created)内で同期的に行う必要があります。
そのため、レンダリングを始める前にすべてのデータのフェッチが完了している必要があります。

この問題を解決するために、例えばNuxt.jsではasyncDataというメソッドを使用することで、非同期のデータ取得をあらかじめ完了させておくことができます。

ja.nuxtjs.org

kurashiruではNuxt.jsを導入していなかったため、同様の仕組みを用意する必要がありました。
コンポーネントのメソッドを利用する代わりに、ルートメタフィールドを利用しています。

router.vuejs.org

簡略化したコードを以下に示します。

// ComponentA.vue
export const generateSsrFetcher = ({ app }: { app: Vue }) => {
  return {
    fetchEndpointA() {
      return app.$api.fetchEndPointA({ id: app.$route.params.id });
    },
  };
};

export default Vue.extend({ /* ... */ });

// routes.ts
const routes = [
  {
    path: '/component_a/:id',
    name: 'component_a',
    component: () => import('./path/to/ComponentA.vue'),
    meta: {
      async getSsrMetadata() {
        const { generateSsrFetcher } = await import('./path/to/ComponentA.vue');
        return { generateSsrFetcher };
      },
    },
  },
];

// renderApp.ts
async () => {
  // ...
  const app = createApp();
  app.$router.push({ path: '/component_a/123' });
  const metadata = await app.$route.meta.getSsrMetadata();
  const ssrFetcher = metadata.generateSsrFetcher({ app });
  const data = await ssrFetcher.fetchEndPointA();
  // ...
}

Nuxt.jsのasyncDataではthisによるVueインスタンスの参照ができないという制約があります。
レンダリング前に実行されるメソッドということを考えると自然なことですが、this.$route.paramsを利用できないなどの不便な点もあります。
これらを緩和するためにAPIからデータを取得する際、ルートインスタンスを参照できるようにしています。2

消極的SSRから積極的SSRへ

SSRの導入に関しては、SEO対策だけでなく、初期レンダリングコスト削減による体感速度の向上など、UX改善という側面もあります。
kurashiruではまだこのようなモチベーションでSSRに取り組めていないので、消極的SSRと呼んでいたりします。

Node.jsを運用する知見が少しづつ溜まってきたこともあり、全てのリクエストに対してSSRするなど、より積極的な導入も考えています。
このあたりは今後の課題です。

まとめ

SPAへの移行を機にSSRを導入した事例についてご紹介しました。
SPA+SSRを同時に導入するということは、大きな変更を2つ同時に行わなければならず、単純明快なメリットが無いと特に採用ハードルが高くなると思います。
この記事で紹介したように、既存資産を流用しながら検証を行うことができれば、SPA+SSRは最良の選択になるかもしれません。

最後までお読みいただきありがとうございました。
SPA化に関しては下記の記事でも紹介していますので、ぜひご覧ください!

tech.dely.jp


  1. 「SPAはSEOに良い影響があった」という意味ではないので注意が必要です。

  2. Vue.js 2.6で入ったserverPrefetchでは、thisによるコンポーネント自身のインスタンス参照が可能なので、置き換えを検討しています。

Firebase Test labでiOSアプリのUnitTestとUITestを行う

こんにちは!クラシルのiOSアプリ開発を行っているtakaoです。

今回はiOSアプリのテストの実行に関する内容です。

現在のクラシルのテスト運用は、Bitrise上でfastlaneのrun_testsというActionを使ってテストの定期実行を行っていますが、結果の確認がしやすい状態とは言えません。特にUITestに関しては画面を確認するために、必要な場合はローカルで実行したりしています。
そこで、何やらFirebaseが提供している Firebase Test lab(以下、Test lab) というサービスを使えば、テスト結果をスクリーンショットや動画で確認することが出来るということだったので、試してみました。
この記事ではTest labでiOSアプリのテストを行う方法とTest labを使うとどういう結果が得られるのかをご紹介します。

準備

Firebase プロジェクトを作成する

Test labを使うためにFirebaseプロジェクトの追加を行っておきます。

サンプルアプリの作成

ボタンを押すとアラートが表示されるだけのサンプルアプリを作成しました。

f:id:takaoh717:20190328184920p:plain:w200 f:id:takaoh717:20190401094519p:plain:w200

UnitTestを書く

簡単なテストを書きます。

func testExample() {
       let model = TestLabSampleModel()
       model.updateTitle(new: "test")
       assert(model.titleLabel == "test")
}

UITestを書く

こちらもボタンをタップしてアラートを閉じるだけの簡単なテストを書きます。 UI Recordingを使ってさっと書きました。

func testExample() {
    let app = XCUIApplication()
    app.buttons["Button"].tap()
    app.alerts["(^o^)"].buttons["close"].tap()
}

アプリのXCTestをビルドする

Test labを利用するためにはXCTestのビルドを行う必要があります。 ビルドされたXCTestを使って、UnitTestとUITestを行います。

XCTest Apple公式ドキュメント

① DerivedData格納場所を確認・変更(任意)

公式のドキュメントの手順では「プロジェクトの Derived Data の場所を構成する」が必要だと説明されていますが、こちらの設定は任意です。 Test labを頻繁に利用する場合に、ビルドされたファイルにアクセスしやすいようにするための設定なので、初回の場合やCIなどでテストを自動化する場合は不要です。

② テストファイルをビルドする

Xcode上でXCTestのビルドを行います。
ナビゲーションから【Product】→【Build For】→【Testing】の順に選択します。

③ アップロード用にテストファイルを圧縮する

Test labにアップロードするために、必要なファイルをまとめて圧縮します。
ファイルはDerivedDataの中にありますが、Xcodeから簡単にアクセスできます。
【File】→【Workspace Settings...】の順に進んで、DerivedDataの箇所にある矢印ボタンをクリックすると、Finderが開きます。

f:id:takaoh717:20190328183523p:plain:w200

その中から以下のファイルを選択して圧縮します。

  • Debug-iphoneos
  • PROJECT_NAME_iphoneosDEVELOPMENT_TARGET-arm64.xctestrun

Firebaseコンソールでテストを実行する

Test labのコンソール画面で圧縮したファイルをアップロードします。
アップロードが完了するとテストの設定を選択する画面になります。

端末の選択

端末のバリエーションが豊富で、タブレットを選択することも出来ます。

f:id:takaoh717:20190327193416p:plain:w300f:id:takaoh717:20190327193451p:plain:w300

画面の向きやロケールも選択可能になっているため、横画面に対応しているアプリや多言語対応アプリなどは全部のパターンを手動でテストするのは大変なので嬉しいと思います。

f:id:takaoh717:20190327193612p:plain:w500

利用可能な端末の種類とOSについても随時更新されていくようです。

Available devices in Test Lab

テスト結果を確認する

テスト結果はこのような感じで表示されます。

f:id:takaoh717:20190327193715p:plain:w400 

サンプルアプリだとスクリーンショットの情報が出なかったため、クラシルのアプリで試してみました。 テスト結果は実行した端末・OSごとに確認することが出来ます。

iPhoneSE

f:id:takaoh717:20190328163743p:plain:w400

iPhoneX

f:id:takaoh717:20190328163800p:plain:w400

また、UITestを動画タブで実行している際の動画を確認することが出来ますが、こちらはかなりざっくりしたコマ送り動画のような状態になっており、さらに、全てのテストの結果が一つの動画にまとめられているので、テストの数が多い場合は確認が難しそうです。。。

f:id:takaoh717:20190401101620g:plain:w200

Firebaseコンソール以外での実行方法

Bitriseでテストを実行する

今回は確認していませんが、BitriseのWorkflowを使用すると、簡単にテストの実行が出来るようです。 実行結果もちゃんとBitrise上で確認出来るようになっているので、Bitriseを導入しているプロダクトの場合はこっちを使うのが良さそうです。

Device testing for iOS - Bitrise DevCenter

f:id:takaoh717:20190327192859p:plain
Bitrise画面

gcloud コマンドラインでテストを実行する

こちらも今回は試していませんが、CLI上でテストを実行することも出来るみたいです。 実行結果はFirebaseのコンソール上で確認出来ます。Firebaseのコンソール画面で確認したい場合はCLIを使って定期実行するのが良いかもしれません。

まとめ

Firebaseのサービスはアプリに設定ファイルやSDKを追加しないといけないものが多いですが、Test labに関してはプロジェクトの追加を行うだけで他に特別なことをしなくて良いため、比較的導入がしやすいと思います。 また、端末の種類が充実していたり、画面の向きやロケールなどの設定を変更したりなども容易に出来るのが良かったです。 Bitriseを使えばCI環境でもすぐに導入できると思うので、テスト実行を行うための環境をまだ作っていないプロジェクトなどでは役立つと思います、ぜひお試しください。

開発ブログの大成功をお祝いして、みんなが大好きなアレを食べてきました

こんにちは!

dely, Inc.でプロダクトマネージャー兼開発部ジェネラルマネージャーをしている奥原 (@okutaku0507) といいます。気がついたら、もう3月になってしまいました。皆さんは花粉と対峙されていますか。

この記事はレシピ動画サービスであるクラシルを開発しているdely開発部が2019年1Qに開発ブログに取り組み、その一環として2018年のAdvent Calendar (dely Advent Calendar 2018) を実施した背景とその成果について書いて行きたいと思います。

目次

 

1. 開発ブログに取り組んだ背景

まず最初に、dely開発部はとても少数精鋭です。実際に人数を聞くといつも皆さんが驚かれるほどです (気になった方はこちらから僕に聞きにきてください) 。実際に会社に所属して開発ブログを運営されている方なら体感されていると思うのですが、開発ブログを運営していくことは驚くほど工数がかかります。自分たちの取り組んでいることを、社外にわかってもらえるように発信することは思っている以上に大変です。では、少数精鋭でリソースが限られている中、なぜ、僕たちがAdvent Calendarに取り組み、開発ブログを盛り上げていこうと思ったのか。その背景についてお伝えしたいと思います。

今ではどこの会社も同じ現状かと思いますが、delyにおいても「クラシルの事業拡大と新規事業立ち上げのため、圧倒的に人材が足りない」という課題を抱えています。そして、採用人事に携われている方々は痛感していると思いますが、エンジニアとデザイナーの労働市場は本当に採用が困難になっています。これらの課題を解決するために、僕らがどうすればいいのか考えました。

僕の考えでは、採用はマーケティングに似ていると思っています。

f:id:okutaku:20180405140824j:plain

だれでも使えるマーケティングファネルの考え方 (https://www.tam-tam.co.jp/tipsnote/others/post14695.html)

つまり、採用される方のUXを考えた時に、会社を認知する、興味を持つ、転職 (新卒で入社) の際に比較・検討する、入社するというようなファネルに分解できると思います。このファネルにおいて、delyの現状と照らし合わせた際にまず浮かび上がった課題が以下の通りです。

クラシルというプロダクトや社長の堀江さんは知っているけど、それを開発しているdelyの開発部のことは全く知らない。

クラシルというレシピ動画サービスは2017年に全国規模のTVCMを打ったのと、社長である堀江がTVに出たり、PRで露出する機会が多く、両者は広く知れ渡っていました。そのため、候補者や実際に入社された方にヒアリングすると上記の課題が出てきました。そもそも、クラシルを開発している僕らが認知すらされていない状態となっていました。

ファネルの上部である「認知」を獲得することを目的として、上記課題の解決策として開発部ブログを盛り上げるという方法を考えました。

以上の拝見から、2019年1Qの開発部の目標として開発ブログの活性化を入れることになりました。

 

2. KPIと実績

そもそも、dely開発ブログである本ブログは2016年から存在はしていました。しかしながら、工数もかかるためほとんど運用されていなかったのが実情です。

実際に記事を書いていくにも、目標を立てておいた方がそれを目指せると考え、2019年1Qが終わるまでに、通常の目標として42,000PV、ストレッチ目標として100,000PVをおきました。通常の投稿で、ちょっとバズったとしてもPV数は数千程度だったので、部内では目標に対する厳しさすら立ち込めていました。まさかの結果となるとはつゆ知らず...

達成できるのかという一抹の不安がこみ上げる中始まった開発ブログですが、2019年1Q中のPVランキングを紹介します。 

 

第一位は...

tech.dely.jp

弊社SREの井上さんが書いた、AWSの料金が意図せず増えてしまうのを仕組みで解決するという記事でした。AWSで意図せず予算を溶かしたというこの上なく辛い経験をした人に刺さったのか、多くのSREの人に読んでいただけたようです。

 

第二位は...

tech.dely.jp

僕が書いた、クラシルで実践しているリーンなプロダクト開発を事細かに紹介した記事です。僕のクリスマス全てを捧げたかいがありました。delyの開発部では、リーンなプロダクト開発を通して、リリースしてみないとわからないという不確実性を減らす仕組みを作り、ユーザーに価値あるプロダクトを提供することに力を注いでいます。

 

第三位は...

tech.dely.jp

弊社SEOスペシャリストである、internet_ghostさん (@ghost_inter_net) が書いたSEOの記事です。アプリの会社と思われがちですが、最近ではwebチームの発足と共にweb版クラシルもガンガン開発されており、前年比2000%の成長を遂げています。webチームにも注目が集まっています。

 

さて、開発ブログがおいていた目標に対する実績ですが...

f:id:okutaku:20190304104109p:plain

僕らの予想を大きく上回り、早々にストレッチ目標であった10万PVを超えました。開発部として、僕らがやっていたことが皆さんに注目していただけて本当に嬉しい限りです。ノウハウも多く書いたので、読んでいただけた皆さんの役に立てれば幸いです。

 

また、これは本質的ではないかもですが、はてなブックマーク数ランキングでは10位を獲得することができました。少数精鋭で行なった分、この順位は僕らの自信に繋がっています。

qiita.com

3. 開発ブログの成果

さて、目標であったPVを達成してもそれが何も繋がっていなかったら意味がありません。僕らが実感してる効果を列挙します。

  • オーガニックでの応募が増えた
  • スカウトの返信率が向上した
  • お会いする人にブログ読んだことがあると言われるようになった
  • 面接にきていただける方が事前に読んでくれるようになった
  • ブログの盛況で開発部全体が盛り上がった

dely開発部の認知が拡大することで、wantedlyなどでのオーガニックでの応募が増えたように感じますし、面接に来ていただいた方に読んだことがあると言われる機会が増えました。また、副次的な効果としてスカウトの返信率が向上したように思います。考察として、認知を獲得することは採用全てのベースになると思いました。そして、開発ブログが盛り上がると開発部全体も盛り上がって良いなと感じました。

 

4. 終わりに

開発ブログの大成功をお祝いして、みんなが大好きなアレを開発部の皆で食べました。みんなの頑張りを労い、一体感が増してよかったです。これからも引き続き、開発ブログでの発信をしていきたいと思います。お寿司目的で頑張ったのではないことだけは大事なので、明言しておきたいです

f:id:okutaku:20190302185201j:plain

そして、皆さまが気になっている実際の採用ですが、開発ブログが全てではないですが、優秀なメンバーの採用が徐々に決まってきています。一緒になってクラシルをよりよくできるメンバーが増えて、とても嬉しいです。ですが、まだまだ積極的に採用を行なっています。

こちらが現在、募集しているポジションになります。プロダクト開発に携わるポジションは全て募集しているので、是非とも話だけでも聞きにきてください。

www.wantedly.com

また、面接となるとハードルが高い方用にカジュアルに内部のことが聞ける「カジュアル1on1」という取り組みも行なっています。絶対に面接はしないので、是非とも中にいるメンバーに話を聞きにきてください。

bethesun.connpass.com

さらに、行くのもためらわれた方用に気軽に僕とチャットできる選択もオープンにしています。起きている時間全て対応しますので、気軽にチャットしてきてください。

note.mu

AWS SDK for Rubyを使ったAWS Athenaの利用

こんにちは、サーバーサイドエンジニアのjoooee0000です。 delyはデータ基盤としてAWS Athenaを使っており、ユーザーの行動ログからアプリケーションのアクセスログまで、様々なログがAWS Athena上に存在しています。AWS AthenaはS3上にあるデータソースをprestoのクエリ記法で引けるようになっていてとても便利です。

クラシルの分析基盤の歴史に興味がある方は、この記事にまとまっているので参照してみてください。クラシルの分析基盤はデータサイエンスチームが主体となって今も改善を続けています!

tech.dely.jp

今回は、AWS SDK for Rubyを使ってAthenaのクエリを実行する記事が1本もなかったので書きました。また、本記事では並列処理ではなく、シンプルな1クエリを引くsync処理の場合について紹介します。

AWS Athena SDKの特徴

AthenaのSDKは少し変わっていて、実行待ちのポーリング処理を自前で実装する必要があります。処理の流れとしては、

  1. クエリを実行するAPIを叩く
  2. クエリ実行のステータス問い合わせAPIを結果が SUCCEED になるまで叩く
  3. クエリの結果を返すAPIを叩く

このように、2の処理の間ポーリング処理を実装する必要があります。

では、工程ごとに実装例を紹介していきます。

1. クエリを実行するAPIを叩く

まずは、クエリ実行をリクエストする #start_query_execution APIを叩きます。 実装サンプルはこちらです。

client = Aws::Athena::Client.new({})
query_string = %Q{SELECT * FROM "databasename"."tablename" limit 10;}

client.start_query_execution(
  {
    query_string: query_string,
    output_location: 's3://' + S3BUCKET_NAME
  }
)
=> 
#<struct Aws::Athena::Types::StartQueryExecutionOutput
 query_execution_id="c0d4460b-xxxx-xxxx-9924-ede5c2d2b56b">

Clientのinitializeの引数は、AWSのaccess_key_id/secret_access_keyやregionを主に指定します。なにも指定しないと、他のAWS SDKの仕様と同様に

  • Aws.config[:credentials]
  • The :access_key_id, :secret_access_key, and :session_token options.
  • ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']
  • ~/.aws/credentials
  • ...

の値が順番に参照されていきます。regionも同様です。環境に合わせて設定してください。

また、#start_query_execution APIを叩く際は、クエリとresult_configurationを指定する必要があります。 result_configurationでは、クエリの実行結果の保存先としてs3のpathを指定します。APIでのクエリ実行も、AWSコンソールでのクエリ実行時と同じようにs3に実行結果を保持する仕組みになっています。指定したs3のpathに実行結果が蓄積されていきます。

また、APIのレスポンスとして、query_execution_idを取得できます。こちらのidは、実行結果の問い合わせや実行ステータスの問い合わせ、実行停止処理などに必要になります。

このAPIを叩いた時点では、処理を開始するリクエストを送っただけです。返り値としてクエリの実行結果が返ってくるわけではなくquery_execution_idという実行固有のidのみが返却されます。

2. クエリ実行のステータス問い合わせAPIを結果がSUCCEEDになるまで叩く

1で実行を開始したあと、クエリが走り終わるまで、結果を取得することはできません。しかし、1の工程ではクエリの実行完了を待たずにレスポンスがかえってきます。 そこで、現在の実行のステータスを知るための #get_query_execution というAPIが存在しています。そのAPIの返り値がSUCCEED になるまでステータスを問い合わせ続けなければなりません。つまり、ポーリング処理が必要となります。

今回はwhileを使ってポーリング処理をする代わりに、こちらのgemのwith_retriesを使用してポーリング処理を実装しました。 GitHub - ooyala/retries: A tiny Rubygem for retrying code with randomized, exponential backoff. こちらのgemは内部でexponential backoffを採用しています。exponential backoffとは、指数関数的に処理のリトライ間隔を後退させるアルゴリズムのことで、処理に時間がかかるほど再処理をする間隔が広くなっていきます。つまり、早く終わる処理には無駄な待ちがなく、時間がかかる処理には無駄なAPIのコールやCPUの負荷をかけずに済むようなアルゴリズムになっています。

実装サンプルはこちらです。

begin
  status = ''
  with_retries({ max_tries: 100, base_sleep_seconds: 0.01, max_sleep_seconds: 30, rescue: [Executing] }) do |retry_count|
    state_result = client.get_query_execution({ query_execution_id: query_execution_id })
    status = state_result.query_execution.status.state
    puts "[Athena Poling] fetching_count: #{retry_count}"
    raise Executing if ['QUEUED', 'RUNNING'].include?(status)

    case status
    when 'FAILED', 'CANCELED'
      # 処理が失敗した理由の取得
      reason = state_result.query_execution.status.state_change_reason
      raise ExecutionError, reason
    end
  end
  status
ensure
  # 成功時以外はクエリの停止リクエストを送信
  unless status.present? && status == 'SUCCEEDED'
    puts "クエリ停止処理"
    client.stop_query_execution(query_execution_id)
  end
end
class Executing < Exception
end
class ExecutionError < Exception
end

with_retriesの引数である、max_triesやmax_sleep_secondsなどは、用途に合わせて調節してください。

ポーリング処理の精度検証

試しに実行時間が100秒弱のクエリのポーリング処理を下記の条件で実行した場合のCPU使用時間やAPIコール数を比較してみます。

  • シンプルなwhileでのポーリング処理(sleepなし)
  • sleep(1)をはさんだwhileでのポーリング処理
  • with_retriesでのポーリング処理

CPU使用時間の計測には、Ruby標準ライブラリのbenchmarkを使用しました。

シンプルなwhileでのポーリング処理

APIコール数: 3470回

CPU使用時間:

# benchmark結果
user       system     total     real
12.160000   1.320000  13.480000 ( 96.931696)

sleep(1)をはさんだwhileでのポーリング処理

APIコール数: 94回

CPU使用時間:

# benchmark結果
user       system     total    real
0.350000   0.050000   0.400000 ( 97.002586)

with_retriesでのポーリング処理

APIコール数: 15回

CPU使用時間:

# benchmark結果
user       system     total    real
0.090000   0.010000   0.100000 (110.306136)

with_retriesの処理の方はwhile処理と比べぴったりに処理が終わらないため、少しreal timeが長くなっています。しかし、長い処理においてもCPUをほとんどつかっておらず、APIコール数も少ないのがわかります。 ほとんどCPUを使わないので平行で重い処理などが走っても心配ありません。(これくらいの差であればsleep(1)でも十分だと思いますが。)

停止処理について

また、2番の処理の実装においてもう一つ大事なことは、途中で強制的に処理を終了した際などにしっかりクエリの実行を中断することです。想定外の長い処理が走ってしまったとき、コードを中断しても #stop_query_execution を叩かない限り裏側ではクエリの実行が走り続けてしまいます。なので、コードを強制終了させた場合などにも後処理として実行されるensure節でクエリ停止処理を書くことをおすすめします。

3. クエリの結果を返すAPIを叩く

クエリの実行が完了したら、後は結果を引く工程のみです。 結果の量が多く、ページングが必要な処理に関してはnext_tokenを次のリクエストでなげる形で実装します。特に変わったことはしないのですが、一つあげるとしたら1ページ目の1行目にカラム行が返ってくるので、結果を返すときにそれを除外しています。 (カラム行をskipしてくれるoptionを探したのですが見つかりませんでした。。探せばあるかもしれません。)

本実装では、1ページにページに返ってくる上限を100件、すべての結果の上限を10000件に絞っています。

MAX_PAGE_RESULTS = 100
MAX_RESULTS = 10000

# next_tokenを受け取って次のページをリクエストする再帰処理
def get_all_results(query_execution_id, next_token = nil, results = [])
  rows, next_token = get_results(query_execution_id, next_token)
  results += rows
  results = results.flatten
  if results.count > MAX_RESULTS
    raise ExecutionError, '結果の上限数を超えています。'
  end
  if next_token.present?
    results = get_all_results(query_execution_id, next_token, results)
  end
  results
end

# 1ページ分の結果を取得する処理
def get_results(query_execution_id, next_token = nil)
  results = client.get_query_results({
              query_execution_id: query_execution_id,
              next_token: next_token,
              max_results: MAX_PAGE_RESULTS
            })

  next_token = results.next_token
  # クエリの実行結果の取得
  rows = results.result_set.rows

  # カラム一覧を取得
  column = results.result_set.result_set_metadata.column_info.map(&:label)

  result_rows = rows.map do |result|
                  row = result.data.map(&:var_char_value)
                  # 初回はカラム行が返ってくるので除外
                  next if row == column
                  column.zip(row).to_h
                end.compact
  [result_rows, next_token]
end

get_all_results(query_execution_id)

まとめ

今回は、AWS AthenaをAWS SDK for Rubyで引く実装について紹介しました。 ポーリング処理を自前で書くような仕様が珍しいですよね。ポーリングを書く処理は、with_retriesを使ってみてはいかがでしょうか。また、停止処理はしっかりと行いましょう!

Google Play Developer APIを活用してAPKのアップロードを自動化する(DroidKaigi 2019の発表を終えて)

こんにちは。Androidエンジニアのうめもりです。

もう終わってからだいぶ経ってしまいましたが、今年のDroidKaigiもとても面白かったですね。自分は去年から参加し始めたのですが、去年と比べても 色々な部分で改善が見られ、運営の方々には頭が下がる思いです。来年以降も続いていくといいですね。

さて、自分は今年のDroidKaigiではこんな発表をしてきました。

ちなみに去年の発表はこんな感じなので、大分毛色が違う感じでしたね。(資料のアス比が…)

正直内容としてはそこまでレベルが高い感じもしなかったので、資料を作りながら(大丈夫かな…いやでもこれCfP通りだしな…いやでもな…)みたいな葛藤がありつつも、当日はそこそこ楽しんでいただけたようで良かったです。日本語から英語への同時通訳をしていただく中での発表は初めてだったのですが、終わった後にゆっくり喋ってもらったのが良かったとコメントをいただいて安心しました。(余談ですが、DroidKaigiの同時通訳の方はAndroid Specificな内容も理解しつつ的確に翻訳してくださる素晴らしい方々だったみたいです。どれだけ事前に勉強されたのでしょうか…。事前の打ち合わせでもお話をさせていただきましたが、安心して発表に臨むことが出来ました。)

発表内容の中でGoogle PlayへのAPK、Proguardのマッピングファイルの自動アップロードを行っているという話をチラッと出したのですが、登壇後のオフィスアワーでも、Twitter上でもどうやっているのかという質問をいただいたりしたので、こちらのブログで補足しておこうと思います。ちなみにAWS Lambdaからゴリゴリやっているので、ナウい感じのCIサービス(Bitriseとか)の話は出てこないことをご了承ください。

APKのアップロードをどのように自動化したか?

タイトルでネタバレしてしまっていますが、delyのAndroidチームでは、Google Play Developer Publishing APIを使って

  • APKのアップロード
  • Proguardのマッピングファイルのアップロード
  • リリースのドラフト作成(アルファ公開チャンネルに作成しています)

のタスクを自動化しています。クラシルはRealmを使っていた関係でSplit APKを行っているので、APKのアップロードやProguardのマッピングファイルのアップロードは地味に面倒なタスクになっています。ファイルをアップロードするだけといえばアップロードするだけなのですが、リリース時の心理的な作業負担感が大分低減しているような実感があります。

Google Play Developer Publishing APIの叩き方

2019年2月21日現在でのGoogle Play Console上での説明なので、もしかしたらこの部分の説明は間違っている可能性があります。

まずはGoogle Play Developer Publishing APIを使うためのサービスアカウントを発行しましょう。今回の用途では各ユーザーの権限でAPIを呼ぶことは無いと思うので、サービスアカウントを使うのが簡単だと思います。

Google Play Developer Consoleの「設定」から、「APIアクセス」を開きます。(なお、この操作は適切な権限のあるアカウントでないと出来ませんのでご注意ください。)

まだ1回もAPIを使ったことが無いのであれば、「新しいプロジェクトを作成」からプロジェクトを作成しましょう。

作成すると、画面の下にサービスアカウントというセクションが現れるはずなので、そこから「サービスアカウントを作成」をクリックします。

基本的にはそこに書いてある通りに操作を行えばいいのですが、Google API Consoleに移動して、「サービスアカウントを作成」から要求された項目を入力し、サービスアカウントを作成します。重要なのは、キーの作成を行って(JSONがいいと思います)、それを控えておくことです。

次に、Google Play Consoleの「ユーザーと権限」から、先ほど作成したサービスアカウントのメールアドレスを指定して、権限を与えます。「リリースマネージャー」にしておくのが手っ取り早くていいと思います。

以上で、先ほど作成したキーを使ってGoogle Play Developer Publishing APIを使うことが出来るようになりました。

Google Play Developer Publishing APIを呼び出す

先程作成したキーを使ってAPIを叩くのはGoogle APIs SDKを使うのが一番簡単です。弊社ではAWS Lambda上でGoを使ってAPIを叩いていますが、以降の説明はGoogleのAPIドキュメントを使って行います。(どのプラットフォーム用のSDKでも基本的には同じようにマッピングされているはずなので、適宜読み替えて使ってください。)

さて、APKやProguardのマッピングファイルをアップロードするのにはまずEditsを作成する必要があります。Google Play Developer Publishing APIはAPIをまたいだトランザクションに対応しており、Editsはトランザクションの単位と考えれば間違えが無いと思います。

developers.google.com

なお、こちらがGoogle Play Developer Publishing APIのドキュメントなので、こちらを見て分かる方はそちらを読んでいただいた方がいいと思います。

Editsを作成する

まずは、

https://developers.google.com/android-publisher/api-ref/edits/insert

POST https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/edits

こちらのAPIをコールしてEditsを作成します。{packageName}にはアプリケーションIDを入れます。

レスポンスはこのような構造になっています。

https://developers.google.com/android-publisher/api-ref/edits

重要なのはidで、こちらのidを使って以降のAPIをコールしてファイルのアップロードを行います。

APKをアップロードする

次に、APKのアップロードのやり方です。

https://developers.google.com/android-publisher/api-ref/edits/apks/upload

POST https://www.googleapis.com/upload/androidpublisher/v3/applications/{packageName}/edits/{editId}/apks?uploadType={uploadType}

こちらのAPIをコールしてAPKをアップロードします。先程作成したEditのidを{editId}に挿入します。{uploadType}は mediaresumable を設定できますが、 今回はmediaを指定した場合の説明だけをしておきます。

Content-Typeは application/octet-streamapplication/vnd.android.package-archive を設定し、ファイルのバイナリ列をリクエストボディとして送信しましょう。

なお、弊社ではAPKごとにゴルーチンを立ち上げて並列アップロードしています。

Proguardのマッピングファイルをアップロードする

次に、Proguardのマッピングファイルをアップロードします。

https://developers.google.com/android-publisher/api-ref/edits/deobfuscationfiles/upload

POST https://www.googleapis.com/upload/androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}?uploadType={uploadType}

基本的にはAPKと同じ要領ですが、どのAPKのマッピングファイルなのかを指定する必要があります。{apkVersionCode}には、対応するAPKのVersionCodeを挿入します。{deobfuscationFileType}には現在は proguard しか指定できませんので、それを指定しましょう。

Content-Typeには application/octet-stream を指定し、マッピングファイルのテキストをリクエストボディに入れて送信しましょう。

余談ですが、難読化を意味するobfuscationはネイティブの方でも馴染みのない単語みたいですね。事前打ち合わせでその話題が出てきて、確かに日本語でも難読化なんて言葉そんなに使わないわ…と思った記憶があります。

TracksをUpdateする

最後に、どのトラックにアップロードするかを指定します。

https://developers.google.com/android-publisher/api-ref/edits/tracks/update

PUT https://www.googleapis.com/androidpublisher/v3/applications/packageName/edits/editId/tracks/{track}

{track}には alpha, beta, production, rollout, internal を指定しましょう。弊社では毎回 alpha でアップロードしています。( internal の方がGoogle Playへの反映が早いですし、そちらでアップロードすることを検討してもいいかもしれませんね…。)

Content-Typeは application/json を指定しましょう。

リクエストボディのフォーマットは

https://developers.google.com/android-publisher/api-ref/edits/tracks#resource

こちらに書いてあります。必須なのは trackreleases[].statusreleases[].versionCodes です。versionCodesにはアップロードしたAPKのVersionCodeを指定しましょう。statusは completed, draft, halted, inProgress ですが、弊社では draft でアップロードし、リリースノート等は後で入力するという運用にしています。

Editsをcommitする

https://developers.google.com/android-publisher/api-ref/edits/commit

POST https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/edits/{editId}:commit

上記APIをeditIdを指定して呼び出せば、今まで行った全ての操作がGoogle Playに反映されます。リクエストボディは指定する必要はありません。

注意点

一つだけ注意点ですが、Editsを編集している際にはGoogle Play上でリリースを操作するのはやめましょう。トランザクション外で操作が発生していると、commitする段階でAPIリクエストが失敗します。

まとめ

以上がGoogle PlayへのAPKのアップロード方法です。少しAPIに癖はありますが、Editsの操作さえ分かってしまえば他のAPIも同じように呼ぶことが出来るはずです。皆さんも良いGoogle Playライフを。

この記事の内容への質問等があればこちらまで気軽にどうぞ。

https://twitter.com/kr9ly

来年のDroidKaigiの話

ちなみに、来年のDroidKaigiでもCfPを出そうと思っているのですが、Kotlin Coroutinesで状態遷移を可視化して管理するみたいな話をしたいと思っています。Kotlin Coroutinesの無限のパワーをどう生かすかという話は来年はいっぱいCfPが出てきそうですが、今のうちにしっかり準備してめちゃくちゃ面白い発表にしたいと思っています。(通るといいな…)

クラシル・パーソナライゼーションの歩み

はじめに

こんにちは。 機械学習エンジニアの辻です。

2/6(水)AWS Loft Tokyoでイベント開催します!ご興味のある方はぜひご応募ください! bethesun.connpass.com

さて本日は「クラシル・パーソナライゼーションの歩み」ということで、クラシルをよくご利用頂いているユーザに対してよりいっそう良いコンテンツを提供していくために、パーソナライゼーションの取り組みに力をいれています。そこで、これまでに取り組んできたさまざまな施策に関して考えてきたことやフィードバックから学んだこと、そして、今後どのように進めて行こうとしているのかということについて、過去から未来への歩みとして少しご紹介したいと思っています。

目次

パーソナライゼーション以前の課題感

昨年6月までクラシルで配信しているレシピ動画として主におすすめしていたものは、いわゆるルールベースの「Most Popular推薦」という選出方法によるものだけでした。このMost Popular推薦とは、非常にシンプルなスコアリングモデルで、たとえばクリック数やお気に入り数の集計結果をベースに、いくつかの独自ルールを盛り込んでスコア化し、そのスコアの高いものから順に選出していくというもので、推薦されるレシピは全ユーザーに対して同じものとなります。この方法をざっくりいえば、「たくさんの人が好きなレシピは、たくさんの人が好きなはずでしょ?」ということなので、ある意味で理にかなっているといえます。しかしこれだけですと、クラシルをよく利用して頂いているヘビーユーザにとっては、代わり映えしない提案だったり、お気に入り済みなのに何度も勧めてきてクドいなぁと感じられることも多々あるかと思います。あるいはまた、何か苦手な食材があるユーザに対してまったく故意ではないにせよ、その苦手食材を毎回おすすめしてしまっていては、続けて使って行こうなんてきっと思って頂けないと思います。

f:id:long10:20190121111701p:plain

それから、このMost Popular推薦のルール変更についても当時は定性的な判断によるもので、例えばこのルールを追加したらCTRが0.3%向上した、あるいはこの施策によってCTRが0.5%下がったなどといったように、ユーザ行動における詳細な相関分析や因子分析を行わないまま、微細な数値の増減だけに翻弄される日々を過ごしていました。今にして思えば、これこそまさにノーフリーランチ定理だったのです。すべてのユーザ標本にとって最大極値を探索するような汎用アルゴリズムは、全ての可能なコスト関数に適用した結果を平均するのと同じ性能になってしまっていたわけです。

ノーフリーランチ定理 f:id:long10:20190121112708g:plain

この状況を打開すべく、まずは特殊用途に最適化するために全ユーザ標本に対してクラスタリングを行い、最適化すべき定義域を局所化することから始めました。これにより、分類した各クラスターの基礎統計を観察してみたところ、それぞれに特色のようなものが現れ始めたので、この特色を定量化すべく主成分分析や因子分析を行い寄与率の高い特徴量を探索して絞り込んでいくことができました。

f:id:long10:20190121113157p:plain

そして、このクラスター毎のMost Popular推薦のルールを作成しそれぞれに適用することで、全ユーザ標本に対してのMost Popular推薦と比較しても格段に高い結果を得ることができました。さらにまた、強調フィルタリングを用いてユーザと動画のスコアリングを行うことでレコメンドエンジンを作成し、パーソナライズド・レコメンドを部分的に適用することができました。(一部のクラスタではCTRが下がるという結果が得られたのですが、そのクラスタには効果がないということが判断できたので、それもまた発見でした。)
中でも、顕著な特性として出てきたのが新奇性に対する反応の違いでした。新奇性とは、目新しさや物珍しさに対する反応のことで、ヘビーユーザの中には新しいレシピを待っていて、出るとすぐにお気に入りするという使い方をされている方がいらして、その方々のパーソナライズド・レコメンドに対する反応が顕著に見られました。しかしその方々の反応は長期間継続せず、それはこのクラスタの方々にとってのレシピの鮮度というのが、配信後からお気に入りするまでの比較的短い期間であるためであることがわかりました。そのため一度お気に入りしてしまうと、おすすめレシピへの興味は急激に減少しCTRが激減するという傾向があったのです。
(以下のグラフでは、緑が各クラスタ、青が全体の平均CTRを現しています)

新奇性が高いクラスターのレコメンドに対する反応遷移

f:id:long10:20190122095358p:plain

その一方、調理を重視してクラシルを利用されている方々にとっては新奇性の影響はあまりなく、その反面、パーソナライズド・レコメンドに対する反応もそこまで高くはないという面が見られました。

調理を重視しているクラスターのレコメンドに対する反応遷移

f:id:long10:20190122095518p:plain

このような、クラスタの特色を踏まえて理想のレシピ提案を行っています。こちらについては、今後もさらなる精度向上を目指しています。

f:id:long10:20190121111853p:plain

エコシステム化

さて、ここまで課題感としてあったレシピ動画のおすすめ提案についての事例をご紹介しましたが、実はレコメンデーション自体は目的ではなく、パーソナライゼーション全体においてはほんの一部に過ぎないと考えています。それというのも、パーソナライゼーションの取り組みを進めていくことで、このおすすめ提案以外にも、様々な機能により利用して頂けば頂くほどユーザからのフィードバックを得られ、より良いサービス提供が可能になると信じているからです。ですので、ここで結果を焦り過ぎてはいけません。まずはこのフィードバックが全体に循環するエコシステム作りこそが優先で、これなくして一時の場当たり的な改善に一喜一憂しては何も得られません。

リーン開発サイクルとフィードバックエコシステム

f:id:long10:20190121184107p:plain

クラシルというブランドを理解する

f:id:long10:20190121180323p:plain

エコシステム化を進めるにあたって、その根幹にはクラシルというアプリの存在意義があります。この点でクラシルには「ブランドガイド」という指針があり、このコンセプトに反するようなイメージを受け入れることはできません。では、どうでしょうか?機械学習やAIという言葉から受ける印象と、クラシルから受ける印象とは親和性があるでしょうか?これについて定性的な判断は不可能ですが多くの人があまり親和性が高いとはいえないとお考えになるのではないでしょうか?それであれば積極的に全面に出るよりも「あたたかくて、おいしい」にそっと寄り添うようなアプローチを目指すほうが良いと判断しています。(あくまで現時点の個人的な所感に過ぎませんが。)

f:id:long10:20190121165652p:plain

データ分析に関する社内への取り組み

先程、ノーフリーランチ定理に触れましたが、やはり、なかなかそれを理解してもらえないという状況もあります。過去の経験やドメイン知識に基づく判断によってルールを場当たり的に変更していけば、いつかそのうちCTRが向上すると頑なに信じている人も中にはいます。仮に過去データに基づき統計的手法で算出した数値を根拠にいくら定量的な検定結果を共有したところで、難しいとか経験によってうまく行ったというハロー効果はなかなか覆し難いのも事実です。このような状況では、分析者側からいたずらに対立関係を作るのではなく、根気強く納得してもらえるまで取り組みに協力して、現実を検証し続けることが大切だと思っています。それでもし運良くすばらしい結果が出れば、より良いMost Popular推薦のルールが発見されたのですからそれはそれで良いことなのです。

こちらの「ファスト&スロー あなたの意思はどのように決まるか?」を読むと統計学の研究者でさえ誤りを犯すことがあるほど、意思決定がいかに曖昧なものか理解できます。

また、社内では以下のような取り組みによって、定量的なデータ分析に基づく共通認識を深めています。

  • サンプルサイズの算出方法を社内共有+推定値の自動算出
  • 分析基盤の構築+運用+改善
  • ユーザ行動ログおよびレシピデータに基づいたEDA分析
  • ダッシュボードによるビジュアライズ
  • 統計学、多変量解析の実践方法を社内にレクチャー
  • SQL勉強会の開催

レシピを考え、作るというプロセス

弊社ではクラシルシェフと呼ばれる料理人の方たちによって日々新しいレシピが考案されています。このレシピを考えるという作業はそれ自体が非常に複雑な最適化問題であるといえます。旬の食材や価格、あるいは余り物があれば優先して使いたいし、家族に子供がいる場合と高齢者がいる場合など家族構成によって様々な配慮が必要です。それに加えクラシルシェフの場合は、世間のトレンドや検索キーワードなど様々な外的要因も考慮しなければならず、また過去に作った多くのレシピともかぶらないものにしなければならないので、レシピの考案まで極めて多くの制約があります。その複雑な作業を少しでもお手伝いできないかと考えて、これらの機械学習を用いたプロセスの改善に取り組んでいます。

  • レシピ考案をお手伝い:いくつかの説明変数からレシピをヒントとして推論する
  • レシピ手順の評価:レシピの手順がネガティブ・ポジティブかを判定して手順を記述する際の判断材料にしてもらう
  • レシピの素性抽出を自動化:レシピに関する様々な素性をルールベースで導出、あるいは推論により抽出し更新・保存する

レシピ動画評価

良いコンテンツは再現したいものの、このコンテンツの良し悪しというのは外的要因に左右されることも多く、また様々なコンテキストによって目的が異なります。再生数が多いほどよいのか?より美味しそうな方が良いのか?あるいは簡単なほどよいのか?など一概に判断が難しいところです。しかしこの「良い動画」をとあるコンテキストにおいて局所的な定量評価することで、より良いコンテンツ作りのサポートができると考えて様々な角度から取り組んでいます。

f:id:long10:20190121174738p:plain

献立の最適化問題

クラシルでは、去年の11月に献立機能をリリースしました。レシピでさえ考えるのが複雑であるにもかかわらず、主菜+副菜+汁物という献立を考えるというのは本当に困難な家事と言えます。ですので、この献立についても、主菜に合う副菜、汁物が最適な組み合わせとなるように現在取り組んでいます。

f:id:long10:20190122102404j:plain

献立画面

f:id:long10:20190122102010p:plain:w300

初回のトレーニングデータのラベル付けについては専門家であるクラシルシェフの皆さんと調理栄養士の方を中心に人海戦術で行いました。現在ではそのトレーニングデータをもとに作成したモデルから最適な組み合わせを推論しています。組み合わせアルゴリズムはナップサック問題のアルゴリズムをベースにした独自実装となっています。

ナップサック問題

f:id:long10:20181105162634p:plain

今後はさらに、旬食材や冷蔵庫の余り物、あるいはアレルギー体質などにも考慮し、さらにご利用頂くユーザに寄り添う献立を様々な形で提案していきたいと考えています。

まとめ

いかがでしたでしょうか?
クラシルにおけるパーソナライゼーションの歩みについてご紹介させていただきました。機械学習やAIというとなんとなく機械的に提案されたレシピを食べるのは嫌だなぁと抵抗のある方もいらっしゃるかもしれませんが、最終的にご提案するのは、クラシルシェフの作った「あたたかくて、おいしい」レシピであって、機械学習やAIはそれにちょっとだけプラスアルファすることで、ご利用頂くユーザのライフスタイルにもっと最適なご提案ができるようなサポート的な存在として寄り添っていきたいと思っております。

さいごに

繰り返しになりますが、
2/6(水)にこのような機械学習のイベントを開催します。今回紹介しました内容以上に実践的なお話ができるかと思いますので、ご興味のある方はぜひお申込みください!
ご来場頂いた方には、弊社の取り組みの中で試行錯誤した「SageMakerの便利スニペット集」をプレゼント致します!こちらのスニペットに対する質問も随時受け付けますので奮ってお申込みください!

bethesun.connpass.com