dely engineering blog

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

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

はじめに

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

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を引き続きよろしくお願いいたします。

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

開発部の雰囲気

f:id:sakura818uuu:20181225144938p:plain

こんにちは: ) sakura(@818uuu)です。

本記事はdely Advent Calendar 2018の15日目の記事です。
Qiita : dely Advent Calendar 2018 - Qiita
Adventar : dely Advent Calendar 2018 - Adventar

前日は、検索エンジニアの仕事内容を紹介しつつ1年間取り組んだことをご紹介しました。

tech.dely.jp

はじめに

本記事は、delyの開発部の中からみた開発部の雰囲気をお伝えしようと思います。
技術ブログですが、現状開発部の雰囲気を知る機会や文章はあまりないのでこういう記事が一つくらいあってもいいかなと思って書かせていただきました。
あくまで個人の主観による意見が大きいですがそこはご了承願います。

自分なりに精一杯言語化したのですが伝わりにくいことがあったらすみません。
これがdelyに入社しようと考える人のご参考になれば幸いです。

変化に柔軟に対応するのが上手い

delyは会社が急成長していることもあり、どんどん環境が変化していっています。
人でも考え方でもそうですが、環境の変化にはそれ相応の対応が必要になってきます。

環境の変化に対応することは一般的にはとても難しいことだと思うのですが、delyの開発部の人は上手い人が多い気がします。

以下の記事に書いてあるプロダクト改善のプロセスは、柔軟に変化してきた一番わかりやすい好例だと思います。
とても為になると思うのでぜひご一読してみてください。

tech.dely.jp

他には、CTOの大竹さんの『越境型スキルのすゝめ』もまさに変化に柔軟に対応していくことが書かれた記事だと思います。

f:id:sakura818uuu:20181225121024p:plain

優しい人が多い

性格的に優しい人が多いです。 一見なんてこともない特徴かもしれないですが、実はとても重要な特徴だと思います。

第一に細かな気遣いをしてくれる方がとても多いです。

他には、
・わからないことを聞いたら丁寧に説明してくれる(環境構築とか特にわからないことが多いですよね)
・物腰がやわらかい
・よく感謝する(ありがとスタンプがよくslackで押されます)
・誰かがやらなきゃいけない仕事を自ら引き受けてくれる
・耳の痛いことをあえて言ってくれる
などとにかく細かなところで優しい人が多いです。

話を聞くのが上手い

話を聞くのが上手い人が多いです。
話しやすい雰囲気、というのもそうなんですが理解してくれるのがはやいし上手いです。

「一を聞いて十を知る」ということわざがありますが、まさにそんなかんじです。
私は自分の思い通りに言葉を伝えるのが少し苦手なので、開発部の人に相談させてもらうときにすごく助かっています。

f:id:sakura818uuu:20181225125039p:plain

他には
- 落ち着いている
- 和やか
- 人の意見を尊重する
- 難しいことをいいかんじに対応してくれる
- 哲学好き
などの特徴をもった人が多いと思います。

さいごに

少しでも開発部の雰囲気が伝わったでしょうか?
よく採用広報で「こんな人を求めています!」や「〇〇はこんな雰囲気です」と書かれていることがありますが、 入ってみないと正直わからないことが多いですよね。
この記事でその溝を少しでも埋めることができたら何よりです。

最後に採用情報です。クラシルを一緒に作る仲間を募集しています。
もしも就職活動や転職活動で悩んでいるならdelyを選択肢の候補にいれてみるのはどうでしょうか。また、この記事に書かれている特徴に当てはまりそうな人もぜひ検討してみてください。
よろしくお願いします!

www.wantedly.com

次回予告

明日は弊社のプロダクトデザイナーのミカサ トシキ(@acke_red)による「Fluid Interfaces実践 - なめらかなUIデザインを実現する」です。
ぜひご覧ください。

クラシルで実践しているプロダクト改善プロセスのすべて

こんにちは!

dely, Inc.でプロダクトマネージャー兼開発部ジェネラルマネージャーをしている奥原 (@okutaku0507) といいます。この記事はdely Advent Calendar 2018の24日目の投稿です。明日は待ちに待ったクリスマスですね。

先日は、弊社CTOの大竹 (@EntreGulss) から「越境型スキルのすゝめ」というタイトルで投稿がありました。自分の立ち位置を理解して、スキルにレバレッジをかけ付加価値つけながら、時代の急激な変化の中で日々戦っているたけさんの考えが書かれていて、とても面白い記事になっています。

f:id:okutaku:20181224153502p:plain

さて、Advent Calendarも終盤に差し掛かった今回は「クラシルで実践しているプロダクト改善プロセスのすべて」という題で、弊社が運営しているクラシルで実践しているリーンなプロダクト開発を一つ一つの事細かに紹介します。そのため、とても長い記事になっています。ですが、この記事を読んでいただき、不確実性に立ち向かう組織が増え、より良いサービスが世の中に増えたらいいなと思います。

また、弊社が運営しているレシピ動画サービスであるクラシルのことはよく知っているけれども、delyという会社あるいはそのエンジニアやデザイナーのことはわからないという方に、これからご紹介するプロダクト改善プロセスを通して弊社の開発部のことを少しでも知っていただけたら幸いです。

僕らは生活のインフラになるようなサービスを再現性高く世の中に提供していきます。クラシルだけではもちろんありません。クラシル規模のサービスがどんどん出てくる組織を目指して、この不確実性に向き合う開発体制を確立しています。現在、募集しているプロダクト開発部の職種です。一緒に良いプロダクトを創っていける仲間を探しています!

 

目次

 

1. リーンなプロダクト開発が必要になった経緯

クラシルでは新規のサービスや機能改善にリーンなプロダクト開発を取り入れています。業界的にそれらが必要になった経緯がまとまった素晴らしい記事があるので、リンクを貼っておきます。現在、メルペイのPMをされている川嶋一矢さん (@tsumujikaze) の記事です。

note.mu

クラシルではプロダクトの立ち上げから2017年の終わりにかけて記事の中にある、通常のアジャイル開発 (図左) を行っていました。

f:id:okutaku:20181222144944j:plain

