dely engineering blog

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

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が「デザインについてエンジニアなりに意識していること」というタイトルで投稿します!お楽しみに!

kurashiruとECSとデプロイ

はじめに

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

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

昨日は弊社iOSエンジニアの堀口(@takaoh717)が「エンジニアがCSと上手く連携するためのコミュニケーション」をタイトルに記事を書きましたので是非読んでみてください。

tech.dely.jp

こんにちは!delyでSREをしている井上です。 本記事では現在kurashiruで運用しているECSとデプロイについてご紹介したいと思います。

delyではインフラにメインでAWSを利用しており、kurashiruのインフラは主にECSで構築されています。直近の半年では、より高速にデプロイが完了できるようにDockerのbuildを非同期で行ったり、コンテナインスタンスのCPUとメモリが効率的に使えるようにタスクの定義情報の最適化を行ったりなどいくつかの改善を行いましたが、現状のフェーズにおいては安定して運用できているので今回紹介したいと思います!

EC2とECSについて

イメージが伝わりやすくなるようにかなり簡略化していますが下記のような構成になっています。

f:id:gomesuit:20181216224515p:plain

コンテナインスタンス

コンテナインスタンスはオンデマンドインスタンスとスポットインスタンスを併用することでコストを抑えています。

オンデマンドインスタンスのオートスケールはオートスケーリンググループを利用して行っており、スポットフリートのキャパシティはオンデマンドインスタンスの数に応じて増減するようにLambdaを使って制御しています。

また、スポットインスタンスは中断される可能性があるため、中断のイベントをCloudWatchで拾ってLambdaで該当のコンテナインスタンスをドレイン状態にするという制御を行っています。

docs.aws.amazon.com

ちなみにデータプレーンにEC2ではなくFargateを使うという選択肢もありますが、下記の理由から本格的な導入は行っていない状況です。

  • EC2と比較して料金が割高
  • 起動が遅い(Dockerのレイヤキャッシュが効かない)
  • CPU・メモリが柔軟に設定出来ない

特にコスト面はスポットと比較するとかなり割高になってしまうので、現状だと全てをFargateに乗り換えるという選択は厳しいです・・・

Fargateにもスポットの対応が待ち望まれますね!

f:id:gomesuit:20181218102145p:plain:w300

ECS

ECSにおいては、WEBやAPIといったドメイン単位でターゲットグループを分けて構築しています。 また、ターゲットグループ毎にred, yellow, greenの3つでサービスを分けていて、タスク定義は同一のものを設定しています。

3つのサービスのうちgreenだけはオートスケール設定を行っていて、トラフィックに応じてタスク数が増減するようにしています。
また3つに分けることによって、デプロイ時に新バージョンをred -> yellow -> greenといったように段階的な反映を可能としており、red、yellowに反映させた状態でエラーの有無やログの確認を行い、問題がなければgreenに反映するという運用を行っています。

デプロイについて

フロー

こちらもイメージが伝わりやすいようにSNSなどは省略して記載しています。

f:id:gomesuit:20181216232229p:plain

CodePipeline

デプロイは内部的にはCodePipelineを使って制御しています。

サービスredのデプロイはCodePipelineからECSに直接デプロイする機能を使っていますが、サービスyellow、greenはLambdaを使ってデプロイしています。3つのサービス全てに対してCodePipelineのECSデプロイ機能を利用した場合、タスク定義のバージョンが3つのサービスで一つずつずれてしまいます。そのためサービスredのタスク定義のバージョンと同じバージョンでデプロイするためにyellow、greenのデプロイはLambdaを使っています。

また各サービス(red, yellow, green)のデプロイの直前にCodePipelineの承認アクションを設定しています。これによって開発者がそれぞれのサービスにデプロイするタイミングをコントロール出来るようにしています。

Slackを利用したデプロイ

デプロイは開発者がSlackのChatBotから行っています。 Slackを利用したデプロイの流れ(例)を紹介します。

