dely engineering blog

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

スモールスタートではじめる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

不確実性とうまくやっていくためのプログラミング設計論

こんにちは。delyのTech Leadのうめもりです。

これはdely Advent Calendarの25日目の記事です。ほかの記事についてはこちら

qiita.com adventar.org

をご覧ください。

昨日はプロダクトマネージャー兼開発部ジェネラルマネージャーをしている奥原 (@okutaku0507)が

tech.dely.jp

という記事を書いてくれました。ご興味あればそちらも是非ご覧ください。

25日目の記事は、みんな大好き技術的負債の話をしたいと思います。

はじめに

「技術的負債」

我々プログラマーからすればうまく付き合っていく必要のある厄介な存在であり、「何故技術的負債を解消していかないといけないのか?」というトピックは定期的にプログラマー界隈でも話題になりやすいものです。

時にはビジネスサイドに技術的負債の存在やその厄介さについて説明する必要が生じることもあり、その説明の難儀さに苦労した方も多いのではないかと思います。

今日はプログラミング、プロダクト開発の不確実性というテーマから、技術的負債についての説明をしてみたいと思います。

そのためにまずは、我々は何故プロダクト開発をするのか?というところに立ち返ってみましょう。

我々は何故プロダクト開発をするのか?

BtoC、CtoC、様々なビジネス領域においてプロダクト開発という業務は存在していますが、仮にあなたがどんなプロダクトを作っていたとしても 「我々は何故プロダクト開発をするのか?」という問いに対する答えは基本的には同じはずです。

我々は、未来のマーケット、未来のユーザーに価値を提供するためにプロダクト開発をしている。 これが我々が何故プロダクト開発をするのか?ということに対する答えになると思います。

注意しなければならないのは、現在のマーケット、現在のユーザーに対してのものではないということです。何故ならばそのプロダクトが出来上がるまでには多かれ少なかれ時間がかかるはずであり、その頃にはマーケット、ユーザーは多少なりとも変化しているはずですから。

未来を確実に予測することはできないという前提

f:id:delyumemori:20181225101754p:plain

未来のマーケット、未来のユーザーに価値を提供するということを考える際に、最も重要な原則があります。それは 未来を確実に予測することはできない ということです。

どんなに注意深くマーケット、ユーザーの情報を集めたとしても、100%未来がこうなると予測することは現在の技術ではできません。つまり、未来のマーケット、未来のユーザーに対して開発する予定のプロダクトが、本当に価値を提供できるかどうかを確実に予測するすべはないということです。プロダクト開発とは本質的に不確実性を持っているものであり、我々はプロダクト開発がもたらす不確実性とどう付き合っていくか、ということを考える必要があります。

もしそのプロダクトを使うユーザーはあなたがよく知っている人間であるとしても、その不確実性を排除することはできません。プロダクトを欲しがっているユーザーと、プロダクトを前にしたユーザーはもはや他人であると考えるべきです。(多かれ少なかれ皆さんも経験があることではないでしょうか?)

そして、一部の例外を除くほとんどのプロダクトについては、今プロダクトを使っていない未知のユーザーに届ける必要性があるものだといえます。勝手知ったるユーザーにプロダクトを提供することに不確実性があるなら、今あなたが全く知らないユーザーに対してプロダクトを提供することについては言うまでもないでしょう。

不確実性を味方にするたった一つの方法

では、我々はプロダクト開発がもたらす不確実性とどう付き合っていけばいいのでしょうか?

未来を予測する最も確実な方法は、それを発明することだ - アラン・ケイ

我々は未来を確実に予測できなくても、過去のプロダクトがマーケット、ユーザーにとって価値があったかどうかを検証することはできます。 あらゆるプロダクト開発は、マーケット、ユーザーにとってそのプロダクトが価値があるかどうかを検証するために行われるものだと言っても過言ではありません。

プロダクト開発が成功した場合においても、失敗した場合においても、我々はそのプロダクトのもたらす価値という情報を得ることになります。

プロダクト開発が成功した場合に得られる情報は、それはそのプロダクトが価値がある、という情報です。
プロダクト開発が失敗した場合に得られる情報は、それはそのプロダクトが価値が無い、という情報です。

大抵の場合、そのプロダクトに価値があるかどうかは100%か0%ではなく、ある部分は価値があり、ある部分は価値がないという情報が得られるでしょう。

プロダクト開発がもたらす不確実性を味方にするたった一つの方法、それはプロダクト開発が失敗した場合に被る損失を最小にし、プロダクト開発が成功した場合に得られる利益を最大化するという方法です。

プロダクト開発の失敗とどう向き合うのか?

プロダクト開発の成功から得る利益を最大にする方法、それはプロダクトの価値のある部分を破棄せずに使い続けることです。
プロダクト開発の失敗から被る損失を最小にする方法、それはさっさとプロダクトの価値のない部分を破棄して、作り直すことです。

ここで一つ気を付けなければならないのは、コードの改修は大抵の場合はこの破棄して作り直す、ということを意味するということです。破棄する範囲が広いか狭いか、という違いだけがそこには存在します。

価値のある部分を残し、価値のない部分を捨てるためには、それらを区別することができることが必須条件です。

もし価値のある部分と価値のない部分の区別がつかなければ、価値のない部分だけを捨てるという判断が出来ません。そうやってプロダクト自体の価値のない部分が時間とともに増えていけば、いずれプロダクト全体を捨てなければならないという破綻を招くことになります。

疎結合であり、単純明快で意味のある構造を実現し続けること

プロダクトの価値のある部分を残し、価値のない部分を捨てられるようにするための基本的なアイディアとして、コードを疎結合な状態に保つということがあります。疎結合とは単純にコードが分割されている、というだけではなく、コードの意味として分割されていることで、分割されたお互いのモジュールの実装の詳細を知らなくてもそれぞれの機能が提供できる、という状態のことです。