なぜモダンなプロダクトチームによるリーンなプロダクト開発が必要なのか (https://note.mu/tsumujikaze/n/n8b5f9cfec2c9)

もちろん、ここでいう通常のアジャイル開発自体は広く一般的で、それで成功している会社も多いと思います。クラシルでも、基本的な機能でさえ不足していた初期では、この開発手法の方が上手く回っていました。しかしながら、幸いなことに多くの方にサービスを利用していただき、長期にわたり運営してきて、それらが上手くはまらなくなってきたのです。

僕らが陥った具体的な問題として大きいのは、頑張って開発した機能がほとんど使われないということが度々あったという問題でした。世の中には「この機能はリリースしてみないと使われるかわからない」という不確実性が存在します。その問題をどのように解決すればいいのか。僕らは四苦八苦していました。

f:id:okutaku:20181222145557p:plain

UXデザインへの理解を深める〜これからのデザイナーがすべきこと〜 (https://goodpatch.com/blog/about-basic-uxdesign)

Ruby on RailsやLaravelなどweb開発で使用する充実したフレームワークが枯れた技術となり、Herokuなど簡単にインフラの環境を構築することができるPaaSが普及した現代において、アイデアはすぐにコピーされ、機能的価値はすぐにコモディティ化します。何かをweb上でできることはもはや当たり前になったのです。

f:id:okutaku:20181224165541p:plain

僕らが開発しているクラシルですが、運営している身からしてこんなことを言ってはいけないと思うのですが、例えば「ハンバーグ」というレシピの作り方が知りたかったら、ユーザー視点で考えると正直なところ別にクラシルではなくても他のレシピサービスでも良いわけです。多分ですが、どのレシピサービスを利用したとしても美味しいハンバーグを作ることができるでしょう。一つ、クラシルの良いところをあげるとしたら、動画でわかり易いというのが強みであり、それがマーケットに刺さったポイントでもあります。しかしながら、クラシルをはじめとして他のレシピサービスは作りたい料理のレシピを知りたいと思った時に引く辞書的なレシピサービスとしては完全にコモディティ化しています。

では、クラシルをこれからさらに伸ばし、より多くのユーザーに使われ世の中に幸せを届けるためにはどうすればいいのか。僕らが出した答えは、噛み砕いた言い方をすれば「ユーザーが欲しいと (潜在的に) 考えているプロダクトを、しっかりと届ける」ことでした。

僕らはこれからクラシルを通してモノが買われる世界を創り、食に関わる今まで解決されてこなかった課題を解決することで、日本で一番使われる生活のインフラとなるサービスにしたいと思っています。さらには、プロダクトの再現性を高めて、クラシルを越えるようなサービスを何度も創っていき、数年後にはdelyから出るサービスは全部めちゃくちゃ流行るという世界を実現したいと考えています。その文脈において、現在どこの会社も採用には難航していると思うのですが、優秀な人材が足りていません。とりわけ、開発リソースは極限まで枯渇しています。

jp.techcrunch.com

そのため、少ない開発リソースを確実に成果に結びつけるために、リリースして使われない機能にリソースを1秒でも割くことは避けなければなりません。しかしながら、僕らが当たると確信した機能でもリリースしてみたら全く使われないという厳しい現実があります。ですが、その「この機能はリリースしてみないとわからない」という不確実性を実際にユーザーに使ってもらう前に下げる手法は存在します。それが、これから紹介するリーンなプロダクト開発なのです。

少々長くなりましたが、経緯をまとめると、

  • レシピサービスとしてのコモディティ化
  • 開発リソースの枯渇

という切実な現実を背景として、不確実性をいかに下げていき「これが欲しかった」を再現性高く実現させ、枯渇した開発リソースを着実に成果に転換していく方法として、リーンなプロダクト開発がその答えとなりました。

 

2. クラシル版リーンなプロダクト開発の概略

リーンなプロダクト開発は川嶋さんの記事にあるような形が原型ですが、クラシルで実際に取り入れていくに伴い、組織のフェーズや形態、プロダクトのフェーズや性質によってより現実に則した形に変わっていきました。それが下の図です。 

f:id:okutaku:20181222155336p:plain

Design by はしもん (@oyasumi_yayaya)

このリーンなプロダクト開発の本質は、要件定義 (何を創るべきか) のアジャイル化、デザインフェーズの導入です。図でもあるようにデザインフェーズと実装フェーズが分かれています。

今までのプロダクト開発を鑑みて、使われない機能がリリースされてしまう今までの開発プロセスの大きな原因として、ユーザーやビジネス上の課題設定は正しいけれども、解決策が間違っているということに気がつきました。これは、今までのアジャイル開発において、要件定義をPOあるいはPMとデザイナーが一気に課題選定から解決策、そしてUI作成までを行ってしまっていたから生じたのではないかという考えに至りました。この、課題と解決策が異なるというミスマッチを防ぐ方法として要件定義もアジャイル化して、課題に対する解決策として正しいのかを実際のユーザーに当てて何度もイテレーションを回すことで確かめていきます。

f:id:okutaku:20181222155309p:plain

つまり、上の図であるように、今までの開発においては紫色の線のように、機能をバーンとリリースした時に一気にその機能が課題に対する解決策なのかという不確実性の学びを得るのに対して、リーンなプロダクト開発では、要件定義をアジャイル化することで、イテレーション毎に不確実性を減らすような学びを得るため、その都度何を創れば良いのかを学ぶ方向修正を行います。こうすることで、一生懸命に開発工数を使った機能が大ハズレすることのリスクが減っていくのです。

これからさらに細かく一つ一つをみていきます。長くなりますが、どうぞお付き合いください。

 

3. どの課題を解くかで全てが決まる

f:id:okutaku:20181222160931p:plain

プロダクト開発において、最も大事になるのが解くべき課題を決めることです。

イシューからはじめよ―知的生産の「シンプルな本質」

イシューからはじめよ―知的生産の「シンプルな本質」

 

この本にもあるように、プロダクト開発においてもやりたいことが無限にあると思います。しかしながら、先ほどもあったように開発リソースが枯渇している現状では、課題を間違えてしまえば、余裕で開発者の一ヶ月くらいをムダにすることになります。競合ひしめくベンチャーにおいてそれは命取りになります。

ユーザーあるいはビジネスの課題は大きく分けて二つに大別すると考えています。

  • バケツを大きくする施策
  • バケツの穴を塞ぐ施策 

これらのどちらも欠かすことができません。

バケツを大きくする施策

バケツを大きくする施策で大切にしている考え方は、自分たちの枠組みで考えないことと、プロダクトを未来の達成したい目標から逆算して考えることです。

人間の思考はついつい自分の枠組みに束縛されてしまいます。具体的には、僕は元エンジニアなので、エンジニアリングに強みを持っていて、DAUが思うように伸びないという課題があった時に、とりあえず継続率をあげる施策を考えます。しかしながら、いくら継続率をあげたところで入っていくユーザー数にもコスト的な視点で限りがあるので、流入と離脱が釣り合う時がきます。その課題解決のアプローチは間違いではないのですが、継続率をあげることも大事だけれども、マーケコストに依存しない大きな流入経路を確立させることができれば、既存の継続率でも十分にユーザーは溜まっていくでしょう。

また、当たり前ですが事業計画上で達成しなければならない数字があります。それらをプロダクト側の人間が無視して開発を行うことは良くありません。プロダクト側の数字で達成しなければならないDAUやサブスクリプションの契約数があります。それから逆算した際に、今のCVRでは全く達成できないことがわかるかも知れません。その場合、今の方法を行っていれば確実に実現したい未来はこないでしょう。そのため、方法を変えるという意思決定をする必要があります。これは、定量的な側面ですが、プロダクトとして実現したい未来という定性的な側面でも同様のことが言えます。数年後にプロダクト上で実現したいユーザー行動があるならば、その行動変容を起こすようにプロダクトに対して変更を加えて行かなければなりません。ある機能をリリースしたからといって、既存のユーザー行動が一気に変わることは滅多にありません。

そして、バケツを大きくする施策で具体的なプロダクトへの変更を考える際には、実際のUXリサーチなどを行いながら、ユーザー行動から課題を発見してそれらに対する解決策を実装していきます。このようなアプローチが人間中心設計やジョブ理論ということだと考えています。

ジョブ理論 イノベーションを予測可能にする消費のメカニズム (ビジネスリーダー1万人が選ぶベストビジネス書トップポイント大賞第2位!  ハーパーコリンズ・ノンフィクション)

ジョブ理論 イノベーションを予測可能にする消費のメカニズム (ビジネスリーダー1万人が選ぶベストビジネス書トップポイント大賞第2位! ハーパーコリンズ・ノンフィクション)

  • 作者: クレイトン M クリステンセン,タディホール,カレンディロン,デイビッド S ダンカン,依田光江
  • 出版社/メーカー: ハーパーコリンズ・ ジャパン
  • 発売日: 2017/08/01
  • メディア: 単行本
  • この商品を含むブログ (6件) を見る
 

クラシルにおいては料理という一連のユーザー体験を考えると、料理は調理だけではないわけです。残念ながら、該当するツイートは消えてしまっているのですが、以下の記事で紹介されているツイートでは、調理という行動は料理の中では氷山の一角であることがわかります。

f:id:okutaku:20181222164157j:plain

料理をしない人に知って欲しいことを「絵」にしてみたら、想像以上に壮絶だと分かった (http://netgeek.biz/archives/76864)

今まで、クラシルはレシピが動画でわかりやすいということがユーザーに刺さり伸びてきたサービスですが、献立機能を開発した背景には、このように料理を毎日のようにしているユーザーには調理だけではなく、もっと大きな問題が山積しているという現実があり、その課題をクラシルでどう解決していくのかということを考えています。

prtimes.jp

バケツの穴を塞ぐ施策 

バケツの穴を塞ぐ施策は、基本的には既存のプロダクトの実装されている機能の改善を行うことです。そのため、溜まっているユーザーの行動データを定量的に分析して行きます。現在ではFirebase Analyticsなどを入れておけば、基本的なアプリの行動は把握することはできますが、もっと詳細に追いたい場合や自分たち用にカスタマイズしたいという要件がある場合は自社でデータ基盤を構築する必要があります。クラシルでもFirebaseなどは導入していますが、自社でデータ分析基盤を持っています。

tech.dely.jp

これらを駆使し、既存の機能に対して、その機能は本当に使われているのか、KPIを達成するためにはどのような変更が必要なのかを定量的な側面からアプローチしていきます。

 

4. 課題に対する解決策を考える

f:id:okutaku:20181222164817p:plain

定性的、定量的両軸で検討した際に、今僕らが解決すべきもっとも大切な課題を設定することができたとします。次のステップでは、設定した課題に対する解決策を考えます。アイデア出しの着想としては「SPRINT 最速仕事術」という本で紹介されている、Googleが実践しているDesign Sprintで、本来であれば5日間で実践する内容を、エッセンスだけを取り入れて、僕らで必要なことだけを行っています。

SPRINT 最速仕事術――あらゆる仕事がうまくいく最も合理的な方法

SPRINT 最速仕事術――あらゆる仕事がうまくいく最も合理的な方法

  • 作者: ジェイク・ナップ,ジョン・ゼラツキー,ブレイデン・コウィッツ,櫻井祐子
  • 出版社/メーカー: ダイヤモンド社
  • 発売日: 2017/04/13
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログ (3件) を見る
 

 これが実際の様子です。

f:id:okutaku:20181222165003p:plain

弊社ではこのように、会議室に数時間こもってアイデア出しをします (「仕様が決まるまで帰れまてん」と呼んでいます) 。この時大事になってくるのが、この会議に誰をアサインすべきかです。少なすぎても多すぎても会議自体が機能しなくなります。

  • POまたはPM
  • デザイナー
  • エンジニア (プロトタイプエンジニア)

絶対に意思決定者であるPOあるいはPMを参加させてください。これらの役職についている人は基本的にめちゃくちゃ忙しい身かと思いますが、プロダクトの明るい未来のために時間を取ってもらいましょう。意思決定者が不在だと、この会議で決まったことが後々になって覆ることがあるからです。プロダクトに関わるみんなで行った民主的な意思決定だったとしても、プロダクトに変更を加える最終的な意思決定を行うためにPOやPMが存在しているわけで、そこにいるメンバーが知るよしもない施策が走っていたりするので、プロダクト開発における組織全体の情報を持っている者には判断を仰ぐことを怠ってはいけません。たとえ、権限が完全に委譲されていたとしてもちょっと確認するくらいはした方がいいと個人的には思っています。

そして、この会議ではメンバーががっつり時間を使って議論できることに価値があります。これを短縮しては良いアイデアは出てきません。なぜなら、限られたメンバーで情報を瞬時に共有して知識レベルを揃え、過学習した結果として、良いアイデアが生まれやすいからです。アイデアはいくつもあれば良いわけではありません。考え尽くされた一つでも十分に成果を上げることは可能です。大事なのはちゃんとあらゆることを考え尽くした結果として出てきたアイデアなのかです。

また、この段階で次のステップに必要な全てを決めてしまいます。具体的には、どこまで精密なプロトタイプを作るかと、どのように機能的価値を検証し、ユーザービリティテストを行うかです。

 

5. プロトタイピング

f:id:okutaku:20181222170512p:plain

次にプロトタイプを作成します。この時に大事になってくるのが Fidelity (忠実度) という概念です。忠実度とは、ざっくりいうとどれだけ現実に則したプロトタイプを作るかの尺度です。High-FidelityとLow-Fidelityのプロトタイプが存在します。

  • High-Fidelity = コーディングによるプロトタイプ (Xcode)

  • Low-Fidelity = ペーパープロトタイプ or ワイヤーフレーム (Sketch)

が具体的な産物になります。

当たり前ですが、High-Fidelityなプロトタイプほど実装の難易度と工数が嵩みます。しかしながら、とても大事な考え方として「考えたアイデアが本当に課題に対する解決策として適しているのかという不確実性を最も下げることができるプロトタイプ」かどうかがあります。工数が嵩むからと言って、中途半端なプロトタイプでは、実際にその不確実性を下げることができず、本末転倒ということはよくあります。

なぜなら、中途半端なプロトタイピでは、動かない所などが多々ありユーザーの体験を阻害し、開発サイドの説明なしには動かすこともままならなくなってしまうからです。それでは、本当に欲しかった機能的価値検証やユーザービリティテストを僕らから誘導する形で期待した答えを言うようにユーザーを導いてしまうこともあるのです。

弊社のプロトタイピングのことが書かれた記事を貼っておきます。時間がある際に読んでいただけると幸いです。

f:id:okutaku:20181224153648p:plain 

6. アイデア検証

f:id:okutaku:20181222172123p:plain

プロトタイプができたら、次はそれが表現するアイデアを検証します。

f:id:okutaku:20181222172311p:plain

このように社内のクラシルユーザーを招いて、上から録画しつつ設問を当てて行きます。検証することは以下の二つです。

  • ユーザービリティ
  • 機能的価値

まずは、機能的価値の検証では、本当にそのアイデアが課題を解決できる方法なのかを検証します。機能的価値とユーザビリティを一度に試験することができる時もあれば、そうではない時もあります。それは、機能的価値がその場で検証しにくい性質を持っているからです。なぜなら、その機能は必要な時が来ないとなんの役にも立たないからです。例えば、被験者に対して「ここはキッチンです。あなたはこれから料理をしようとしています。それらを想像しながら以下の設問に答えてください。」という設定は、あくまでも想像でしかないので、本当に機能的価値を検証できるとは限らないのです。そのため、機能の特性によって機能的価値を検証する方法は異なりますが、それが現実的に可能ではない場合は、想像で答えてもらう場合もあります。

次にユーザービリティテストでは、使いやすさのテストを行います。実際のユーザーは様々なバックグラウンドを持っていて、持っている認知バイアスはそれぞれ異なります。そのため、プロトタイプを使っている際に手が止まるポイントや理由が異なるのです。それが、その個人による局所的なものなのか、それともユーザー全般的に影響を及ぼすものなのかを見極める必要があります。設問を当てながらそれらを瞬時に見抜くことは人間の脳には困難なため、被験者の承諾の上でテスト内容を録画をしています。その認知バイアスの話で弊社CTOのいい感じのスライドがあるので、引用しておきます。

speakerdeck.com

 

7. 学びを整理する

f:id:okutaku:20181222173810p:plain

機能的価値の検証およびユーザビリティのテストが済んだら、デザインフェーズの最後に得られた知見を共有し、整理します。この時に大事になってくるのが以下のことです。

  • 必ずデザインフェーズに関わる全員が参加する
  • 検証を実施した日に行う
  • 小さな問題も見逃さない
  • 得られたインサイトを深く掘って本質を見抜く
  • 次のイテレーション時のことを全て決める

まず大事なのは、検証を実施した日に、デザインフェーズに関わった全員がこの学習の場に参加することです。人間の記憶は短命です。数分前のことでさえ忘れてしまう場合があります。後日にしては、せっかく検証の際に色々考えていたことが揮発してしまうので、絶対にその日に知識を整理してください。また、デザインフェーズに関わる人全てが参加する必要があるのは、次のイテレーション時のことを全て決めておく必要があるからです。そもそも、これでデザインフェーズを終えるのか、まだイテレーションを回す必要があるのかを議論して決定する必要があります。

この学びを得るステップでは、必要あらば録画した動画を見直し、ユーザーが手を止めてしまった原因、それを解決するための情報アーキテクチャやUIの改善をどうするかを決めます。また、今回のアイデアは課題の解決策としてどこまで作り込むべきかもここで話す必要があります。

プロトタイプへの改善策が決まれば、再度デザインフェーズを回し、もう必要ないと判断したならば次のステップへ進んでいきます。

 

8. 意思決定者の決定を仰ぐ

f:id:okutaku:20181222180154p:plain

プロダクト開発において、様々な組織形態があると思いますが、弊社では意思決定者として、POあるいはPMを設置しています。現在では、僕が兼務している形ですが、会社の未来を左右するような機能の場合はCEOである堀江 (@santamariaHORI) の決定を仰ぐ場合もあります。

基本的に、意思決定と責任はセットである必要があると考えています。全員が意思決定者となり、プロダクトに変更を加えて行くことがベンチャーあるいはスタートアップの醍醐味と思われているかも知れません。それで使われるプロダクトが創れるならばそれが良いと思うのですが、不幸なのは使われない機能が世に出てしまうことと、会社としての方向性が開発部だけ異なる方向に向かっていることです。会社を成長させ、社員全員を盛り上げて行くためにも、プロダクトの成功は絶対に欠かせません。それに対して、責任を負っているのに、意思決定権がメンバー全員に分散しているのは組織構造としてよくありません。そのため、デザインフェーズの締めくくりとして、これが課題に対しての最適な解決策で、世に出して良いものかを意思決定者に確認を仰ぐのです。実質的に一つ前のステップである「学習」にも意思決定者が参加しているはずなので、そこで判断されることが多いですが、とても重要なことなので明確なステップとして切り出しています。

 

9. 要件定義を詰め、実装仕様書を作成する

f:id:okutaku:20181222180218p:plain

デザインフェーズを終えてブラッシュアップされたアイデアが世に出して良いと判断されたら、次は今までの要件定義を整理して実装仕様書を作成します。

f:id:okutaku:20181222180307p:plain

実装仕様書とは、1px単位で詰められたUIと、どのAPIを叩くのか、どのようなレスポンスが返ってくるのか、必要なパラメータは何かなどを事細かに書いたドキュメントです。もちろん、ボタンを押した際にどのようなインタラクションを行うかなども記述します。この実装仕様書を読めば誰でも同じ品質で実装を行うことができることを目指します。

この実装仕様書が必要になった背景として、アプリを開発している会社で問題になりがちなのが、iOSとAndroidで意図した挙動が異なるように実装されてしまうことがあります。それぞれ、Material DesignHuman Interface Guidelinesにあるようにデザイン原則が異なるので、意図した差異であれば良いのですが、仕様上では一緒のはずなのに実装の際にそれらの意図が明確に伝わらずに、確認漏れもあってリリースされてしまったというケースが頻発していました。そのため、誰が見ても均質な実装が行えるようにソースを一つとして、このドキュメントさえ読めば大丈夫というように仕組みで解決しました。

 

10. ゴリゴリ実装f:id:okutaku:20181222181137p:plain

さて、要件定義も煮詰まったら実装フェーズに突入します。この時に大切になるのは、以下のことです。

  • どのように実装するかはエンジニアの裁量に任せる
  • 実装仕様書は常に最新状態を維持する
  • 原則としてデザインフェーズまで回帰することはない

今までの文脈では、実装フェーズのエンジニアは「何を創るべきか」という所には参加していませんでした。それらは全てデザインフェーズにいる人たちで決めています。何を創るか決まったものを実装するなんてつまらないという人も、もちろんいると思います。先ほどもありましたが、全ての人が意思決定者となってプロダクトに変更していくことは組織が拡大していくことでカオスになり、必要ではない機能がリリースされる、その結果として枯渇状態の開発リソースが割かれてしまうという問題が生じることがよくあります。何度も伝えたいのですが、プロダクトが成長しないことが最も不幸なことです。このリーンなプロダクト開発は、より確実に確度が高い機能をリリースさせ、プロダクトを成長させるためにとってる開発プロセスなので「何を創るべきか」に関心があるならば自分自身の組織における役割を変え、デザインフェーズに入っていくことで開発できます。もちろん、リソースに余裕があれば、デザインフェーズで固まった要件をそのまま自分で実装するケースもあると思います。そこに垣根はありません。

実装フェーズにおいて、エンジニアはどのように実装するかと期日に間に合わせるためにはどうするかという所に責任を負います。どのように実装するかを考えることは、非常に難易度が高いことです。プロダクトが成熟し、組織が拡大すると、基本的にはソースコードは複雑性を増していきます。複雑性を増すことは、開発におけるスピードを落とす原因にもなりかねません。

f:id:okutaku:20181222182358j:plain

そのメタファとして、ジェンガが面白いです。開発初期では、ジェンガの中から一つを取り出すことは簡単です。しかしながら、開発が進んでくると一つのブロックを取り除き、どこに置くかはとても難しくなります。これが開発でも起こりうるのです。そのため、成熟したプロダクトの開発こそ、より良いアーキテクチャや読みやすく、理解しやすく、変更に強いコードを書くことはとても重要です。長期的な生産性の高さやテストのしやすさなどに必ず効いてきます。より良いコードを実現するために、コードの全体像を理解して、メンテナンス性が高い設計をすることに実装フェーズのエンジニアは責務があるわけです。

また、実装上にも不確実性が存在します。実装を進めていく上で、難しい実装や意図してなかった仕様の穴が潜んでいるわけです。それが発覚した時点で、先ほど作成した実装仕様書に変更を加えて、関係者に伝達する必要があります。それに応じて、リリース日の再検討などが発生するため、PMはステークホルダーに伝達する必要があります。

そして、原則としてその機能を開発すべきということはデザインフェーズで議論し尽くされたという前提があるため、全てを覆す事実がない限りはデザインフェーズまで立ち返ることはありません。その機能の必要性が納得できていないメンバーいるならば、それはPMに納得いくまで説明する責任があります。ここで大事なのは、全員が納得した状態で開発に挑むことです。僕らエンジニアは決まったものを実装するためにいるのではありませんし、ユーザーに価値あるプロダクトを届けるために、役割を分担して良いサービスを創っているので、全員が同じ方向を向いていることはとても大切なことです。

 

11. デバックテストを行う

f:id:okutaku:20181222183432p:plain

実装フェーズの最後のステップとして、デバックテストがあります。ちゃんとQAエンジニアがいれば良いのですが、そのような充実した開発体制が整っているところも多くはないと思います。

f:id:okutaku:20181222183549p:plain

弊社では、検証可能なバージョンをDeployGateで配布して、各々が持っている端末あるいは検証用に用意された端末でデバックを行なっています。その際に出た小さい実装漏れやクラッシュを引きこすようなバグをその再現方法などを専用のチャンネルでやりとりしています。大きな機能によっては、会議室を抑えてみんなでデバックしています。この時大事になことは、どれだけ小さなことでも発言することです。小さいことが実は大きなバグの引き金となることは歴史が証明してくれています。誰が実装したかなどは関係なく、発言することが大事です。

ここで修正すべき問題があれば、そのフィードバックをもとに実装仕様書を変更して、実装フェーズのイテレーションを回します。問題ないことが確かめられたら、晴れてリリース🎉となるわけです。

 

12. まとめ

今まで読んできていただいたように、このリーンな開発プロセスはとても長いですし、デザインフェーズに関わるメンバーも工数がかなりかかっています。そのため、全ての課題に対してこのリーンなプロダクト開発を適用することはおすすめしません。この開発プロセスを適用する判断の軸として「そのアイデアは課題に対して本当に最適な解決策となっているという不確実性が高いか、あるいはその課題が本当に今取り組むべき課題なのか」を持っておくと良いと思います。極論をいえば、文言をちょっと変えるだけの実装にデザインフェーズは不要な場合が多いです。少ない開発リソースでデザインフェーズを適用するかどうかも、そのメンバーで実装できたことのトレードオフの上に成り立っているということを忘れてはなりません

クラシルの開発プロセスにこのリーンなプロダクト開発を取りいれたら、世にでる施策の精度は一気に高まりました。全て当たったと思うほどの威力を発揮します。裏を返せば、今までリリースされてきた不要な機能のように確度が低いものが世に出なくなったということでもあります。

プロダクト開発はマラソンです。リズムが大切です。一見、煩雑に見れるこの開発プロセスを体験してみると、実は今まで以上に開発スピードが上がっていることに気がつくと思います。それは、今まで要件定義がバーンとなされていたことで、仕様の雑さなどから、手戻りなどが多く発生し、仕様の共有ができてなかったり様々な要因で開発が阻害されていたからです。それらが積み重なれば、エンジニアの心理的な余裕がなくなり、バグが発生してしまう要因にもなり得ます。

リーンなプロダクト開発のように、確度が高い施策を常に世に出せる開発体制は、成功体験を積みやすく、モチベーションにも還元されてきます。結局はプロダクトの成功が全てを癒してくれるのです。

もちろん、僕らもまだまだ完璧ではないし、多くのミスを犯してしまいます。しかしながら、リーンなプロダクト開発は僕らに不確実性に向き合うための勇気をくれたと信じています。

 

13. 終わりに

長い長い記事を読んでいただき、本当にありがとうございます。

明日は弊社Androidのエンジニア兼テックリード兼エンジニアリングマネージャーをしている梅森より「不確実性とうまくやっていくためのプログラミング設計論」というタイトルで記事が出ます。是非お楽しみにしてください。

このように僕らは、不確実性に向き合う組織を作って、ユーザーから「これが欲しかった!」をいただけるように日々開発を行なっています。自分たちが開発しているプロダクトを通して、世の中がより良くなっていくことはエンジニアの冥利に尽きます。しかしながら、現在開発部を含めてすべてのポジションで人が足りていません。僕らと一緒に世の中を変えるプロダクト創りに全力を出せる人を探しています。

www.wantedly.com

全てのプロダクト開発に幸あれ!

良いクリスマスを!

Lispの車窓から見た人工知能

はじめに

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

本記事はdely Advent Calendar 2018の22日目の記事です。

dely Advent Calendar 2018 - Adventar

dely Advent Calendar 2018 - Qiita

昨日は弊社のサーバサイド・エンジニアの山野井が「【Vue.js】算出プロパティの仕組みについて調べてみた」という記事を書きました!
とてもわかり易く解説しているので興味のある方は是非読んでみてください。

tech.dely.jp

さて本日は「Lispの車窓から見た人工知能」と題しまして、プログラミング言語Lispから見た人工知能の風景を眺めていきたいと思っています。ぼくはEmacs使いのLisperですが、Lispを書くのは自分用のスクリプトや、Emacs Lispの設定変更といったものだけで、ふだんの機械学習に関するプロダクションコードでは一切使っていません。ではなぜそんなLisp本職でないぼくが、Lispの歴史から人工知能の風景を眺めるかといいますと、実はLispと人工知能とはとても縁の深い関係にあるからなんです。昨今では、機械学習といえばPythonやC++を用いるのが主流となっていますが、黎明期には実はLispがその役割を担っていました(Lisp専用のハードウェアがふつうに販売されていたほどです)。その辺りの流れや様子などの風景を、象徴的な書籍などを少しずつかいつまみながら気楽にご紹介していけたらなぁと思っています。

目次

Lispと人工知能の父

f:id:long10:20181206102030p:plain

この方は言わずと知れたジョン・マッカーシー(John McCarthy)ですね。
人工知能(Artificial Intelligence、以下はAIと称します)という言葉の提唱者であると同時にLispの生みの親でもあります。つまり、その点においてはLispと人工知能は同じ親を持つ兄弟のような関係ともいえるわけです。
マッカーシーのAIに関する功績は偉大過ぎるほど偉大で、遡ること今からなんと60年以上も前、1956年にAIに関する世界初の国際会議(ダートマス会議)を主催されたのでした。実は「人工知能の父」と称されるあのマービン・ミンスキーもこの会議を機にAI研究者を志し、その3年後にはMITでマッカーシーを師事することになったのです。
また、AIにおいてしばしば議論の的となる「有限の情報処理能力しかないロボットには、現実に起こりうる問題全てに対処することができない」という、このフレーム問題についても、パトリック・ヘイズとともに最初に提唱されたのがやはりマッカーシーでした。
その一方で、コンピューターサイエンスに関するマッカーシーの功績も超絶偉大で、実はいまでは当たり前のように用いられている「ガベージコレクション」技法を発明されたのもマッカーシーなんですね。そして、タイムシェアリングシステム(メインフレームからエンジニアをはじめたぼくにとってはTSSはとても馴染みがありますが、一般的にはどうなんでしょうか?)の技術によって(水道や電力のように)コンピュータの能力や特定のアプリケーションを販売するビジネスモデルを生み出すかもしれないともマッカーシーは提唱されましたが、いかんせん当時のハードウェアもソフトウェアも通信技術も未熟であったために1970年代中ごろには消えてしまいました。しかし恐るべきことに、21世紀になってからこのTSSの考え方はアプリケーションサービスプロバイダにはじまり、グリッド・コンピューティングを経てクラウドコンピューティングへと昇華し再浮上してきたことを考えると、まぁなんとういう先見だろうとただただ驚愕してしまいます。
さて、Lispはと言いますと、マッカーシーが最初にLispを発表したのはダートマス会議から4年後(1960年)のことで、それはまさしくAIアプリケーションのための専用プログラミング言語としての誕生でした。
(出典:ジョン・マッカーシー - Wikipedia

Lispの概略

f:id:long10:20181206102931j:plain

ガイ・スティール(Guy L. Steele, Jr)

ここでもしかすると、Lispという言語をあまりよく知らないという方のためにほんの少しだけご紹介したいと思います。Lispは上述の通りマッカーシーによって1960年に生み出されたのですが、実装はというと、当時マッカーシーのもとで大学院生だったスティーブ・ラッセルがマッカーシーのLispに関する論文を読み、それを僅かな期間で機械語で実装してみせてマッカーシーを大変驚かせたという逸話が残っています。そしてこの時こそが、Lispインタプリタが誕生した瞬間だったのです。誕生から幾年月が流れ、1980年代から90年初頭には非常にたくさんのLisp方言が乱立することになりました。それはある意味ではLispという言語仕様の、その実装の容易さこそが仇となった結果と言えるのでした。LispにはLisp自体を拡張する(すなわちon Lispする)マクロという機能が備わっていて、そのマクロを用いることで既存のLispの文法構造自体を拡張することが可能であるため、利用者ごとにカスタマイズした方言を生み出すことができます。しかしそれでは管理が大変だということで、乱立するLisp方言たちを一つの言語に統合していかなければという動きがガイ・スティール(Guy L. Steele, Jr)を先頭として始まり、その結果として設計された新しい言語「Common Lisp」は、基本的にそれらの方言たちのスーパーセットであり、それらの方言たちを置き換え統合することになったのでした。そして試行錯誤の末の1994年、ついにANSIはCommon Lispの標準仕様「ANSI X3.226-1994 American National Standard for Programming Language Common LISP」を出版したのでした。ところが悲しいかな、その時には既にC言語など新たな言語の潮流に押し流され、Lispはもはや全盛期に比べると市場のほとんどが失われていたのでした。
(出典:LISP - Wikipedia

COMMON LISP 第2版

COMMON LISP 第2版

  • 作者: Guy L.Steele Jr.,井田昌之,川合進,川辺治之,佐治信之,塩田英二,田中啓介,元吉文男,湯浦克彦,六条範俊
  • 出版社/メーカー: 共立出版
  • 発売日: 1992/06/10
  • メディア: 単行本
  • 購入: 3人 クリック: 70回
  • この商品を含むブログ (16件) を見る

言語の登場を簡易的に示した図 f:id:long10:20181211112944p:plain

図中の赤のやじるしは影響を与えた言語を示しています。

Lispの特徴

さらに続けまして、Lispの構文についても少しご紹介しておきたいと思います。
Lispという言語構文の特徴といえば何といってもカッコ、S式です。Lispはすべての構文をこのS式で記述できるので式と文とが区別されず、すべてのコードとデータは式としてシームレスに書き下すことができます。コンパイラによって式が評価されたとき、それは値(または値のリスト)として生成されます。ところで、S式はあまりにカッコを大量に使用するため、その見た目上読みづらい(他の言語構文の仕様と乖離があるため)という批判を受けることがままあります。「Lispは 『lots of irritating superfluous parentheses』(過剰でいらいらさせる大量の括弧)に由来する」というジョークもあるほどです。しかしこのS式によるすべてのコードとデータの同図像性(homoiconic)こそがLispの他言語にない能力を生み出す要因ともなっているわけです。特にLispの関数はそれ自身がリストとして書かれているのでデータとまったく同様に扱うことができます。
果たして、良いコードとは一体なにか、その答えは千差万別で、状況次第なわけですし、これという正解をぼくは知りません。可読性の対象とは人間なのか、否、機械なのか、それともまた他の何かなのか...まあその議論はここでは置いておきましょう。それでは、ほんの少しだけS式構文の例をCommon Lispでご紹介したいと思います。

Lispの構文的特徴としてはカッコもそうですが、前置記法が顕著なものとしてあります。こちらの例でカッコで閉じられた先頭のlistは文字列ではなくリストを返す関数です。

(list 1 2 "foo")
;; => (1 2 "foo")

そして、このようにカッコの前にシングルクォーテーションをつけても上のlist関数と同じ意味となりリストを返します。

'( 1 2 "foo")
;; => (1 2 "foo")

+オペレータを使うと、このように、後続の整数をすべて足し合わせることもできます。

(+ 1 2 3 4)
;; => 10

ラムダ式を記述する場合もこのようにlambdaと記述できて直感的に表現できます。

((lambda (x y) (+ x y)) 3 4)
;; => 7

また、関数を定義する場合はこのように、defunを用いて定義します。
再帰的に階乗を計算する場合はこのように定義します。

(defun factorial (n)
  (if (<= n 1)
    1
    (* n (factorial (- n 1)))))

(出典:LISP - Wikipedia

on Lisp

先ほど少し触れたマクロについても少しだけご紹介しておきたいと思います。
Lispといえばこの方、ポール・グレアムは著書「on Lisp」の前書きの中でこのようにおっしゃっています。

「プログラムをただLispで書くだけでなく、独自の言語をLispを基に(on Lisp)書くことが可能であり、プログラムはその言語で書くことができる。~中略~ Lispは開発当初から拡張可能なプログラミング言語となるよう設計されていた。言語そのものの大半がLispの関数の集合体であり、それらはユーザ自身の定義によるものと何も違わない。それどころかLispの関数はリストとして表現できるが、リストはLispのデータ構造なのだ。このことは、ユーザがLispコードを生成するLisp関数を書けるということだ。」

S式が同図像性(homoiconic)構文であるがゆえに、マクロを使って元の構文を自由に変更したり追加することが可能となります。ほんの少しだけマクロの例についてもCommon Lispを例に触れたいと思います。
マクロ定義はこのように記述します。

(defmacro macro-name (args1 args2)
  (list args1 args2))

この場合、呼び出す際にはこのように記述します。

(macro-name args1 args2)

あまり意味はないですが、ifを拡張してmy-ifを作る場合はこのようになります。

(defmacro my-if (condition then else)
  `(cond (,condition ,then)
         (t ,else)))

(defun fak (n)
  (my-if (= n 0) 1
         (* n (fak (- n 1)))))

リテラルデータをテーブルにputするのにdefmapマクロを作成する場合はこのようになります。

(defmacro defmap (name options &rest entries)
  (declare (ignore options))
  (let ((table (make-hash-table)))
    (dolist (entry entries)
      (setf (gethash (car entry) table) (cadr entry)))
    `(defvar ,name ',table)))

このように、マクロによってLispの構造をどんどん拡張することができます。しかし実際のところ関数で事足りる場合がほとんどです。マクロを使う場合として、ポール・グレアムはこのように示しています。
「ある関数が本当にマクロであるよりも関数であった方が良いというのは、 どうしたら分かるだろうか? マクロが必要な場合とそうでない場合の間には大抵明確な区別がある。基本的には関数を使うべきだ。関数で間に合う所にマクロを使うのはエレガントでない。マクロが何か特定の利益を与えるときのみそれを使うべきなのだ。それはいつだろうか? マクロで実現できることの大部分は関数ではできない。」つまり、関数を書いていて実現できないことをマクロで書くとそういうことですね。ボトムアップなコーディングを推奨されるLispにとってそれはある意味自然なことです。

On Lisp

On Lisp

こちらは、さらにマクロを過激に啓蒙する賛否ある書籍です。とても刺激的な内容になっています。

Let Over Lambda

Let Over Lambda

最初のAIの春(1956−1974)

ここでLispを一旦離れまして、車窓から見えるAIの風景へと目を移していきたいと思います。何と言っても、ダートマス会議後の数年はジョン・マッカーシーを中心としたAIに対する期待と発見の時代であり、新たな地平を疾走する機関車のような勢いでAIは発展を遂げていきました。しかし今から考えると、この時代に開発されたプログラムのほとんどは推論と探索に頼っていて、どんなに巨額を投じて開発した当時の最高峰のコンピュータだろうと、処理可能な計算量はごく僅かであり、非常に限定的な領域の問題しか解くことはできませんでした。しかし、それでも当時の人々にとって、見た目的にはまるで人間の様に振る舞う機械は非常に「驚異的」に写っていたことでしょう。

ELIZA

f:id:long10:20181210150717j:plain

そんな黎明期の象徴的な出来事として、ジョセフ・ワイゼンバウムは、ELIZAと呼ばれる単純な自然言語処理プログラムを公表されました。それはLispが誕生してわずか6年後の1966年のことでした。ELIZAは診療カウンセラーを装って人間と対話できるプログラムとして紹介され、あたかも人間の返答を理解したような対話をするために、今ではとても単純なパターン照合プログラムが適用されていました。つまり、ELIZAはいわゆる「チャットボット」の先駆けといえます。特に日本では人工無脳という名前で知られていて独自の進化を遂げました。

ちなみに、弊社では毎日こちらの哲学SlackBotからありがたいお言葉を頂いております。 tech.dely.jp

ワイゼンバウムは、ELIZAが多くの利用者や観衆に非常に重大な影響を及ぼしていく様子や、人々がELIZAに心を開く様子を見ていく中で徐々に不安に苛まれるようになりました。そして、あらためてAIという意味について冷静に考えはじめ、後にはAIに対する主要な批評家の1人に転じられることとなったのでした。
有名な1976年の書籍「コンピュータ・パワー―人工知能と人間の理性( Computer Power and Human Reason)」の中で、ワイゼンバウムはコンピュータ技術についてのアンビバレンスを示されています。それはすなわち、「AIは可能であろう。しかし、コンピュータは常に同情や知恵などの人間性を欠くため、人間は決してコンピュータに重要な決定を任せるべきでない」というものでした。
8章人工知能学にはこのような一節があります。「私の主張したいことは、知性についてあまりにも単純すぎる考え方が大衆ならびに科学会の思考を支配し、部分的にはこの考えがもとになって、人工知能学において誤った雄大なる幻想が育まれることになった、ということである。」このような警鐘を鳴らしています。
ちなみに、ワイゼンバウムがELIZAを実装された際のオリジナルはSLIPという独自の言語で実装されていたのですが、これを Bernie Cosell が後にLispに移植されました。(あとで紹介するピーター・ノーヴィグの「実用Common Lisp」にその実装が掲載されています)
(出典:ジョセフ・ワイゼンバウム - Wikipedia

コンピュータ・パワー―人工知能と人間の理性

コンピュータ・パワー―人工知能と人間の理性

余談ですが、Emacsで「M-x-doctor」としてみるとこのようなELIZAと謎の対話を始めることができます。

I am the psychotherapist.  Please, describe your problems.  Each time
you are finished talking, type RET twice.

me> Hi

ELIZA> How do you do?  What brings you to see me?

me> I am hungry

ELIZA> Why do you say you are hungry?

me> You've got to say what?

ELIZA> Is it because of your plans that you say I have got to say what?

ELIZA> What do you think?

SiriにELIZAのことを尋ねると...

f:id:long10:20181211115732p:plain:w250

お知り合いでした。

AIの最初の冬 (1974−1980)

1970年に入ると、それまで破竹の勢いで発展してきたAIでしたが、様々な批判に晒され、その結果として資金も縮小され発展に陰りが見え始めました。その要因として、ワイゼンバウムの警鐘も虚しく、当時のAI研究者たちの過度な期待によって、直面している問題の難しさに対して、正しく評価できなかったことが大きかったのでした。その期待ゆえに楽観的な彼らが予想する成果へのさらなる期待の高まりは、他の研究者や出資者の間で飽和していたにもかかわらず、実際の結果としてその期待に応えるどころか落胆の連鎖を生み、次第にAI研究への出資はほとんど無くなっていきました。今から考えると、1970年代前半のAIプログラムの能力は非常に限定的で、その実態を出資者から見れば、どのプログラムも単なる「おもちゃ」に過ぎませんでした。そしてさらには、その実用化に当たっては、ハード面でのメモリ容量や速度の不足といった性能の限界は深刻な課題でした。
また、AIそのものに対する他学界からの批判が激化し、一部の哲学者はAI研究者の楽観的な主張に強く反論し、ゲーデルの不完全性定理が形式体系(コンピュータプログラムなど)では、人間が真偽を判断できることも判断できない場合があることを示していると主張したのでした。

Gödel, Escher, Bach「GEB」 (1979)

f:id:long10:20181206114124j:plain:w300

さて、ゲーデルといえば、そんな最初の冬が終わりを告げる頃、ダグラス・ホフスタッター(Douglas Richard Hofstadter)によって執筆されたゲーデル・エッシャー・バッハ(Gödel, Escher, Bach)が出版されたのでした。この本は人工知能の問題に関する書籍としては世界中で一般の人々に読まれた最初の古典と言えるのではないでしょうか。当時、ホフスタッターはまだ20代後半という若さでしたが、人工知能問題を高エネルギー物理学、音楽、芸術、分子生物学、文学、といった多彩なテーマに絡めて非常に豊かに記述され当時大変な話題となりました。この本がきっかけになって、人工知能分野へ進むことを決めた学生も大勢いたと言われています。
未だファンの多い書籍であり、内容も多岐に渡るので概略をご紹介するのは恐れ多いことですが、気楽に、誤解を恐れず一ファンの個人的な感想として紹介させていただくと、この本のエッセンスは、バッハのカノンのパターンとエッシャーの『描く手』に現れていると言えるのではないかと思います。
それは、右手が左手を、左手が右手を描いているという極めて奇妙な絵です。(『描く手』の実物を、今年上野で「エッシャー展」が開かれましたので見て感激しました)

f:id:long10:20181206201324j:plain

この絵を「手」そのものの次元で観察したときには、どちらが描く方で、どちらが描かれている方なのか、判断することは不可能です。つまり、互いに描きあう手は自己言及を繰り返しその環の中で閉じているといえます。しかし、一方で観察者が「絵」としての次元で観察したときには、その背後の、右手、左手の両方の創造者である、エッシャー自身の描かれてない「描く手」がメタ的に控えています。そしてさらには、この絵を描くエッシャーを鏡に映したなら、エッシャーの絵をさらに鏡の向こうの環に閉じるという連続が無限に続いています(『3つの球体』の試み)。このように互いに描き合う「手」を起点として渦巻く無限ループからいったん離れ、自分のしていることをメタ的に眺めることができるのは、人と機械(AI、論理構造、システムetc...)の最大の違いであるという点にホフスタッターは言及するのでした。(こうして今「手」について記述しているぼく自身がしている行為も無限ループの一環に閉じているのかもしれません。)
この、人間と機械の違いというのは、「矛盾」の取り扱いに対して如実に現れ、人は自分の考えに矛盾した点を見つけても、全精神が崩壊したり、思考活動を停止してエラーを吐き出すようなことにはなりません。その代わりに、矛盾を引き起こしたと思われる信念や前提、推論方法を振り返り吟味しはじめます。すなわち、その中で矛盾が生じたと思われるシステムから外に出て、それをメタ的に修復しようと試みること、この「アナロジー」としてエッシャーのこの「描く手」やバッハのカノンのパターン、あるいは、特徴的な「アキレスと亀」の掛け合いを用いて議論が進行していきます。
ちなみに、書籍中の数ページではLispについて言及されている箇所もあって、「すべてのコンピュータ言語の中でもっとも重要でしかも魅力的なもののひとつ」として紹介されています。とくに面白いのが、『描く手』になぞらえて「自分自身の構造の中に入り込み、それを変更するように設計されたLispプログラムについても、これに比類できる両義性が生じうる。もしそれを「Lisp」そのものとして眺めれば、自分自身を変更しているといえるだろう。しかし、レベルを移して、LispプログラムをLispインタープリタのデータと見るなら、事実動いている唯一のプログラムはインタープリタであり、なされた変更は単にデータ片の変更のみでインタープリタ自身の変更は免れている」このように述べられています。

ゲーデル、エッシャー、バッハ―あるいは不思議の環 20周年記念版

ゲーデル、エッシャー、バッハ―あるいは不思議の環 20周年記念版

  • 作者: ダグラス・R.ホフスタッター,Douglas R. Hofstadter,野崎昭弘,柳瀬尚紀,はやしはじめ
  • 出版社/メーカー: 白揚社
  • 発売日: 2005/10/01
  • メディア: 単行本
  • 購入: 14人 クリック: 432回
  • この商品を含むブログ (145件) を見る

メタマジックゲームは、「サイエンティフィック・アメリカン」でホフスタッターが連載していたコラムを中心に一冊にまとめられた書籍でGEBよりも具体的なテーマを扱っています。17章ではまるっと1章「人工知能言語Lispの楽しみ」と題されたLispに関して記載されています。「私個人としては、setqや副作用のある関数を徹底的に嫌っているわけではない。関数型プログラミングのエレガンスも捨てがたいが、これだけで大きな人工知能プログラムを組むのはちょっと無理だと思う。」実に当時から関数型プログラミングの議論の論点は変わっていないみたいです。

メタマジック・ゲーム―科学と芸術のジグソーパズル

メタマジック・ゲーム―科学と芸術のジグソーパズル

  • 作者: ダグラス・R.ホフスタッター,Douglas R. Hofstadter,竹内郁雄,片桐恭弘,斉藤康己
  • 出版社/メーカー: 白揚社
  • 発売日: 2005/10/01
  • メディア: 単行本
  • 購入: 5人 クリック: 47回
  • この商品を含むブログ (41件) を見る

こちらは今年翻訳が出版された書籍で、GEBから40年以上の年月を経た今、ホフスタッターの考える「人間の認知」についての考察がGEB同様様々な視点からつづられています。非常に興奮して一気に読み終えました。

わたしは不思議の環

わたしは不思議の環

2度目のAIの春 (1980–1987)

ゲーデル・エッシャー・バッハ(Gödel, Escher, Bach)の出版を皮切りにして(因果関係があるかどうかは言及しません)、1980年代に入ると再びAIが脚光を浴び始めます。この頃から、AIプログラムの一形態である「エキスパートシステム」が世界中の企業で採用されるようになり、その知識表現がAI研究の中心となっていきました。エキスパートシステムとは、特定領域の知識について質問に答えたり問題を解いたりするプログラムのことで、専門家の知識から抽出した論理的ルールを使用して解を導いていきます。エキスパートシステムはあえて扱う領域を狭くし(それによって1回目の冬の轍を踏まないよう常識的知識の問題を回避する)、単純な設計でプログラムを構築しやすくすると同時に運用中も修正が容易となりました。このエキスパートシステムは実用的なプログラムであり、それまでのAIが到達できていなかった段階にまで到達していくことが可能となったのでした。

Structure and Interpretation of Computer Programs「SICP」(1985)

f:id:long10:20181210145955j:plain:w250

この頃の書籍の代表としてはやはりこちらでしょう。
SICPこと『計算機プログラムの構造と解釈』(Structure and Interpretation of Computer Programs)が刊行されたのが1985年のことでした。この本の中で用いられている例にはすべてScheme(主要なLisp方言の一つ)が用いられていて、抽象化、再帰、インタプリタ、メタ言語的抽象といった計算機科学の概念の真髄が説明されています。
こちらも序文から当時の様子が伺える箇所を少し抜粋してみたいと思います。「Lispは人工知能のため、重要な応用分野でプログラミングの要求を支えてきた。これらの領域は重要であり続け、LispとFortranは少なくとも次の四半世紀では活発に使われるよう、そこでのプログラマは2つの言語に専念しよう。~中略~ これは人工知能の研究の準備に使われるほとんどの書籍と違いプログラミングの教科書であることに注意しよう。つまり、ソフトウェア工学と人工知能におけるプログラミングの重要な関心事が、検討するシステムが大きくなるにつれて合体する傾向なのである。これが人工知能以外でもLispへの関心が増しつつあることへの説明だ。」
最初の苦い冬の経験を経て、人工知能の研究がもたらした様々な課題への取り組みは実用的なプログラミングの問題解決に活用できる汎用的な成果であるということが窺い知れます。

計算機プログラムの構造と解釈 第2版

計算機プログラムの構造と解釈 第2版

  • 作者: ハロルドエイブルソン,ジュリーサスマン,ジェラルド・ジェイサスマン,Harold Abelson,Julie Sussman,Gerald Jay Sussman,和田英一
  • 出版社/メーカー: 翔泳社
  • 発売日: 2014/05/17
  • メディア: 大型本
  • この商品を含むブログ (4件) を見る

AIとLispの冬 (1987−1993)

1980年代後半となり、再びAIに冬の時代が訪れます。そしてこの冬はAIとともにLispにとっても過酷な冬の時代でした(四半世紀で活発に使われることを望んでいたのですが)。
その最初の兆候となったのは、1987年にAI専用ハードウェアの市場が突然崩壊したことでした。その背景にはアップルやIBMのデスクトップコンピュータの性能が徐々に向上していったことがあり、当時非常に高価だったAI専用のLispマシンは性能的に凌駕され、Lispマシン5億ドルの市場があっという間に消えてしまったためでした。
それから1990年代初頭にかけて、AIには確率と決定理論がもたらされ大きな影響を受けることとなりました。ここで多くの実用化されたツールが、ベイジアンネットワーク、隠れマルコフモデル、情報理論、確率的モデリング、古典的最適化などを活用し始めることになりました。
またこの頃から、ニューラルネットワークや進化的アルゴリズムといった「計算知能」パラダイムのための正確な数学的記述も発展してきたのでした。この発展によりもたらされた再現性の恩恵によって、元来はAI研究者が開発したアルゴリズムだったプログラムなどが大規模システムの一部として使われ始めたのでした。
この流れによって、SICPでも述べられていた通り、実はそれまでAI研究を通じて様々な非常に難しい問題が解決されてきたという事実、そして、その解法は極めて実用的であったことが証明されはじめたのでした(例えば、データマイニング、産業用ロボット、物流、音声認識、銀行のソフトウェア、医療診断、Googleの検索エンジンなどがまさにその例です)。ところが残念なことに、こういった良い流れがありながら、それらの産業における実用的成功が、実はAIのおかげだという事実が世間的に知られることはほとんどありませんでした。それらの技術革新の多くは、達成と同時に計算機科学のありふれたアイテムの一つとして扱われたのです。一体なぜなのでしょうか?
ニック・ボストロムはこれを「AIの最先端の多くは、十分に実用的で一般的になったとたんAIとは呼ばれなくなり、一般のアプリケーションに浸透していく」と的確に説明されています。そういった背景にあって、1990年代の当時の多くのAI研究者は、意図的に自らの仕事をAIとは別の名前で呼んでいたのでした(例えば、インフォマティクス、知識ベース、認知システム、計算知能など)。その理由の一部には、確かに彼らが自分の研究をAIとは全く異なるものだと思っていたということもありますが、背景的にこのような新しい名前をつけることで資金提供を受けられるという面も少なからずあったのです。
とくに産業界では「最初のAIの冬」がもたらした失敗の影が依然として払拭されておらず、ニューヨークタイムズ紙は「無謀な夢を見る人とみなされることを恐れ、計算機科学者やソフトウェア工学者は人工知能という用語の使用を避けた」そのように評しました。

HAL 9000 はどこに?

f:id:long10:20181214100512p:plain

2001年宇宙の旅

さて話はころっと変わりますが、1968年、アーサー・C・クラークとスタンリー・キューブリックは、2001年(もうだいぶ昔に感じますが...)には人間並みか人間を越えた知性を持ったマシンが存在するだろうと想像していました。彼らが創造したHAL 9000は、当時(最初のAIブームの頃)のAI研究者が2001年には存在するだろうと予測していたものだったのです。(このエピソードだけでもだいぶ楽観的だなという感じがします..)これに対して、マービン・ミンスキーは、「そこで問題は、なぜ我々は2001年になってもHALを実現していないのかだ」と問題提起したのでした。

実用Common Lisp

f:id:long10:20181206114232j:plain:w250

1991年にピーター・ノーヴィグによって発表されたこちらの書籍は、人工知能とCommon Lispにおける古典と位置づけられている名著です。
この本が扱っているトピックは、人工知能(AI)、コンピュータプログラミング技術、プログラミング言語Common Lispの3つです。この本をていねいに読めば、AIに対する多くの疑問が解けると同時に、重要なAIの技法も理解できます。実例として、AIの研究で実際に使用されるテキストが多く含まれています。これらは、かつてのAI領域の重要な問題を解決へと導いた応用範囲の広い技法を使用したプログラムの一部でもあります。GPS問題、ELIZA、エキスパートシステムなどAIに2度目の春をもたらした当時の息吹を感じとることができます。

実用Common Lisp

実用Common Lisp

Deep Learningの芽生え

2度目の冬は果てしなく長く続きました。
そしてようやく2000年代となり、今ではよく知られている制限ボルツマンマシンやコントラスティブ・ダイバージェンスの提案が徐々に行われ始めました。これらの提案によって、あのディープラーニングの発明に向かう道筋がつくられていくことになっていくのでした。そしてついに、2006年にジェフリー・ヒントンによるオートエンコーダを利用したディープラーニングが発明されました。この発明は支持され、とくに人手を介さず特徴量を抽出できる点で、人間による知識表現の必要が無くなり、近年のAIにおける大きなブレイクスルーをもたらすこととなるのでした。そしてこの瞬間に、長らく暗黒時代を迎えていたコネクショニズムが突如として復活することとなりました。また同時に、人間が知識表現を行うことで生じていた記号設置問題も解決されたのでした。
ハード面でも、再び春の到来を待ちわびていたかのように、2010年頃にはインターネット上のデータ転送量の指数関数的な増大を受けて、ビッグデータという用語が誕生しそれを取り扱うハードウェアの発展への兆しを見せ始め、そしてまた後を追うようにして、2012年の物体の認識率を競うILSVRCにおける、GPU利用による大規模ディープラーニング(ジェフリー・ヒントンの率いる研究チームがAlex-netで出場した)の大幅な躍進があり、同年のGoogleによるディープラーニングを用いたYouTube画像からの猫の認識成功の発表など、世界各国において再び人工知能研究に注目がぐっと集まり始めたのでした。これ以降のディープラーニングの目覚ましい発展については記憶に新しいところです。

f:id:long10:20181215125517j:plain

こちらの図はガートナー社が今年発表した「先進テクノロジーハイプサイクル」です。ディープラーニングを見ると、過度な期待のピーク期にあるとされていて、いずれは幻滅期へと向かうとされています。しかし、AIは幾度の春と冬とを乗り越えた結果として現在に至り、轍を踏むことなく過度な期待を慎重に実用に転換してきました。「ディープラーニング活用の教科書」の中で、ディープラーニングは今後『ジェネラル・パーパス・テクノロジー(GPT)となっていく』という松尾先生の視点が示されています。GPTというのは、汎用的な目的に利用できる技術のことで、古くは車輪の開発や内燃機関の発明から、最近ではインターネットやナノテクノロジーといった汎用的な技術を指します。かつて2度目の冬では、あえてAI研究を隠していたわけですが、そういうネガティブな意味ではなく、ニック・ボストロムのいう「AIとは呼ばれなくなり、一般のアプリケーションに浸透していく」状態がごく自然な形で浸透して、この先の20年では春や冬という尺度ではないより高次元の汎用的な技術の一つとして扱われることで、これまで以上の驚くべき発展を遂げていくのではないでしょうか?

ディープラーニング活用の教科書

ディープラーニング活用の教科書

まとめ・そしてHylangという選択

いかがでしたでしょうか?
「Lispの車窓から見た人工知能」ということで、現在における人工知能の源流から現在に至るまでの風景をLispというプログラミング言語の視点からご紹介してきました。Lispと人工知能がどちらもジョン・マッカーシーを父に持つ存在として誕生し、幾度となく春と冬の季節の移り変わりを経て、Lispマシン=AIと認知されていた頃に比べると、今ではLispは数あるプログラミング言語の一つとして、人工知能はGPTになりうる技術として、それぞれ別々の道を進むこととなりました(もちろんLispで機械学習のプログラミングができないという意味ではないです)。マッカーシーを始めとした多くの先人たちは、天国から今の様子をどんな思いで眺めていらっしゃることでしょうか。この先、HAL 9000が誕生する日は果たしてやってくるんでしょうか。今回こうしてLispの車窓から人工知能の風景を眺めてみたことで、ぼく自身も広義においてはAI分野を生業にしている端くれである以上、今後も近い場所でこの発展を眺めていけるように日々技術の研鑽や知識のアップデートに努めていきたいと改めて感じることができてとても有意義であったと思っています。

Hylang

f:id:long10:20181215160231p:plain

おまけですが、それでもS式を書きたい!そういう方のための選択肢としてHylangをご提案したいと思います。「Lisp and Python should love each other.」ということで、pythonの資産をすべからくS式で利用できるというプログラミング言語なっています。github.com

↓こちらから面白いreplが試せます。(powered by Symbolics, Inc.!!)

try-hylang

インストールはpipで普通にできます。

pip install hy

jupyter notebook のカーネルとしてhyを利用したい場合はcalysto_hyをインストールします。

pip3 install git+https://github.com/ekaschalk/jedhy.git
pip3 install git+https://github.com/Calysto/calysto_hy.git
python3 -m calysto_hy install

Common Lispの例で示した階乗計算も、hyではこんな感じで同じように書けます。

(defn fact [n]
  (if (= n 0)
    1
    (* n (fact (- n 1)))))

マクロの定義もこのように書けます。

(defmacro incf [var &optional [diff 1]]
  `(setv ~var (+ ~var ~diff)))

(defn plus [&rest args]
  (let ((sum 0))
    (for [i args] (incf sum i))
    sum))

letがないのでletをマクロで追加します。

(defmacro let [var-pairs &rest body]
  (setv var-names (list (map first  var-pairs))
        var-vals  (list (map second var-pairs)))
  `((fn [~@var-names] ~@body) ~@var-vals))

↓詳しくはチュートリアルを参照してください。

Tutorial — hy 0.15.0 documentation

では、簡単なMNISTをTensorflowでトレーニングするチュートリアル・プログラムをちょっとだけhyで示したいと思います。

まず、importから

(import [tensorflow.examples.tutorials.mnist [input_data]])
(import [tensorflow :as tf])

MNISTデータを取得します。

(setv mnist (input_data.read_data_sets "MNIST_data/" :one_hot True))

設定やデータ加工をもろもろおこないまして、

(setv sess (tf.InteractiveSession))
(setv x (tf.placeholder tf.float32 [None 784]))
(setv y_ (tf.placeholder tf.float32 [None 10]))
(setv W (tf.Variable (tf.zeros [784 10])))
(setv b (tf.Variable (tf.zeros 10)))
(sess.run (tf.global_variables_initializer))
(setv y (+ (tf.matmul x W) b))
(setv cross_entropy (tf.reduce_mean (tf.nn.softmax_cross_entropy_with_logits :labels y_ :logits y)))
(setv train_step (.minimize (tf.train.GradientDescentOptimizer 0.5) cross_entropy))
(for [_ (range 1000)]
     (setv batch (mnist.train.next_batch 100))
     (sess.run train_step :feed_dict {x (get batch 0) y_ (get batch 1)}))
(setv correct_prediction (tf.equal (tf.argmax y 1) (tf.argmax y_ 1)))
(setv accuracy (tf.reduce_mean (tf.cast correct_prediction tf.float32)))
;; (print (accuracy.eval :feed_dict {x mnist.test.images y_ mnist.test.labels}))

必要な関数をいくつか定義します。

(defn weight_variable [shape]
     (setv initial (tf.truncated_normal shape :stddev 0.1))
     (tf.Variable initial))

(defn bias_variable [shape]
     (setv initial (tf.constant 0.1 :shape shape))
     (tf.Variable initial))

(defn conv2d [x W]
     (tf.nn.conv2d x W :strides [1 1 1 1] :padding "SAME"))

(defn max_pool_2x2 [x]
     (tf.nn.max_pool x :ksize [1 2 2 1] :strides [1 2 2 1] :padding "SAME"))

関数を用いて変数にデータを入れていきます。

(setv W_conv1 (weight_variable [5 5 1 32]))
(setv b_conv1 (bias_variable [32]))

(setv x_image (tf.reshape x [-1 28 28 1]))
(setv h_conv1 (tf.nn.relu(+ (conv2d x_image W_conv1) b_conv1))) 
(setv h_pool1 (max_pool_2x2 h_conv1))

(setv W_conv2 (weight_variable [5 5 32 64]))
(setv b_conv2 (bias_variable [64]))

(setv h_conv2 (tf.nn.relu(+ (conv2d h_pool1 W_conv2) b_conv2))) 
(setv h_pool2 (max_pool_2x2 h_conv2))

(setv W_fc1 (weight_variable [(* 7 7 64) 1024]))
(setv b_fc1 (bias_variable [1024]))
(setv h_pool2_flat (tf.reshape h_pool2 [-1 (* 7 7 64)]))
(setv h_fc1 (tf.nn.relu (+ (tf.matmul h_pool2_flat W_fc1) b_fc1)))

(setv keep_prob (tf.placeholder tf.float32))
(setv h_fc1_drop (tf.nn.dropout h_fc1 keep_prob))

(setv W_fc2 (weight_variable [1024 10]))
(setv b_fc2 (bias_variable [10]))
(setv y_conv (+ (tf.matmul h_fc1_drop W_fc2) b_fc2))

(setv cross_entropy (tf.reduce_mean (tf.nn.softmax_cross_entropy_with_logits :labels y_ :logits y_conv)))
(setv train_step (.minimize (tf.train.AdamOptimizer 1e-4) cross_entropy))
(setv correct_prediction (tf.equal (tf.argmax y_conv 1) (tf.argmax y_ 1)))
(setv accuracy (tf.reduce_mean (tf.cast correct_prediction tf.float32)))

そして、最後にトレーニングを実行します。pythonで書く場合とほとんど同じですね。

(with (sess (tf.Session))
     (sess.run (tf.global_variables_initializer))
     (for [i (range 20000)]
         (setv batch (mnist.train.next_batch 50))
         (when (= (% i 100) 0)
             (setv train_accuracy (accuracy.eval :feed_dict {x (get batch 0) y_ (get batch 1) keep_prob 1.0}))
             (print (.format "step {0}, training accuracy {1:.2f}" i train_accuracy)))
         (train_step.run :feed_dict {x (get batch 0) y_ (get batch 1) keep_prob 0.5}))
     (print (.format "test accuracy {:.3f}"  (accuracy.eval :feed_dict {x mnist.test.images y_ mnist.test.labels keep_prob 1.0}))))

さいごに

明日は、お待ちかね弊社CTO大竹が「越境型スキルのすゝめ」というタイトルで投稿します!
お楽しみに!

【Vue.js】算出プロパティの仕組みについて調べてみた

この記事はdely Advent Calendar 2018の21日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely
Adventar: https://adventar.org/calendars/3535

前日は、iOSデザインエンジニアの John が
デザインについてエンジニアなりに意識していること という記事を書きました。 デザイナーとエンジニアがどううまく連携していくかがわかりやすく書かれています。

はじめに

こんにちは、delyでサーバサイドエンジニアをやっている山野井といいます。
普段の業務ではweb版 kurashiru(www.kurashiru.com)のサーバサイド周りを主に担当していて、たまにフロントエンドを触ったりもしています。

web版 kurashiruでは Javascript のフレームワークに Vue.js を使用しています。
通常、Vue.jsは内部実装をそこまで深く理解する必要がなくとも十分に扱えますが、自分のスキルアップの為や、普段の業務で何かヒントになりそうだと思い調べてみました。

本記事では、算出プロパティ(以下computedプロパティ)がどのように結果をキャッシュして依存された値の変更を検知しているか調べて分かったことを、解説していきたいと思います。

(注)解釈が間違っている可能性もございますが、温かい目で見守っていただけると嬉しいです。  

今回解説に使用するサンプルコード

今回は、下記のサンプルコードをベースに解説していきます。
Vue.js@2.5.17

このコードは、マウントしたエレメント(#sampleApp)に対して computed_message を表示するようなシンプルなサンプルになっています。

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
  </head>
  <body>
    <div id="sampleApp"></div>
   
    <script>
      var vue = new Vue({
        data: {
          message: 'Hello, World'
        },
        computed: {
          computed_message() {
            return `Computed ${this.message}`
          }
        },
        render(h) {
          return h('div', this.computed_message)
        }
      })
      vue.$mount('#sampleApp')
    </script>
  </body>
</html> 

このコードをブラウザで開くと、'Computed Hello, World'という文字列がブラウザ上に表示されます。 f:id:yamanoi-y:20181216174114g:plain 作成された Vueインスタンス の $data.message を devTool 等で書き換えると、同時に computed_message の値も更新され、画面上の文字も更新されることがわかると思います。

computedプロパティの基本の動き

computedプロパティは作成されると Vueインスタンス に同名のプロパティが定義されます。

サンプルコードでは computed_message が定義されているため、Vueインスタンスに computed_message という名前のプロパティが生えます。 (this.computed_message として Vueインスタンス内からアクセスすることができるようになります。)

また computedプロパティ は一度アクセスされると、依存されたプロパティが変更されない限りは常に同じ値を返し続けます。
つまりどういうことかというと、computed_message は一度実行された後その結果("Computed Hello, World")を内部で保持しておき、2回目以降はその保持した結果を返し続けます。 これにより、高速に処理を行うことができる様になっています。

依存しているプロパティ(message)の値が "Hello, World" から "hogehoge" に変更されたら結果を更新する必要があるため、再度関数を評価し、その結果("Computed hogehoge")を保持し、返しているわけです。​

そのため computedプロパティ は依存しているプロパティの変更を逐一知る必要があります。

computedプロパティが data の変更を知る仕組み

Vue.js ではこの変更を知る仕組みをデザインパターンで言うオブザーバーパターンで実装されていて、dataプロパティ の各値が更新されると Watcherクラス(src/core/observer/watcher.js)  のインスタンス(以下watcher)へと通知される仕組みになっています。

f:id:yamanoi-y:20181215235428j:plain

各 computedプロパティ は  Vueインスタンス を生成する際にこの watcher を生成しています。 今回のサンプルコードでは computedプロパティ である computed_message が watcher を生成し、message の変更はこの watcher へと通知されます。

通知を受け取った watcher は update() を実行します。

update() 内では dirty を true にして依存されたプロパティに変更があった事を保持しておきます。

export default class Watcher {
  ...
  
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
 
  ...
}

  今回 message の変更を通知する先は computed_message が所持する watcher のみになりますが、message に依存している複数の computedプロパティ が定義されている場合は全ての watcher に対して mesage の変更を通知させる必要があります。

そこで依存関係を構築する Depクラス(src/core/observer/dep.js ) が登場します。 Depクラス は下記の様な、ユニークidと subs という watcher の配列を持ったクラスになります。

  export default class Dep {
    static target: ?Watcher;
    id: number;
    subs: Array<Watcher>;
 
    ...
    ...
    ...
  
    notify () {
      const subs = this.subs.slice();
      for (let i = 0, l = subs.length; i < l; i++) {
        subs[i].update();
      }
    }
  }

 data の各プロパティは Vueインスタンス を生成する際に Depクラスインスタンス(以下dep) を生成します。
サンプルコードでは message が data として定義されているため、message の dep が生成されます。 この dep の subs に 通知すべきwatcher を格納することで data と 複数の watcher の対応関係を作ることができます。  data(message) が更新されると生成された dep の notify() を通して依存している全ての watcher へ通知されます。

f:id:yamanoi-y:20181215235431j:plain

dataプロパティの変更を検知する

 そもそも dataプロパティ の変更の検知自体はどの様に行われているかと言うと Object.definePropertyを用いて実現されています。

Object.definePropertyを使用すると任意のオブジェクトに対して独自の getter や setter を生やすことができます。

data = {}
Object.defineProperty(data, 'message', {
  enumberable: true,
  configurable: true,
  get: function() {
    console.log('called getter')
    return this._message
  },
  set: function(value) {
    console.log('called setter')
    this._message = value
    return
  }
});
 
data.message = 1 // called setter
data.message  // called getter

これを使用して、各dataプロパティの setter に 値をセットした後にwatcher に通知する処理を書いてあげることで値の変更通知処理を実現することができます。

実際のコードは下の様になっています。

src/core/observer/index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()
 
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
 
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
 
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

setter であるreactiveSetter の中で dep.notify() を呼んでいるのが分かると思います。

依存関係の構築

ここまでで message が変更された時、message の持つ dep を通して subs に対して通知を送れる様になりました。

最後に computedプロパティ と dataプロパティ がどのようにして依存関係を構築しているのかを見ていきます。

これは watcher が computedプロパティ の getter を評価する時と、先程の dataプロパティ の getter である reactiveGetter 内に出てきたdep.depend() が関係してきます。

まずは computedプロパティ の初期化の部分から追ってみます。

 computedプロパティ の初期化は Vueインスタンス の初期化フローにて行われます。

src/core/instance/state.js

function initComputed (vm: Component, computed: Object) {
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()
 
  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
 
    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
 
    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}

  for文で定義されている computedプロパティ を1つずつ取り出して Watcherインスタンス を作成しています。
 Watcher を new する際に Vueインスタンス である vm , computedプロパティ の関数本体である getter , その他オプションを渡しています。
その後呼び出されている defineComputed のコードは以下になります。

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
 
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

  先程登場した Object.defineProperty で target(vm) に対して computedメソッド名 をキーにしてプロパティを定義しています。

これは'computedプロパティの基本の動き'で述べた

computedプロパティは作成されると Vueインスタンス に同名のプロパティが定義されます。

の正体です。

さてこれで Vueインスタンス内から this.computed_message と呼び出すことができるようになったのですが this.computed_messageと呼び出された時の処理は Object.defineProperty によって以下のようになっていますね。

sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
 

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

watcher.dirty の場合 watcher.evaluate() を実行しています。watcher.evaluate() は watcher の作成時に渡したgetter(computedプロパティの関数本体)を評価し、結果を value に代入しています。

一度実行した結果を value に保持していて、 watcher.dirty な状態にならない限りは watcher.value を返し続けています。

こちらも最初に述べました

また computedプロパティ は一度アクセスされると、依存されたプロパティが変更されない限りは常に同じ値を返し続けます。

の部分になります。

このdirtyがtrueになる時というのは watcher.update() が呼び出された時、つまり dataプロパティ に変更があり、 watcher へ通知された時となります。

下の図は message に変更があった時に this.computed_message が呼び出された時の様子です。

f:id:yamanoi-y:20181218153330p:plain

watcher.evaluate()で行っている処理は大きく分けて以下の4ステップになります。

  1. pushTarget
  2. getter.call
  3. popTarget
  4. dirty=falseにする

1.pushTarget

staticな値 Dep.target に対してwatcher自身を代入します。

2.getter.call

getter.call をしてcomputedプロパティの関数を評価します。

関数を評価するということはその中で this.message が呼び出され、this.message の getterの中に定義されていた dep.depend() も呼び出されます。

...
 
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

...

dep.depend()Dep.Target が存在する時に、 Dep.targetaddDep() を自身を引数にして呼び出します。 初めに Dep.target にはステップ1にて watcher を格納していたため、その watcher の addDep() が呼び出されます。

src/core/observer/watcher.js

class Watcher {
  ... 
  
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
  
  ....
}

src/core/observer/dep.js

class Dep {
  ...

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  
  ...
}

渡された dep が watcher の newDepIds にまだ存在しない場合は追加して、その後 dep.addSub(this) としています。

これで

dep.subs = [watcher]

の依存関係を作ることができました。

例えば  computed_message が次の様に定義されているとすると

new Vue({
  data: {
    message: 'Hello, World',
    name: 'tarou',
  },
  computed: {
    computed_message() {
       return `Computed ${this.message} by ${this.name}`
     }
  },
})

下の図のようにそれそれのdepがwatcherと依存関係を構築します。

f:id:yamanoi-y:20181218161325p:plain

そして以下の様な依存関係が構築されることになります。

message => dep => [watcher]
name    => dep => [watcher]

1つのdataプロパティが複数の computedプロパティ に依存している場合は

new Vue({
  data: {
    message: 'Hello, World',
  },
  computed: {
    computed_message() {
      return `Computed ${this.message}`
    },
    introduction() {
      return `My First ${this.message}`
    },
  },
})

以下の様な依存関係になります。

message => dep => [watcher(computed_Message), watcher(introduction)]

この様にして Vue.js では computedプロパティ の依存関係を構築しています。

3.popTarget

Dep.targetに代入されいてたwatcherを取り除きます。

4.dirty = false にする

次回 this.computed_message にアクセスがあった時に計算済みの値 value を返すようにするため、dirtyをfalseにします。

watcher.evaluate() の実行を経て、 data プロパティと watcher の依存関係を構築することができました。

まとめ

長くなりましたが、解説は以上となります。 実際にソースコードレベルで調べることでよりフレームワークへの理解を深めることができたような気がします。
Vue.js 3.0では今回解説したObject.definePropertyを用いた監視方法から変更になるようなので機会があればそちらも解説できたらと思います。
最後までお付き合いありがとうございました。

明日は、弊社の機械学習エンジニアの辻より'Lispの車窓から見る人工知能'の記事がアップされる予定です。 こちらもぜひご覧ください!  

サーバーレス+Go言語で作るインタラクティブな哲学slackBot

本記事はdely Advent Calendar 2018の19日目の記事です。

Qiita : dely Advent Calendar 2018 - Qiita
Adventar : dely Advent Calendar 2018 - Adventar

前日は、弊社でSREをしている井上がkurashiruのデプロイについて記事を書きましたので是非読んでみてください! tech.dely.jp

はじめに

こんにちは。サーバーサイドエンジニア兼、新米slackBot整備士のjoe (@joooee0000) です。 前回の11日目の記事では、くだらないslackBotを作るモチベーションやBotアプリの作成方法について書かせていただきました。

tech.dely.jp

本記事では、InteractiveMessages(InteractiveComponents)を用いた哲学slackBotの実装面について紹介させていただきます。
また、slackでは

をつけることができますが、今回はボタンを使っています。
(こちらのような見た目です)

f:id:joe0000:20181217151059p:plain

(深い。。。)

機能としては、

  • 数百種類の哲学名言の中から毎朝9:30に1つをつぶやく
  • メンバーがボタンを押して哲学を評価する
  • 誰がどのように評価をしたかがslackにポストされる

というシンプルなBotです。これだけでも、みんなが参加してくれて盛り上がったりします! 1つの哲学に対してみんなの受け取り方が全く違ったりするので、今やとても興味深いです。

構成図

f:id:joe0000:20181218194353p:plain

インタラクティブなBotを構成する要素は二種類あります。

  1. ボタン付きのメッセージをチャンネルにポストする
  2. ボタン付きのメッセージへのユーザーのリアクションを受け取り、処理する

こちらの二種それぞれについて書いていきます。

また、slackBotの実装を始めるには、slackのアプリを作成します。アプリの作成に関しては、11日目の記事に書いてあるので、参照してください。

ボタン付きメッセージをポストする

最初に、ボタン付きメッセージを決まった時間にチャンネルにポストする部分について書きます。 組み合わせは、下記のシンプルな構成です。

  • Go言語
  • DynamoDB
  • AWS SAM
    • AWS Lambda
    • AWS CloudWatch

哲学名言はクロールしてDynamoDBにデータを保持しています。クロールの部分やDynamoDBへのデータ保持について話すと長くなってしまうので、あらかじめデータが入ったDynamoDBが用意されている前提で話します。

1. Go言語でメッセージをポストするコードを書く

コードは、Lambdaで実行する用に書いていきます。 こちらが簡略化したサンプルコードです。長くなりすぎないように、DynamoDBから哲学用語を取得する部分などは省略しています。

package main

import (
    "errors"
    "log"
    "math/rand"
    "strconv"
    "syscall"
    "time"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/dynamodb"
    "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
    "github.com/nlopes/slack"
)

type PhilosophicalWord struct {
    PhilosophicalWordId int    `dynamodbav:"philosophical_word_id"`
    FormedText          string `dynamodbav:"formed_text"`
    PlainText           string `dynamodbav:"plain_text"`
    ScholarName         string `dynamodbav:"scholar_name"`
}

func getRandomPhilosophyWordByDynamoDB() (result *PhilosophicalWord, err error) {
   // DynamoDBから哲学名言を取得する処理
    return
}

func getIconEmoji(scholarName string) (emojiStr string) {
   // 絵文字の文字列を学者の名前から取得する処理
    return 

func handleSendPayload(client *slack.Client, channelId string, sendText string, scholarName string) error {

    actionName := "philosophical_value"
    attachment := slack.Attachment{
        Color:      "#42cbf4",
        CallbackID: "philosophical",
        Fields: []slack.AttachmentField{
            {
                Title: "Please evaluate the word!! :smirk_cat:",
            },
        },
        Actions: []slack.AttachmentAction{
            {
                Name:  actionName,
                Text:  "すごく微妙",
                Type:  "button",
                Style: "danger",
                Value: "minus2",
            },
            {
                Name:  actionName,
                Text:  "微妙",
                Type:  "button",
                Style: "danger",
                Value: "minus1",
            },
            {
                Name:  actionName,
                Text:  "普通",
                Type:  "button",
                Value: "zero",
            },
            {
                Name:  actionName,
                Text:  "良い",
                Type:  "button",
                Style: "primary",
                Value: "plus1",
            },
            {
                Name:  actionName,
                Text:  "すごく良い",
                Type:  "button",
                Style: "primary",
                Value: "plus2",
            },
        },
    }

    params := slack.PostMessageParameters{
        IconEmoji: getIconEmoji(scholarName),
        Username:  scholarName,
    }

   // メッセージの本文を定義する処理
    msgOptText := slack.MsgOptionText(sendText, true)
   // 必須項目を定義する処理
    msgOptParams := slack.MsgOptionPostMessageParameters(params)
   // ボタンを定義する処理
    msgOptAttachment := slack.MsgOptionAttachments(attachment)

   // メッセージ送信処理
    if _, _, err := client.PostMessage(channelId, msgOptText, msgOptParams, msgOptAttachment); err != nil {
        log.Println("Slack PostMessage Error")
        return err
    }
    return nil
}

func LambdaHandler() error {
    oauthAccessToken, found := syscall.Getenv("OAUTH_ACCESS_TOKEN")
    if !found {
        log.Print("OAuth Access Token Not Found")
        return errors.New("OAuth Access Token Not Found")
    }

    channelId, found := syscall.Getenv("CHANNEL_ID")
    if !found {
        log.Print("Channel Id Not Found")
        return errors.New("Channel Id Not Found")
    }

    result, err := getRandomPhilosophyWordByDynamoDB()
    if err != nil {
        log.Print("DynamoDB Error")
        return err
    }

    sendText := result.FormedText
    scholarName := result.ScholarName

    client := slack.New(oauthAccessToken)

    handleSendPayload(client, channelId, sendText, scholarName)
    return nil
}

func main() {
    lambda.Start(LambdaHandler())
}

slackのAPI Clientには、nlopes/slackというpackageを使用しています。

今回はメセージをチャンネルに送ることが目的なので、こちらのpackageの中でもchat.goPostMessage関数を使ってslackチャンネルにメッセージを送信しています。

nlopes/slackPostMessageは、要件をslackメッセージの要素ごとに分解して設定できるようになっています。

ざっくり言うと、

  • PostMessageParameters: メッセージ送信時の必須パラメータ部分(ピンク部分)
    • ユーザー名やアイコンの設定など
  • MsgOptionText: メッセージ本文(黄色部分)
  • MsgOptionAttachments: アタッチメント部分(青部分)
    • ボタンの設定

f:id:joe0000:20181217152218p:plain

という構成です。 nlopes/slackでは、上記であげたそれぞれの要素が構造体として定義されています。PostMessageの引数は可変引数となっており、必要な型だけを引数として指定することができます。
例えば、ユーザー名やアイコンの設定はデフォルトでよく、ボタンも不必要なメッセージのみを送る場合は、

msgOptText := "適当"
client.PostMessage("your-channel-name",  msgOptText)

だけでシンプルなメッセージを送信することができます。

また、アタッチメント部分(今回だとボタンを定義している部分)もいくつかの構造体の入れ子になっており、AttachmentField(アタッチメント部分に記載できるテキストなど)やAttachmentAction(ボタンやプルダウンメニューなどのアクションの具体的な内容を指定するところ)を指定できるようになっています。ここら辺の値をやりたいことに対して柔軟に変えることでカスタマイズしていきます。

ここに書いたこと以外にも、nlopes/slack をつかって様々なことができるので、詳しくはコードを読むか、docsを参照してください。

2. AWS SAMで定期実行するLambdaを構築する

AWS SAMの設定ファイルを作成する

AWS SAMとは、サーバーレスアプリケーションモデルの略で、AWSでサーバーレスアプリケーションを構築するために使用することができるオープンソースフレームワークです。テンプレートに必要な情報を記入することで、サーバーレスアプリケーションの構築を手軽に行うことができます。(本当に手軽にできます)

下記のサンプルは、

  • Lambdaを動かすためのIAM Roleの作成
  • 毎日朝9:30に稼働するメッセージポスト用Lambdaの設定(1で作成したLambdaコード)

が記述されています。 哲学Botは、DynamoDBに哲学用語をためているため、DynamoDBへのアクセス権限もつけています。

また、ソースコードに載せられないセキュアな情報は環境変数にしてLambdaから呼び出すようにしています。
Systems Manager パラメータを使って設定した変数をLambdaのコード内から呼び出せるように、Parametersという項目を指定します。
今回は、

  • チャンネルID (slackのチャンネル名)
  • OAuth Access Token (slackのAPIを呼び出すためのトークン)

をパラメータ化して環境変数として呼び出せるようにしています。 Systems Manager パラメータの設定の仕方は、こちらを参照してください。

# template.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Create Lambda function by using AWS SAM.
Parameters:
  ChannelId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /philosophy_bot/channel_id
  OauthAccessToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /philosophy_bot/oauth_access_token
Resources:
  LambdaIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        -
          PolicyName: "philosophy-slack-bot"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action: "dynamodb:*"
                Resource: "*"
              -
                Effect: "Allow"
                Action: "cloudwatch:*"
                Resource: "*"
  PhilosophySlackBot:
    Type: AWS::Serverless::Function
    Properties:
      Handler: philosophy-slack-bot
      Runtime: go1.x
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          CHANNEL_ID: !Ref ChannelId
          OAUTH_ACCESS_TOKEN: !Ref OauthAccessToken
      CodeUri: build
      Description: 'Post philosophical word to slack'
      Timeout: 30
      Events:
        Timer:
          Type: Schedule
          Properties:
            Schedule: cron(30 0 * * ? *) # JST 09:30

デプロイする

デプロイするにあたって、下記を済ませておく必要があります。

  • IAM Roleの設定
    • こちらで言う所のIAM Roleの設定は、AWS SAMを動かすための権限付与
    • (正しく解説できる自信がな記事が長くなりすぎるため詳細の説明を省略)
  • AWS CLIのセットアップ

先ほど作成したAWS SAMのtemplate.ymlを使って、aws-cliのコマンドでデプロイすることができます。

# deploy.sh
#!/usr/bin/env bash
cd ./slack_bot
GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot
cd ../
zip build/philosophy-slack-bot.zip build/philosophy-slack-bot

aws cloudformation package --profile yourprofile \
    --template-file template.yml \
    --s3-bucket serverless \
    --s3-prefix philosophy-slack-bot \
    --output-template-file .template.yml
aws cloudformation deploy --profile yourprofile \
    --template-file .template.yml \
    --stack-name philosophy-slack-bot \
    --capabilities CAPABILITY_IAM

こちらのスクリプトでは、前半の部分でGoのコードのデプロイパッケージ(コードと依存関係で構成される .zip ファイル)を作成しています。

windowsにおけるGoのデプロイパッケージの作り方が少し違うようなので、デプロイを実行するOSによって書き方を変える必要があります。こちらのサンプルコードは、MacOSで作成する場合のサンプルとなっています。詳しくは、こちらをご覧ください。

また、ここで紹介しているサンプルでは、下記のようなファイルの階層を想定しています。

.
├── build
├── deploy.sh
├── slack_bot
│   └── main.go
└── template.yml

これで、deploy.shを実行するだけでLambdaが指定したevent通りの時間に定期実行されるようになります。

ここまでで、メッセージの見た目はslackチャンネルにポストできるようになりました!!

f:id:joe0000:20181217233108p:plain

(この時点では、ボタンを押してもなにも起こらない)

ボタン付きメッセージへのユーザーのリアクションを受け取る

ボタンをおしたらアクションが起こるようにしていきます。

1. Go言語でユーザーのリアクション情報を受け取るコードを書く

ユーザーがボタンを押すと、設定したURLにPOSTリクエストが届きます。 なので、常にリクエストを待ち受けるAPIにしておく必要があります。

構成は、下記のようなシンプルなものです。

  • Go言語
  • DynamoDB
  • AWS SAM
    • AWS Lambda
    • AWS API Gateway

最初に、API Gatewayにslackからリクエストがきた時のLambdaの処理を書いていきます。 処理は、下記のような手順で行います。

  1. callbackレスポンスをParseする
  2. VerificationTokenをチェックする
  3. 結果を確認し、所望の処理をする
  4. リクエストのレスポンスとして、callbackレスポンスの中のoriginal_messageと同じ形式のレスポンスを返す

1. callbackレスポンスをParseする

リクエストをJSON形式にParseすると、このような形式のレスポンスが返ってきます。 返ってきたレスポンスを、nlopes/slack packageが定義してくれている slack.InteractionCallback 型にマッピングします。

{  
   "type":"interactive_message",
   "actions":[  
      {  
         "name":"philosophical_value",
         "type":"button",
         "value":"plus1"
      }
   ],
   "callback_id":"philosophical",
   "team":{  
      "id":"xxx",
      "domain":"xxx"
   },
   "channel":{  
      "id":"xxx",
      "name":"xxx"
   },
   "user":{  
      "id":"xxx",
      "name":"joe"
   },
   "action_ts":"1544247183.026560",
   "message_ts":"1544247178.002000",
   "attachment_id":"1",
   "token":"DUMMYDUMMYDUMMY",    # VerificationToken
   "is_app_unfurl":false,
   "original_message":{  
      "type":"message",
      "subtype":"bot_message",
      "text":"言い違い、聞き違い、読み違い、書き違いは受ける側の願望を表わしてる。 - ジークムント・フロイト- ",
      "ts":"1544247178.002000",
      "username":"ジークムント・フロイト",
      "icons":{  
         "emoji":":tetsu_freud:"
      },
      "bot_id":"xxxxxx",
      "attachments":[  
         {  
            "callback_id":"philosophical",
            "id":1,
            "color":"42cbf4",
            "fields":[  
               {  
                  "title":"please score the word!! :smirk_cat:",
                  "value":"",
                  "short":false
               }
            ],
            "actions":[  
               {  
                  "id":"1",
                  "name":"philosophical_value",
                  "text":"すごく微妙",
                  "type":"button",
                  "value":"minus2",
                  "style":"danger"
               },
               {  
                  "id":"2",
                  "name":"philosophical_value",
                  "text":"微妙",
                  "type":"button",
                  "value":"minus1",
                  "style":"danger"
               },
               {  
                  "id":"3",
                  "name":"philosophical_value",
                  "text":"普通",
                  "type":"button",
                  "value":"zero",
                  "style":""
               },
               {  
                  "id":"4",
                  "name":"philosophical_value",
                  "text":"良い",
                  "type":"button",
                  "value":"plus1",
                  "style":"primary"
               },
               {  
                  "id":"5",
                  "name":"philosophical_value",
                  "text":"すごく良い",
                  "type":"button",
                  "value":"plus2",
                  "style":"primary"
               }
            ]
         }
      ]
   },
   "response_url":"https:\/\/hooks.slack.com\/actions\/DUMMYDUMMY\/DUMMYDUMMY\/DUMMYDUMMY",
   "trigger_id":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

2. VerificationTokenをチェックする

さきほどParseしたリクエストに、token というkeyがはいっています。こちらのトークンを使って、不正なリクエストでないかを判定することができます。
こちらのtokenと作成したアプリの管理画面で参照できるVerificationTokenが一致するかをチェックします。

3. 結果を確認し、所望の処理をする

哲学Botは、DynamoDBにcallbackとして送られてきた評価を記録していますが、割愛します。

4. リクエストのレスポンスとして、callbackレスポンスの中のoriginal_messageと同じ形式のレスポンスを返す

ParseしたJSONを見ると、original_messageという項目が返ってきているのがわかります。 こちらのJSONを希望のレスポンスに加工して返すことで、slackに反映させることができます。

また、response_type/replace_originalというkeyをoriginal_messageのJSONに追加して返すことで、下記のような様々な見た目のメッセージの反映ができます。

  • アクションしたユーザーだけが見れる(Only visible to you)

    • response_type: ephemeral (default)

f:id:joe0000:20181217181135p:plain

  • メッセージの上書き

    • response_type: in_channel
    • replace_original: true

f:id:joe0000:20181217175616p:plain

  • 新しいメッセージのポスト

    • response_type: in_channel

f:id:joe0000:20181217175644p:plain

レスポンスの選択肢に関しては こちらを参照しました。

サンプルコード

本記事で紹介している哲学Botのレスポンス形式は、ユーザーのアクションを検知したら、前のメッセージをうわ書かずに新しいメッセージをチャンネルにポストする方式です。

package main

import (
    "context"
    "encoding/json"
    "net/url"
    "strings"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/nlopes/slack"
)

func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
   //  1. callbackレスポンスをParseする
    str, _ := url.QueryUnescape(request.Body)
    str = strings.Replace(str, "payload=", "", 1)

    var message slack.InteractionCallback
    if err := json.Unmarshal([]byte(str), &message); err != nil {
        return events.APIGatewayProxyResponse{Body: "json error", StatusCode: 500}, nil
    }
   //  2. VerificationTokenをチェックする
    verificationToken, found := syscall.Getenv("VERIFICATION_TOKEN")
    if !found {
        return events.APIGatewayProxyResponse{Body: "NoVerificationTokenError", StatusCode: 500}, nil
    }
    if message.Token != verificationToken {
        return events.APIGatewayProxyResponse{Body: "InvalidVerificationTokenError", StatusCode: 401}, nil
    }

   //  3. callbackの中身をみて所望の処理をする
    var score string
    value := message.ActionCallback.Actions[0].Value
    switch value {
    case "plus2":
        score = "すごく良い"
    case "plus1":
        score = "良い"
    case "zero":
        score = "普通"
    case "minus1":
        score = "微妙"
    case "minus2":
        score = "すごく微妙"
    default:
        score = "0"
    }
   
   // 4. リクエストのレスポンスとして、callbackレスポンスと同じ形式のレスポンスを返す
    userName := message.User.Name
    resMsg := userName + "さんが" + "「" + score + "」" + "と評価しました"

    orgMsg := message.OriginalMessage
    orgMsg.Text = ""
   // 今回はメッセージを上書きせず、チャンネル全体に投稿する
    orgMsg.ResponseType = "in_channel"
    orgMsg.Attachments[0].Color = "#f4426e"
   // ボタンを空にする
    orgMsg.Attachments[0].Actions = []slack.AttachmentAction{}
   // 返したいレスポンスを定義する
    orgMsg.Attachments[0].Fields = []slack.AttachmentField{
        {
            Title: resMsg,
            Value: "",
            Short: false,
        },
    }

    resJson, err := json.Marshal(&orgMsg)
    if err != nil {
        return events.APIGatewayProxyResponse{Body: "JsonError", StatusCode: 500}, nil
    }

    return events.APIGatewayProxyResponse{Body: string(resJson), StatusCode: 200}, nil
}

func main() {
    lambda.Start(handleRequest)
}

2. AWS SAMでslackからのリクエストを受け取るAPI Gatewayを構築する

AWS SAMの設定ファイルを作成する

こちらの設定ファイルは、先ほどのボタン付きメッセージをslackにポストする部分も一緒に含まれています。

前回と同様に、VerificationTokenなどのセキュアな情報はSystems Manager パラメータで設定したものを呼び出しています。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Create Lambda function by using AWS SAM.
Parameters:
  ChannelId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /philosophy_bot/channel_id
  OauthAccessToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /philosophy_bot/oauth_access_token
  VerificationToken:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /philosophy_bot/verification_token
Resources:
  LambdaIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        -
          PolicyName: "philosophy-slack-bot"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: "Allow"
                Action: "dynamodb:*"
                Resource: "*"
              -
                Effect: "Allow"
                Action: "cloudwatch:*"
                Resource: "*"
              -
                Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:DescribeLogGroups"
                  - "logs:DescribeLogStreams"
                  - "logs:PutLogEvents"
                  - "logs:GetLogEvents"
                  - "logs:FilterLogEvents"
                Resource: "*"
  # ボタンつきメッセージのポスト
  PhilosophySlackBot:
    Type: AWS::Serverless::Function
    Properties:
      Handler: philosophy-slack-bot
      Runtime: go1.x
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          CHANNEL_ID: !Ref ChannelId
          OAUTH_ACCESS_TOKEN: !Ref OauthAccessToken
      CodeUri: build
      Description: 'Post philosophical word to slack incoming webhooks'
      Timeout: 30
      Events:
        Timer:
          Type: Schedule
          Properties:
            Schedule: cron(30 0 * * ? *) # JST 09:30
  # ボタン付きメッセージのレスポンスAPI
  PhilosophySlackBotInteractiveApi:
    Type: AWS::Serverless::Function
    Properties:
      Handler: philosophy-slack-bot-interactive-api
      Runtime: go1.x
      CodeUri: build
      Timeout: 300
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          VERIFICATION_TOKEN: !Ref VerificationToken
      Events:
        Post:
          Type: Api
          Properties:
            Path: /slack
            Method: post

Lambdaを定期的に動かすタイプの設定と違うところは、EventsのところをAPI Gatewayの設定に変更するだけです。

      Events:
        Post:
          Type: Api
          Properties:
            Path: /slack
            Method: post

それだけで、API Gatewayが立ち上がり、pathに指定したエンドポイントにアクセスすることができます。

https://{restapi_id}.execute-api.{region}.amazonaws.com/{stage_name}/ このようなエンドポイントが、/Stage(ステージング用)と/Prod(プロダクション用)それぞれ用意されます。

エンドポイントはデプロイ後、AWSコンソールのAPI Gatewayの画面で確認することができます。

デプロイする

こちらのデプロイスクリプトに関しても、ボタン付きメッセージをポストする部分が含まれています。 説明はボタン付きメッセージの時とほぼ一緒なので割愛しますが、2つのデプロイパッケージをつくって同時にデプロイすることが可能です。

#!/usr/bin/env bash
cd ./slack_bot
GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot
cd ../

cd ./slack_interactive_api
GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot-interactive-api
cd ../

zip build/philosophy-slack-bot.zip build/philosophy-slack-bot
zip build/philosophy-slack-bot-interactive-api.zip build/philosophy-slack-bot-interactive-api

aws cloudformation package --profile yourprofile \
    --template-file template.yml \
    --s3-bucket serverless \
    --s3-prefix philosophy-slack-bot \
    --output-template-file .template.yml
aws cloudformation deploy --profile youprofile \
    --template-file .template.yml \
    --stack-name philosophy-slack-bot \
    --capabilities CAPABILITY_IAM

想定する階層構造

.
├── build
│   ├── philosophy-slack-bot
│   ├── philosophy-slack-bot-interactive-api
│   ├── philosophy-slack-bot-interactive-api.zip
│   └── philosophy-slack-bot.zip
├── deploy.sh
├── slack_bot
│   └── main.go
├── slack_interactive_api
│   └── main.go
└── template.yml

./deploy.shをしていただければAPI Gateway/Lambdaに先ほど書いたコードがデプロイされます。

これで、晴れて、インタラクティブなslackBotが完成しました!!

f:id:joe0000:20181217232046p:plain

まとめ

初めてのことが多かったので色々な記事を参考にさせていただきました。 こちらの記事も、少しでもslackBotの運用をするきっかけとなれば幸いです。

今後の展望ですが、ランキング機能をつけるなどの拡張を考えるとRDBの方が使い勝手がいいので、近々Aurora Serverlessに載せ替えたいと思っています!

明日はデザインiOSエンジニアのJohnが「デザインについてエンジニアなりに意識していること」というタイトルで投稿します!お楽しみに!