開発者がSlackのチャンネルにおいて下記のように送信すると、

f:id:gomesuit:20181218095146p:plain:w250

サービスredのデプロイ開始待ちになります。
デプロイを開始するタイミングをコントロールできるようにSlackのボタンでCodePipelineの承認アクションを制御しています。

f:id:gomesuit:20181218095227p:plain:w500

Approveをクリックすると、

f:id:gomesuit:20181218095315p:plain:w410

サービスredのデプロイが開始されます。

f:id:gomesuit:20181218095459p:plain:w500

サービスredのデプロイが完了するとサービスyellowのデプロイ開始待ちになります。
このタイミングで新バージョンでのエラーや怪しいログが発生していないか確認します。 確認後、Approveをクリックすると、

f:id:gomesuit:20181218095538p:plain:w410

サービスyellowのデプロイが開始されます。

f:id:gomesuit:20181218095609p:plain:w500

サービスyellowのデプロイが完了するとサービスgreenのデプロイ開始待ちになります。
Approveをクリックすると、

f:id:gomesuit:20181218095631p:plain:w410

サービスgreenのデプロイが開始されます。

f:id:gomesuit:20181218095759p:plain:w450

デプロイが正常終了するとデプロイ完了の通知が届きます。

さいごに

簡単にはなりますがkurashiruで運用しているECSとデプロイについてご紹介しました。オーケストレーションツールはKubernetes一強ですが、現段階はECSで運用しています。組織やプロダクトの成長に伴って、インフラは何を求められ、提供できるのかを考え今後オーケストレーションツールに何を採用するのかを見極めていきたいと思っています。

ちなみに先日行われたAWS re:Invent 2018ではたくさんの機能追加や新しいサービスが紹介されました。その中でも今回紹介した構成の中で利用できそうなものがあったので抽出してみました!

  • CodePipelineがソースをECRに対応

aws.amazon.com

  • オートスケーリンググループ内でオンデマンドインスタンスとスポットインスタンスを混在させられる

aws.amazon.com

  • CodeDeployがECS対応

aws.amazon.com

まだ手がつけられていないので、時間を見つけて検証し、より使いやすいデプロイ環境にしていこうと思っています!

明日は新米slackBot整備士のjoe (@joooee0000) が「サーバーレス+Go言語で作るインタラクティブな哲学slackBot」というタイトルで投稿します!お楽しみに!

エンジニアがCSと上手く連携するためのコミュニケーション

この記事はdely Advent Calendar 2018の17日目の投稿です。
昨日は、プロダクトデザイナーのミカサ トシキ(@acke_red)が「Fluid Interfaces実践 - なめらかなUIデザインを実現する」というタイトルで投稿しました。

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

はじめに

こんにちは、delyでiOSエンジニアをしている堀口(@takaoh717)です。
今日は、普段僕が行っているCS(カスタマーサクセス)担当者との取り組みについてご紹介しようと思います。
僕の普段のメイン業務はiOSアプリの開発ですが、CSの技術的なサポートも毎日行っています。
内容としては、主にクラシルの利用に関する問い合わせが来た際にCS担当者が分からないことの解説を行ったり対応方法を教えてあげたりしています。
今回は1年ほどCS担当者とのやり取りを行った中で、エンジニアがこういうことを意識してコミュニケーションすると良いなと感じたことを挙げてみたいと思います。

CSとのコミュニケーションで意識して良かったこと

ボールを宙に浮かせない

弊社ではクラシルの問い合わせ対応においてはCS用のツールを特に使用していません。
現在は主に以下のツールのみで運用を行っています。
(掲載しているレシピに関する質問への対応は社内ツールを使用しています。)

  • Gmail
    • ユーザとのやり取り
  • Slack
    • 社員同士のやり取り
  • Qiita:Team
    • 対応テンプレ作成やナレッジの蓄積