例えばプロダクトがAという機能とBという機能を提供していたとして、Aという機能を提供している部分、Bという機能を提供している部分がコード上での構造としても明確であり、Bという機能だけを検証の結果として破棄することになったとしてもすんなり破棄できる状態であるのが疎結合になっているという状態です。これが、Aという機能がBという機能の実装を深く理解していないと提供できない、あるいはそもそも不可分である、という状態になっていると、そう簡単にBという機能だけを破棄することが出来ないということになります。Bという機能を外から不可視にすることは簡単にできるかもしれませんが、システムとしては本来必要のないBという機能の実装を理解した上で、今後の機能実装を進める羽目になるでしょう。そういった破棄できない部分が増えていけば、前述したようにいずれコードを丸ごと破棄するしかなくなるという結末が待っているでしょう。

コードを疎結合に保つ、ということはそうでない場合に比べて実装コストがかかりやすいものですし、そもそも困難なことです。大抵の場合は密結合に作る方が簡単ですし、実装コストも安く済むでしょう。ただし、それは実装した部分全てが有用であり、変更する必要が無いということが前提になっているか、そもそも一回きりの実装でコードを丸ごと破棄することに問題がない場合だけでしょう。(例えばプロトタイプ開発とか)

我々はプロダクトを実装した結果から学習し、不要な部分を破棄した上で先に進み続ける必要があります。古くからそのための様々な考え方がありますが、今回はその文脈でよく出てくるDRY原則と、SOLID原則についてもう一度振り返ってみましょう。

DRY原則をコードを破棄するという観点から振り返る

DRY(Don’t Repeat Yourself)原則とは、情報の重複を避けるという考え方です。特定の機能の実装がバラバラにコードの中に入っていると、その機能を破棄する際にコードの実装を細かく調べる必要があり、破棄することが困難になります。例えばそれらが同一の関数あるいは同一のメソッドあるいは同一のクラスで表現されていれば、破棄は容易になるはずです。一つにまとめたうえで、分かりやすい名前がついていればより簡単にコードを捨てられるようになるでしょう。

ここで気を付けなければならないのは、コードの重複を排除して一つにまとめることが、コードを破棄しにくくすることにつながってしまうことも往々にしてあるということです。一見それは重複であっても、機能上の意味として異なる場合にはそれらの機能をまとめてはいけません。

DRY原則は有用な考え方ですが、プロダクトから学習してプロダクトを改善し続ける際には、注意深く適用しなければならない考え方です。

SOLID原則をコードを破棄するという観点から振り返る

SOLID原則とは、オブジェクト指向言語(今日の実用的なプログラミング言語は多かれ少なかれオブジェクト指向的な要素を持っていますね)における、5つのプログラミング上の原則をまとめて頭文字をとったものです。

  • Single Responsibility Principle(単一責務の原則)
  • Open/closed principle(開放/閉鎖の原則)
  • Liskov substitution principle(リスコフの置換原則)
  • Interface segregation principle(インターフェース分離の原則)
  • Dependency inversion principle(依存性逆転の原則)

今回はそのすべてを振り返ることはしませんが、コードを破棄するという観点から考えるとどれも有用な考え方です。そのうちの二つ、単一責務の原則と開放/閉鎖の原則について振り返ってみます。

単一責務の原則

Single Responsibility Principle(単一責務の原則)とは、「クラスはただ一つの理由で変更すべきであり、一つの機能だけを持っているようにするべきである」という原則のことです。コードを破棄するということから考えると、とても自明なことであると言えます。機能がクラスごとに明確に分割されており一対一でマッピングされる状態であるなら、それらを破棄することはとても容易なはずです。

開放/閉鎖の原則

Open/closed principle(開放/閉鎖の原則)とは、「クラスは拡張に対して開いていなければならず、修正に対して閉じていなければならない」という原則のことです。機能を追加する際にクラス自体を修正する必要が無いようになっているということも重要ですし、その他クラス外の何か変更によって、クラスの挙動が破壊されないということが保証されているならば、安心してコードの破棄を行うことができるようになるはずです。

まとめ:「技術的負債」とは何だったのか?

さて、プロダクトから得る価値を最大化するためにコードを破棄するという観点からDRY原則、SOLID原則の一部を振り返ってみました。

「技術的負債」という言葉をここまで使いませんでしたが、この言葉をプロダクトから得る価値を最大化するためにコードを破棄するという観点から整理し直すと、

  • マーケット、ユーザーにとって価値のないコードそのもの(いずれ破棄しなければならないという債務を抱えている)
  • コードを破棄することを妨げるコード(負債への対応を先送りし、プロダクト全体の破綻を招くコード)
  • プロダクトの不確実性に耐えられない、脆い部分

が技術的負債である、ということが言えるのではないでしょうか。

常にそれは時間とのトレードオフになりますが、我々はプロダクトから学習しプロダクトを成長させ続けるために、技術的負債にいかに対応していくかということを考えていく必要があります。

参考文献

最後に

dely Advent Calendarは今年初めて行った試みですが、今日で25日分全て公開できました。(本来のアドベントカレンダーは24日分であることが多いと思うのですが、何故ブログは25日分書くことになっているのかは割と謎ですよね)

今までdelyの開発部はあまり情報を外に出していなかったのですが、今回は技術的な情報を外に出していくといういいきっかけになったと思います。今後も定期的に色々な情報を発信していきますので、dely engineering blog, dely design blogを引き続きよろしくお願いいたします。

最後に、師走で業務も忙しい中しっかり記事を書いてくれた開発部の皆さんに感謝を。どうもありがとうございました。