基本的にはエンジニアとCS担当者とのやり取りはSlack上で行われますが、管理ツールを使用していないと、たまに以下のようなやり取りが発生します。

CS < ユーザからアプリが正しく動作しないと問い合わせが来ましたが、何か分かりますか?
エンジニアA < うーん、こちらでは再現しませんね・・・
エンジニアB < 最近ここ特に触ってないですよね・・・
エンジニアA < そうですね、謎ですね・・・

このまま問題が放置されて、しばらく時間が経ってしまうということが以前はたまに起きてました。 これだと、CS担当者も対応が分からず不安なままですし、何よりユーザを待たせてしまいます。 このように、原因がすぐに特定できない場合は、CS担当者にその旨を伝えてユーザに返信をしてもらうようにします。

↓改善後はこのようなコミュニケーションになります

CS < ユーザからアプリが正しく動作しないと問い合わせが来ましたが、何か分かりますか?
エンジニアA < うーん、こちらでは再現しませんね・・・
エンジニアB < 最近ここ特に触ってないですよね・・・
エンジニアA < じゃあ一旦ユーザには調査する旨を伝えてIssue作成しておきます。
       @CS  ユーザさんに調査する旨をお伝え下さい。

質問しやすい状態を作る

CSの対応ではスピード感がとても重要だと思います。
内容にもよりますが、ユーザの問題の解決は早いに越したことはありません。
そして、素早い対応を行うには、躊躇せずに質問ができる状態を作っておくことが大切です。
そこで、どういう風にすれば素早く質問をしてもらえる状況を作れるかを考えて以下のようなことを行っています。

  • 質問の仕方をフォーマット化する
    • 質問したい問い合わせのユーザのメールアドレスのみをSlackに投稿し、スレッド内に聞きたいことを書く
      • フォーマットが自由になっていると、「お忙しい所すみませんが・・・」みたいなやり取りが無駄に発生しがちです。簡潔なフォーマットが決まっていれば、無駄なやり取りに気を使う精神的コストや文章を考える負担をかけることがありません。
  • 週1回は対面でのコミュニケーションの場を設ける
    • 問い合わせが来たわけではないけれど気になっていることなどをここで質問してもらう
      • オンラインのやり取りだと中途半端な理解で済ませがちなことも、きちんと納得がいくまで説明する機会が作れます。
      • サービスに関する説明は画面を見せながら説明すると理解しやすいことが多いです
  • Slackでやり取りするときは絵文字や!などをなるべく使う
    • テキストのみでの会話だと感情が読み取りにくいため、相手の顔色を伺いながら話しかけるような状態が生まれやすく、生産性の低下に繋がります。

対応方法だけではなく、きちんと仕組みを説明する

不具合があったときに、非エンジニアの人でも理解できる言葉を使って説明することも意識しています。サーバーデータベースなどの技術的な単語は使わずになるべく一般の人が理解できる言葉に置き換えながら説明を行います。

その一例として、クラシルで実際に起こった例をご紹介します。

起きたこと:
iOSアプリの内部のデータベースの一部のデータの保存先を変更した際に、変更する予定じゃなかったデータ(お気に入り)の読み書き先も変わってしまい、データが表示されなくなってしまった。
この現象を担当者に理解してもらうために以下の説明をしました。

クラシルのお気に入りはアプリの中にデータを保存しています。  
イメージとしては、アプリの内部にWindowsのマイドキュメントのようなデータを保存する仕組みがあると思ってください。
マイドキュメントの中には「お気に入りフォルダ」があります。ユーザがお気に入りボタンを押したときはレシピがこのお気に入りフォルダに入ります。
これが普段のクラシルの状態です。
今回のパターンを説明します。今回はお気に入りとは違うデータを保存しないといけなくなりました。
そこで、マイドキュメントの中に「新しいフォルダ」を作成して、ここにデータを保存するようにしました。
しかし、開発上のミスで、ユーザがお気に入り一覧画面を開いたときに、今までは「お気に入りフォルダ」を開いていたんですが、
「新しいフォルダ」を開くようになってしまいました。

これをちゃんと行うことで以下のような効果があると思います。

  • きちんと仕組みを理解してもらうことで、納得感を持ってユーザに説明することができるようになる
  • 問い合わせ対応の質(説明の内容やスピード)が向上する
  • 問題がどういう状態のものか理解することで、似ているけれど違う要因の問い合わせがきたときに判別ができる
  • 内容は同じだけど、問い合わせの文章が異なっていて同じ要因かどうか判別しづらいものが判別できるようになる

対応方法を共有する場合は、自分がその解に至った経緯やロジックを共有する

何らかのサポートをするときに解決方法を共有しただけでは、次に同じ問題が発生した場合に解決することができない可能性が高いです。 そのため、自分がどうやってその解を導き出したかという経緯も共有してあげると良いと思います。 例えば、こういう感じでコミュニケーションをしています。

  • 「ユーザさんが「昨日から〜〜ができない」と仰っているので◯◯ではなくて、△△に該当すると思います」
  • 「Slackやドキュメントで「〇〇」で検索をしたら、こういう結果が出てきたので△△の対応が適切だと思います」

こういった共有を行うことで、次に同じような問題が発生した際に対応方法は分からなくても調査を行うことができますし、 全く別の問題が発生した場合の調査方法の幅も広がります。

まとめ

以上、自分としてもまだまだ改善できることはあると思っていますが、やってみるとチーム的にもCSの対応としても良くなったんじゃないかなと思っています。 ここで書いた内容に関して、まとめると総じて重要なことは以下のことだと思います。

・ちゃんと納得感を持った上でユーザへの対応行う

・素早く的確にユーザの問題を解決できるようにする

明日はSREの井上が投稿します。こちらもぜひご覧ください!

社内SQL勉強会を開催しました

f:id:sakura818uuu:20181211164246p:plain

こんにちは、検索エンジニアのsakura (@818uuu) です。
先日、営業さん向けにSQL勉強会を行いました。開催してみて難しかったことや得た知見などを紹介します。


この記事はdely Advent Calendar 2018の14日目の記事です🎅🎄
Adventar : dely Advent Calendar 2018 - Adventar
Qiita : dely Advent Calendar 2018 - Qiita

13日目の記事は、Androidエンジニア kenzoさんの「【Android】ViewPagerのページ切り替えをいい感じにする 」でした。ぜひ読んでみてください。

tech.dely.jp

なぜやろうと思ったか

勉強会を開催しようと思ったきっかけは、営業さんの日報を見ていると「SQLや分析に興味がある」と時々書かれていてなにか助けになれないかなーと思ったからです。

エンジニア以外でもSQL学ぶことのメリットはこちらにまとまっていましたのでよければご覧ください。

paiza.hatenablog.com

行う目的やゴールを明確にする

勉強会の事前準備をしてる中で教えていただいたことがあります。
それは
「この勉強会の目的はなにか。ゴールをどういう指標にするのか。」
を明確にするということです。
そうすることで 、
・参加者にとって参加するか/しないかを決める判断指標の1つになる
・スピーカーにとって何を伝えたらいいのかを明確にできる
になるからです。

いままで個人で勉強会を開催したことはあるのですが、参加者のゴールは決めたことがなかったのでとても参考になりました。
これから勉強会を行う際も気をつけていこうと思います。

f:id:sakura818uuu:20181214101242j:plain
今回の勉強会のゴール

開催概要

勉強会には営業さんを中心に約10名にご参加していただきました🎊
そして、開発部やマーケティング部の方にご協力いただき計3名がスピーカーをしました。

参加者の皆さんは積極的に質問していただいたり、スピーカーの方はとても参考になる資料作成をしていただいたり、コーポレート部さんは自発的に勉強会の様子を動画で撮影してくださいました。

勉強会は色んな方の協力があってこそ成立するんだなと改めて思いました。

f:id:sakura818uuu:20181211160921j:plainf:id:sakura818uuu:20181211160452j:plain
勉強会で紹介した資料の一部

知見まとめ

勉強会を開催したことによって学んだ知見をslackにまとめました。

f:id:sakura818uuu:20181211144758p:plain

いい知見をたくさん得ることができたので次の勉強会を開催する際に活かします。

これからのdely Advent Calendar 2018もぜひお楽しみにしていてください〜!

【Android】ViewPagerのページ切り替えをいい感じにする

こんにちは。delyでAndroidのエンジニアをしているkenzoです。
この記事はdely Advent Calendar 2018の13日目の記事です。

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

 

昨日はkurashiruのwebグロース全般を担当しているinternet_ghostがこちらの記事を書きました。
クラシルでのSEO施策についてや、外部の方が気になりそうなポイントについて書かれています。ぜひご覧ください!

はじめに

今日は先日の記事に引き続き、AndroidアプリのViewPagerをいい感じの動きにしていく際にやったことをご紹介します。
今回はページ切り替え時の動きをいい感じにしていきたいと思います。

※ 注意: 今回の記事を読んで試しに実装する場合は一旦下まで読んでから実装してください。上の方のコード使わなくていいかもなので。

ViewPagerのページ切り替えをいい感じに

先日の記事で作成したサンプルアプリに少し機能を追加します。
ページを切り替えるボタン「←」「→」の設置と★のタップではじめに戻るようにします。

ページを切り替える処理を実装

ViewPagersetCurrentItemを使います。

star.setOnClickListener { viewPager.currentItem = 0 }

f:id:kenzo_aiue:20181211145045g:plain

これでページが切り替わるようになりました。

もう少しゆっくりページを切り替えたい

ページが切り替わるようになりましたが、ちょっと切り替わるスピード早いですよね?ズビュンって感じ。
なので、少しゆっくり切り替わるようにします。

ViewPagerを継承してCustomViewPagerを作成します。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, CustomScroller(context))
        }
    }

    private class CustomScroller(context: Context) : Scroller(context, FastOutSlowInInterpolator()) {

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, CUSTOM_DURATION)
        }
    }
}

ViewPagerで使用されるScrollerを自分で作成してセットします。 durationを1000msにしたので、1秒でページが切り替わるようになります。

f:id:kenzo_aiue:20181211145137g:plain

これでページがゆっくり切り替わるようになりました。

スワイプするときは今までどおりにしたい

ページがゆっくり切り替わるようになったと思ったら、今度はスワイプする際におかしな挙動をするようになってしまいました。

f:id:kenzo_aiue:20181211150722g:plain

ボタンを押した時はゆっくり切り替わってほしいけど、スワイプする時は今までどおりに動いてほしいですよね。
なので、先程作成したCustomViewPagerに手を入れてsetCurrentItemを呼ぶ時だけゆっくり切り替わるようにします。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    private val interpolator: CustomInterpolator = CustomInterpolator()
    private val scroller: CustomScroller = CustomScroller(context, interpolator)

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, scroller)
        }
        addOnPageChangeListener(object : OnPageChangeListener {

            override fun onPageScrollStateChanged(state: Int) {
                when (state) {
                    SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING -> { // ページ切り替えが終わったら、また、切り替え中にスワイプした際に元の挙動で切り替わるように
                        interpolator.isCustom = false
                        scroller.isCustom = false
                    }
                }
            }

            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit

            override fun onPageSelected(position: Int) = Unit
        })
    }

    override fun setCurrentItem(item: Int) {
        interpolator.isCustom = true
        scroller.isCustom = true
        super.setCurrentItem(item)
    }

    private class CustomScroller(context: Context, interpolator: Interpolator) :
        Scroller(context, interpolator) {

        var isCustom: Boolean = false

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, if (isCustom) CUSTOM_DURATION else duration)
        }
    }

    private class CustomInterpolator : FastOutSlowInInterpolator() {

        private val default: Interpolator = Interpolator { input -> (input - 1).pow(5) + 1 } // ViewPager内で作成されているInterpolatorをここで再実装
        var isCustom: Boolean = false

        override fun getInterpolation(input: Float): Float =
            if (isCustom) super.getInterpolation(input) else default.getInterpolation(input)
    }
}

ちょっと長くなってしまいましたが、こんな感じになるよう変更しています。

  • ゆっくりとデフォルト両方の動きができるScrollerInterpolatorを作成(isCustomtrueでゆっくり)
  • ボタンで切り替えを行うタイミングでisCustomtrue
  • 切り替えが終わったらisCustomfalse
  • ゆっくり切り替え中にスワイプした場合にもデフォルトの動きができるようにisCustomfalse

f:id:kenzo_aiue:20181211155638g:plain

これで、ボタンで切り替える時はゆっくり、スワイプした時は今までどおりにページが切り替わるようになりました。

この実装でいいのか

アプリの挙動だけ見ると、うまいこと動くようになりました。
しかし、今回作成したCustomViewPagerはリフレクションでmScrollerにアクセスしています。
ViewPagerの中身の実装が変わってmScrollerがなくなる可能性も0ではないので、できれば違う方法で実現したいところです。*1

隣のページに移動するだけでいいなら

今度は先程作成したCustomViewPagerではなくViewPagerに戻し、別の実装をしてみます。
ViewPagerfakeDragBegin fakeDragEnd fakeDragByを用いてページを切り替えます。
これを用いると、隣のページへのスワイプを模した動きをさせることができます。(本記事ではfake dragと呼ぶことにします)

private var prevDragPosition = 0

override fun onCreate(savedInstanceState: Bundle?) {

    /* 省略 */

    left.setOnClickListener {
        if (viewPager.currentItem > 0) fakeDrag(false)
    }
    right.setOnClickListener {
        if (viewPager.currentItem + 1 < viewPager.adapter?.count ?: 0) fakeDrag(true)
    }
}

private fun fakeDrag(forward: Boolean) {
    if (prevDragPosition == 0 && viewPager.beginFakeDrag()) {
        ValueAnimator.ofInt(0, viewPager.width).apply {
            duration = 1000L
            interpolator = FastOutSlowInInterpolator()
            addListener(object : Animator.AnimatorListener {

                override fun onAnimationStart(animation: Animator?) = Unit

                override fun onAnimationEnd(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationCancel(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationRepeat(animation: Animator?) = Unit
            })
            addUpdateListener {
                val dragPosition: Int = it.animatedValue as Int
                val dragOffset: Float = ((dragPosition - prevDragPosition) * if (forward) -1 else 1).toFloat()
                prevDragPosition = dragPosition
                viewPager.fakeDragBy(dragOffset)
            }
        }.start()
    }
}

このようなことをしています。

  • beginFakeDragでfake dragを始める
  • いい感じのdurationinterpolatorValueAnimatorを用意
  • fakeDragByで少しずつスクロールさせる
  • ViewPagerwidth分のスクロールが終わったらendFakeDragでfake dragを終了

f:id:kenzo_aiue:20181211154305g:plain

このようにCustomViewPagerと同様の挙動で隣のページへ切り替えることができました。

おわりに

今回と前回使ったサンプルアプリのソースはこちらです。
先日の記事「Androidにいい感じの動きをさせていく話(ViewPagerとその他Viewとの連動」と合わせてみなさまのアプリ上のViewPagerの動き方をよりよいものにしていくための一助になれば幸いです。

明日は検索エンジニアsakuraの「社内SQL勉強会を開催しました」です。お楽しみに!

*1:リフレクションでmScrollerを変更するやり方はぐぐるといっぱい出てくるし、やたら使われてそうなので、そうそう変えられないかなーとは思いますけど