2014年12月24日水曜日

第1回 えびスタ! ~ 恵比寿スタートアップ勉強会 ~ レポート

先日12月18日、恵比寿にある UZABASE 新オフィスにてエンジニア向け勉強会「えびスタ! 」第 1 回を開催させて頂きました。

移転したばかりの新オフィスで初めて開催する勉強会ということで不安な点もありましたが、50 人という定員枠に対してなんと 50 人オーバー近い参加登録を頂き、盛況のうちにイベントを終えることが出来ました。参加者の皆さん、ありがとうございました!


本記事では当日の様子をレポートしたいと思います。




今回のアジェンダは以下のとおりです。
前半はクックパッド、Retty、VASILY、 UZABASE から 4 名のエンジニアによるプレゼンテーション。後半は皆でわいわいエビスビール(恵比寿だけに)やピザをつまみながらの QA セッション + 懇親会でした!

時間
発表者
内容
19:00 ~ 19:20
小野 大器(クックパッド株式会社)
マイクロサービスアーキテクチャについて
19:20 ~ 19:40
梅田 昌太(Retty 株式会社)
Dev が AWS と出会って DevOps を目指した話
19:40 ~ 20:00
今村 雅幸(株式会社 VASILY)
プッシュ通知大戦争
20:00 ~ 20:20
文字 拓郎(株式会社 UZABASE)
NewsPicks を支える技術と怖い話
20:30 ~ 21:15
-
QA セッション
21:15 ~ 22:00
-
懇親会


<プレゼンテーション>

1.マイクロサービスアーキテクチャについて     小野 大器 様(クックパッド)

クックパッドからは小野様に、クックパッドが何故マイクロサービスアーキテクチャを採用したかということと、実際に行っている取組みについてご紹介頂きました。


クックパッドがマイクロサービスアーキテクチャを採用するにあたった経緯として


・ビジネスの変化(ブラウザからモバイルへの変遷といった環境の変化)
・巨大化するモノリシックなアプリではテストやデプロイに時間がかかり過ぎる

などの理由を挙げられていました。





えびスタ以前から「最近マイクロサービス気になる…!」という声を周りで聞いていたので、そのような方にとってはとても勉強になるセッションだったのはないでしょうか。


2.Dev が AWS と出会って DevOps を目指した話  梅田 昌太 様(Retty)

Retty からは 梅田様に、サービスの成長とユーザーの増加に伴う問題にへの対応と今後、そしてアプリケーションエンジニア出身の DevOps として AWS とどう関わっているのかについてお話頂きました。


ユニークユーザーが 10 万人以下のサービス黎明期から 200 万~ 400 万人の人気サービスにまで成長していく過程で 、インフラアーキテクチャをどのように変化させて対応していったのかついて取り上げていらっしゃいました。






またインフラで利用されている ElasticBeanstalk についての詳しい説明もされていました。
ElasticBeanstalk は AWS から無料で提供されている アプリケーション管理サービスです。AWS内のアプリケーションに対してデプロイの詳細を自動的に設定してくれます。
参加者には ElasticBeanstalk を利用されている方はほとんどいなかったのですが、このセッションを聞いて気になったという方も多かったのではないでしょうか。


3.プッシュ通知大戦争  今村 雅幸 様(VASILY)

VASILY からは今村様に、iQON で行われているアプリのプッシュ通知に対しての取り組みをご紹介頂きました。(ちなみに VASILY さんは UZABASE と同じビルの 1 フロア下のご近所さんです)


プッシュ通知のメリットとそれに付随する課題にはじまり、代表的な ASP サービスの比較から、自前でプッシュ通知サービスを実装するにあたりおこなった様々な工夫(配信の最適化・スケーラビリティーの確保の仕方・通知の分析など)まで、プッシュ通知まわりでのノウハウについてお話頂きました。


テスト配信における秘話などタメになる話が満載でした。




4.NewsPicks を支える技術と怖い話  文字 拓郎(UZABASE)

弊社からは 3 ヶ月前に NewsPicks チームに join したばかりの文字が、急成長を続けるサービスの裏側がどのようなアーキテクチャで構成されており、どのような工夫を行っているのか、実際にあった事件なども交えてご紹介。

今までにあった怖い話として、荒ぶる Redis、暴走する Phantom JS、互換性肥満などといった事件の原因及び、解決策としてどのような工夫を行ってきたのかについてお話しました。




プレゼンテーションのあとはエビスビールとピザを頂きながら、しばし休憩を挟んで、後半の QA セッションへ。




<QAセッション>

QA セッションでは本日登壇された 4 名によるカジュアルな質疑応答が行われました。ガリガリにテック寄りの質疑応答が行われたわけではなく、ゆるい雰囲気の中で、ビール片手に冗談を交えながらの進行でした。
質問は、会場にいる参加者全員に対して、スマートフォンを用いて 4 択のアンケートで回答をとり、その結果をスクリーンに表示して共有する、という形で行われました。弊社杉浦恒例のリアルタイム参加型です!


1418904633198.jpg


登壇者の方には代表して、各自選んだ回答に対しての意見や、なぜその回答を選んだのか、などのお話をしていただきました。ユーモラスな回答も多かったのですが、時には登壇者の方の熱いお話を聞くこともできました。

質問内容は、好きなエディタは何かという質問から、普段聞きづらいような攻めた質問まで様々で、エンジニアジョークが飛び交うセッションでした。登壇者の意見を聞いて、他のエディタや他の言語に浮気してみようかな、なんて思った方もいたかもしれませんね。


また、登壇者以外の参加者がアンケートページで入力したコメントもスクリーンに表示されるようになっていて、そのコメントに対しての笑いも起こり、会場全体で盛り上がっていました。


その後は同じ会場内で懇親会を行いました。参加者の方と主催企業エンジニアの垣根なく、皆様思い思いに交流されていました。
集合写真を撮れなかったのが心残りです…
1418905278274.jpg


1418906038626.jpg
1418906110029.jpg

幸いなことに「次回はいつですか」というお声も多数頂けたので、是非次回も開催したいと思います!恵比寿オフィス引っ越し後まもなくの勉強会で到らないところも多々ありましたが、次回の勉強会では今回の反省を活かしてより良いものにしたいと思います!


みなさまご参加ありがとうございました!

2014年12月18日木曜日

オフショア開発 @ Framgia Vietnam

SPEEDA QAチームの藤田です。

オフショアでのテスト自動化PJ立ち上げのために11月中旬より1か月間 Framgia Vietnam に来ています。

Framgia さんには、以前からUzabaseの社内システムの開発を依頼していましたが、今回は社内システムだけでなくSPEEDAの自動テストコードを実装いただくべく一緒にベトナムで仕事しております。

せっかくなのでちょっと今までの投稿とは異色ですが、Framgiaさんとのオフショア開発についてまとめようと思います。
今後オフショアを考えてる方には参考になる?とうれしいです。

信頼できるオフショアパートナー ベトナムのFramgiaさん

「なぜFramgiaさんと一緒に開発するのか?」という理由はいろいろありると思うのですが、
ベトナムという国としての特性とFramgiaさんの会社としての魅力が大きいと感じています。
オフショア開発を進めるポイントとなるベトナムの基本情報。
  • 物価、労働コストが日本の3~4分の1
  • 平均年齢が若い(27~28才)
  • 親日
  • IT業種はとても人気がある
  • みんなポジティブで明るく、面倒みの良い国民性

Framgiaさんの場合、平均年齢が27才以下らしく、実際に170人くらいの社員みなさんが若くパワーあふれる感じです。
優秀な学生のリクルートには力を入れているとのことで、国費留学で海外で学んだ超エリートもいるとのこと。
Framgiaさんではラボ型開発を推進しています。ラボ型は、契約した期間で担当メンバーを確保することができ優秀なエンジニアを常に確保することができます。

日本と2Hの時差のあるベトナムでは、日本に合わせてみなさん1日は基本的に以下のスケジュールで仕事しています。
稼動時間が同じなので、いつでもオンラインでチャットや会議をできます。
AM 7:45 全社の朝礼
朝は、日本のクライアントに合わせて早いです。
毎日1人ずつモーニングスピーチを英語でし、最後は日本のあいさつの練習を全社で実施。やる気UP!

AM 8:00 チームの朝会
どのチームも、最後は「モー・ハイ・バー わっしょい!」の掛け声で終了。みんな同じ時間に朝会をするので掛け声が社内のそこら中から聞こえます。やる気UP!2回目。

AM 8:10~PM17:00? 開発集中
チームは席が近く、いつでもコミュニケーションはその場ですぐできるのでミーティングが少なく仕事に集中できる時間が長い。

モチベーションやチームビルディングはどこでも大きな課題ですが、ここではそんな悩みないのでは?と思えるくらいにみなさん明るく仲良しで、とても仕事に集中しやすい会社の文化があります。

もちろん技術力UPにも熱心で、チーム対応Hackathon、コードコンテストなどのイベントが企画されていたり、トレーニングルームが用意されておりプロジェクトに携わるメンバーが必要スキルを短期で習得できる体制ができています。

ということで、長期で一緒に仕事をしこれからのオフショア開発の可能性をほぼ毎日感じ、更にベトナムとFramgiaが好きになってしまいました。(暖かいし、食べ物もめっちゃおいしいし。。。)

立上げはやっぱりオンサイト!
私は、春からオフショア開発に参加しましたが、今までを振り返って思うのはやはりコミュニケーションが難しいという部分です。
テスト自動化PJの立ち上げでは、メンバーが自分たちで開発環境をつくり、コードを書いていけるように、
「コンセプトを共有 > 必要最小限のドキュメントを作成  実際に一緒にやってみる」繰り返しました。
その中でよりワークしそうなフローを一緒に相談したり、躓いている部分を共有しました。
割とスムーズに自動テストの実装を依頼できる体制ができたのも、オンサイトだったからこそ。

また、ベトナムでFramgiaメンバーと仕事がしたい!!


自動化を手伝ってくれたFramgiaエンジニア Ngocさん
with 社内Hackathonの告知ポスター!

2014年12月16日火曜日

NewsPicks(iOS)の設計思想

NewsPicksチームインターン生の保田です。
主にiOSアプリ開発のお手伝いをしております。
iOSアプリを作っていて悩ましいな、と思うのが、同じ機能を持つがiPadとiPhoneで見た目が違うページをどう設計していくか、ということです。

ちょうど僕が関わった、NewsPicksの特徴的な機能である「ニュース中間ページ」をどのように設計したかについてお届けいたします。



NewsPicks上でニュースをみるとき、上図の左から右の流れで画面を切り替えます。
この中の真ん中の画面(ニュース中間ページ)の設計についてご説明していきます。


Season1. 単一仕様時代

当初は、次のような単純な仕様でした
 - 「フォローしているユーザー」セクションで、コメントを表示
 - 「その他のユーザー」セクションで、コメントを表示


ただし、並び順に関してはサーバー側で『時系列順』『Like順』でソートされたものを受信して、そのまま表示していました。

当時は次のようなクラス図になっていました(メソッドは省略)。


ControllerにはiPhone, iPadで共通の処理が双方に書かれていたため、メンテナンス性が低いものになっていました。また、NewsTableViewにはコメントが表示されるのですが、コメントの有無の判定、データの保持などの処理も行っており、ViewとLogicが切り分けられていない典型的なアンチパターンになってしまっていました。


Season2. 人気コメントの誕生

次に、人気ニュース一覧からニュース中間ページに遷移した場合、Likeがたくさん付いているコメントを「人気コメント」というセクションで上位に表示するという仕様が産声を上げました。


仕様を整理すると、下記のようになります。
そこで、以下のような設計に変更しました。


Commentsというクラスを作成し、NewsTableViewから表示するコメントの表示内容、コメントのソートなどを切り離しました。これで、ViewからLogicを少し切り離すことができました。
CommentsWithTrendingCommentsはCommentsを継承した、人気コメントを表示するためのクラスです。NewsTableViewがどうCommentsを生成すれば良いか知っていて、それをControllerが知っているという設計です。実装の詳細を知らないと使えないクラスはイケてません。


Season3. 検索機能の実装と、連載企画のスタート

NewsPicksオリジナル連載企画がはじまり、中間ページに以下のようなページが仲間入りしました。

上のほうに連載名が書いていたり、「記事に登場するユーザー」というカテゴ リが追加されたりしました。
また、検索機能が追加され、コメント検索の結果から遷移すると「ヒットした コメント」というカテゴリを表示することとなりました。
仕様を整理すると、下記になります。
それに伴い大幅な設計変更を行い、以下の様な設計になりました。

大工事です。
5個しか無かったクラスが
20 個近くになりました。緑色のクラスが実際に呼び出されるクラスです。
共通部分は
Abstract に集約し、コメントのロジックは PickerCommentsCategorizeLogic が責務を持 ち 、 データはCategorizedComments で授受されそのまま表示すると正しいセクションが表示されます。
また、
Factory Method パターンを導入したことによって呼び出し側は Abstract など実装の詳細は一切知りません。 しかし、まだ改善の余地があります。『AbstractNewsSummaryController』の 親クラスが iPad, iPhone によって分かれてしまっていて、全く同じ処理が iPhone, iPad のそれぞれに書かれてしまっています。 
Season4. 更なるリファクタリング Season3 で残ってしまった重複コードを削除すべく、リファクタリングを 行いました。iPad だけが継承している TransitionController は、Controller の遷移方法に関する責務を持っていました。これをを Helper として存在さ せ、標準の UIViewController を継承できるようにしました。
こうすることで共通部分を抽象クラスへ追いやることができ、具象クラスの重複コードが撲滅できました。
ここで、season3でFactory Method パターンを導入していたため、呼び出し側では全くコードの変更が必要ありませんでした。GoF さまさまです。
まとめ
NewsPicks では、同じ機能をもつが iPad iPhone で見た目が違うページを、下記のような戦略でメンテナンス性の高い設計で実装しています。 - View, Logic, Controller を切り分ける
- 具象クラスに点在する共通処理を抽象クラスに追いやる
- Factory Method パターンでインスタンスの生成方法を隠すことにより、後々の変更に強くする
サービスローンチ時に、様々な試行錯誤を行いながら徐々にサービスを成長させる過程で、負債コードをためてしまうのは致し方ないことです。しかし、適切な手順で設計を変更すれば必ずコードは綺麗になります!
NewsPicks では更なるグロースに向けて、一緒に戦ってくれるエンジニアを募集しております!
インターン生でも設計から携わらせていただけるのでとても勉強になります!
興味をお持ちいただいた方は Wantedly などからご応募ください!!


2014年12月12日金曜日

俺の Docker イメージ

SPEEDA 開発チームの緒方です。

最近話題に上ることも多い Docker ですが、UZABASE でもチームによっては積極的に使っています。
(現在は主に開発用途。)

他のコンテナ型仮想化技術と比較した場合の Docker の良さとして、
  • Docker Hub など、レジストリに登録されているイメージを利用できる
  • Dockerfile を使用したイメージ構築の自動化
などが挙げられると思います。

Docker Hub をざっと見渡しただけでも、Ubuntu や CentOS など環境だけを提供するものから redis や MySQL などそのままアプリケーションが実行できるものまで様々なイメージを見つけることができ、その良さを垣間見ることができます。

さて、その便利な Docker のイメージですが、実体がどのようになっているかご存知でしょうか。
Ubuntu や CentOS が丸ごと入っているくらいだからさぞかし難解なものだろうと思われる方もいらっしゃるかもしれませんが、作成の手順はとても単純です。

ということで、試しに作ってみることにしました。

とりあえず簡単なものということで、
  • フルスクラッチから作る
  • busybox を動かすだけの最小のイメージ
ということを目標とします。
イメージの作り方にも色々な方法があるのですが、今回はイメージのファイル構成を含んだアーカイブを作成して docker import するというやり方でいきます。

アーカイブの最終的なファイル構成は次のようになります。
bin/
bin/busybox (※busybox-x86_64 へのシンボリックリンク)
bin/busybox-x86_64
話がややこしくならないように、busybox はスタティックリンク版を利用します。
(ダイナミックリンクしているバイナリを実行するためにはライブラリの配置や /etc/ld.so.conf の設定などが必要です。)

以下手順です。

環境:
Docker 1.3.2 / Debian sid (Linux 3.16.0-4-amd64 #1 SMP Debian 3.16.7-2 (2014-11-06) x86_64 GNU/Linux)
$ mkdir -p busybox-static/bin
$ cd busybox-static/bin
$ wget -P . http://www.busybox.net/downloads/binaries/latest/busybox-x86_64
$ chmod +x busybox-x86_64
$ ln -s busybox-x86_64 busybox
$ cd ..
$ tar zcf busybox-static.tar.gz bin
これで、busybox を含んだ bin ディレクトリだけの最小の Docker イメージができました。
作成したアーカイブを docker import します。
$ cat busybox-static.tar.gz | docker import - busybox-static
うまくいったようです。
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
busybox-static      latest              85e373c46d80        27 seconds ago      973.2 kB
では、run してみます。
$ docker run busybox-static /bin/busybox ls -a
.
..
.dockerenv
.dockerinit
bin
dev
etc
proc
sys
これも成功です。目標達成!

ところで、アーカイブには bin ディレクトリしか含まれていなかったはずですが、いくつかファイルやディレクトリが追加されています。
これらは、デバイスなどホストのリソースを利用できるように、Docker が勝手に作ってコンテナに追加してくれているものです。

このような感じで、単純なイメージであれば非常に簡単に作ることができます。
Docker コンテナ内でプログラムを実行するには何が必要であるか、コンテナからホストのリソースはどのように見えているかなど勉強になることも多いので、一度スクラッチからのイメージ作成をやってみても面白いかと思います。

2014年12月11日木曜日

PhantomJSを使ってみる

Techチームの遠藤です。

コンテンツのグローバル展開に向けて、いくつかの地点での画面表示の速度測定のためにphantomjsを使用し計測しました。
全部ではないですが初めの部分だけ記述します。

PhantomJSはまぁ仮想ブラウザを立ち上げてHPを操作していくものですね。
自動テストなどに使えるものです。

PhantomJS
http://phantomjs.org/

DownloadでそれぞれのOSにあったものをDLしてください

私はwindowsでやってるのでwindowsのみ記述します。
といっても大したことしてません。

windowsの場合はDLしてきたzipファイルを解凍し、任意の場所に配置してから環境変数にそのフォルダを指定すると使えるようになります。

さっそく使ってみます。

jsファイルを書いて、コマンドプロンプトで
phantomjs XXX.js

phantomjsコマンドと実行するファイルを指定するだけでできます。

まず手始めにgoogleへアクセスしてみます。

var page = require('webpage').create();
page.open('http://www.google.co.jp/', function () {
        page.render('sample.png');
        phantom.exit();
});

新しく作ったJSファイルに上記を記述して実行。

アクセスできました。
sample.pngというピクチャが保存されています。

↓こんな感じ



実際に使うにはどんどんページ遷移していきたいので、、、
下記ページを参考に遷移する先をtask化していきます。

var page = require('webpage').create();

function next(){
  if (tasklist.length == 0) {
    phantom.exit();
  }
  var task = tasklist.shift();

  page.address = task.path;
  page.resources = [];

  page.open(page.address, function(status) {
    if (status !== 'success') {
      console.log('FAIL to load the address');
      phantom.exit(1);
    } else {
      var imagename = 'sample2_' + ('0' + task.id).slice(-2);
      page.render(imagename + '.png');

      next();
    }
  });
}

var tasklist = [
{
  id:1,
  path:'http://www.yahoo.co.jp/'
},{
  id:2,
  path:'http://www.google.co.jp/'
}];

next();
こんな感じに...


実行してみるとちゃんとアクセスされているようです。
sample2_01.png、sample2_02.pngと画像が保存されているので順にアクセスされてる模様です。

さて、意味もなくやってるわけじゃなくて仕事で使うので自社サイトのアクセスをするわけですが。。。
自社サイトでは認証があるのでそれを実装します。

var page = require('webpage').create();

function next(){
  if (tasklist.length == 0) {
    phantom.exit();
  }
  var task = tasklist.shift();

  page.address = task.path;
  page.resources = [];

  page.open(page.address, function(status) {
    if (status !== 'success') {
      console.log('FAIL to load the address');
      phantom.exit(1);
    } else {
      var imagename = 'sample3_' + ('0' + task.id).slice(-2);
      page.render(imagename + '.png');
      if(task.operation == null){
        next();
      } else {
        task.operation(page);
      }
    }
  });
}

var tasklist = [
{
  id:1,
  path:'http://www.ub-speeda.com/',
  operation:
    function(page){
      page.evaluate(function() {
        //ここは各サイトで実装が異なります
        document.forms[0].username.value = 'dummy_username';
        document.forms[0].password.value = 'dummy_password';
        document.forms[0].submit();
      });

      //弊社サイトではログイン数管理をしており、それを超過した場合、古いログインを追い出すなどがあるため確認メッセージを出し、再度ログインボタンを押してもらう仕様のため、その対応です
      setTimeout(function() {
        if (page.url.indexOf('top/welcome') > 0) {
          page.evaluate(function() {
            document.forms[0].submit();
          });
          setTimeout(function() {
            next();
          }, 3000);
        } else {
          next();
        }
     }, 3000);
    }
},{
  id:2,
  path:'http://www.ub-speeda.com/company/companyinformation/cid/JPN3O1U73D3VNGWO'
}];

next();

operationという変数を作ってそこにログイン処理を放り込んでみました。
operationが実装されていれば実行する感じですね。
これを応用すればHP内の操作を追加していけるかと思います。
こちらは実際動作するのですがID/Pass等が必要なため試していただける方は少ないかと思います。(ゴメンナサイ

次にファイルのアクセスに関しての情報をファイルに記述します。
ここではページのロード時間しか記述しませんが、ちょこっと検索すればより詳細な情報を取得できるサンプルコードがあるところがあるので詳細はそちらを参考にしてください。

まず、ファイルに書きだす方法ですが公式を参考に。。。

var fs   = require('fs');
fs.write(ファイル名, 内容, 'w');

で書き出せます。

組み込んでみましょう。

var page = require('webpage').create();
var fs   = require('fs');

page.onLoadStarted = function() {
  page.startTime = new Date();
};

function next(){
  if (tasklist.length == 0) {
    phantom.exit();
  }
  var task = tasklist.shift();

  page.address = task.path;
  page.resources = [];

  page.open(page.address, function(status) {
    if (status !== 'success') {
      console.log('FAIL to load the address');
      phantom.exit(1);
    } else {
      page.endTime = new Date();

      var logname = 'sample4_' + ('0' + task.id).slice(-2);
      store(logname, page.endTime, page.startTime);

      if(task.operation == null){
        next();
      } else {
        task.operation(page);
      }
    }
  });
}

function store(logname, endTime, startTime){
  page.render(logname + '.png');
  fs.write(logname + '.log', endTime - startTime , 'w');
}

var tasklist = [
{
  id:1,
  path:'http://www.ub-speeda.com/',
  operation:
    function(page){
      page.evaluate(function() {
        document.forms[0].username.value = 'dummy_username';
        document.forms[0].password.value = 'dummy_password';
        document.forms[0].submit();
      });

      setTimeout(function() {
        if (page.url.indexOf('top/welcome') > 0) {
          page.evaluate(function() {
            document.forms[0].submit();
          });
          setTimeout(function() {
            next();
          }, 3000);
        } else {
          next();
        }
     }, 3000);
    }
},{
  id:2,
  path:'http://www.ub-speeda.com/company/companyinformation/cid/JPN3O1U73D3VNGWO'
}];

next();

さて、これで
sample4_01.log
sample4_02.log
というファイルが出来ました。

中身は「endTime - startTime 」という内容が示すとおり殺伐とした数字だけです。
sample4_01.logの中身
---
219
---

sample4_02.logの中身
---
2448
---

これでページを開く時間がわかるようになりました。
実際にはこれをより詳細にHAR形式で出力するなどして、使えるものに仕上げていってます。
他にも、ダウンロードやajaxでの表示待ちなどを組み込みながら作ったりします。

こんな感じで日々仕事してます。。。

2014年12月8日月曜日

NewsPicks の Chrome 拡張を作った話


こんにちは。NewsPicks の開発を担当している文字(もんじ)です。本日 NewsPicks の Chrome 拡張をリリースしました。


幸いユーザーの皆様にもご好評頂いているようで嬉しいです。ということで今回は NewsPicks の Chrome 拡張を作った話をします。アジェンダは以下の通りです。
  1. NewsPicks の Chrome 拡張が提供する機能について
  2. なぜ Chrome 拡張を作ったのか?
  3. どうやって Chrome 拡張を作ったのか?
  4. まとめ

1. NewsPicks の Chrome 拡張が提供する機能について

Chrome 拡張が提供する機能については NewsPicks のブログをご覧下さい。主に 2 つの機能を提供しています。

現在開いているページを NewsPicks に Pick する機能




アドレスバー(Omnibox)を利用して NewsPicks 内の記事やコメントを検索する機能





2. なぜ Chrome 拡張を作ったのか?

NewsPicks は基本的にスマホファーストな方針で開発しており、Web 版は今年の夏にリリースされたばかりです。しかしヘビーユーザー ── 特に NewsPicks がターゲットとしているビジネスマン ── は、スマホ以外から NewsPicks を利用することも多いと考えられます。実際に 25 % のユーザーが Chrome から NewsPicks にアクセスしていますし、そもそも会社でスマホを使ってニュースを見ていたら印象が悪いでしょう。

また、過去記事を参照しながら長文のコメントを書いて下さるユーザーは、スマホではなく PC から書きたいと考えている方が多いとも感じていました。私自身も NewsPicks でコメントを書く場合は、スマホではなく PC を利用することが多いため、業務の隙間時間(ビルド時間)や休日の隙間時間に業務外で Chrome 拡張を開発することにしました。


3. どうやって Chrome 拡張を作ったのか?

さて本題の開発方法ですが、それほど特別なことはしていません。Chrome 拡張は必要なツールキットも揃っており、JavaScript と HTML で書けるため、実開発工数は 10-20 時間程度だと思います(気楽に作ることが出来るのが HTML + JavaScript の良いところですね)。ただ、私自身は Chrome 拡張を作ったことが無かったため、以下の順に調査をしました。
  1. Google のドキュメントを読む
  2. 今回開発しようとしている拡張機能に似た Extension のソースコードを読む
1. については、Google の API ドキュメントは良く整備されており、必要最低限の情報は十分に記載されていると感じました。ただ幾つか実際の使用例を見たかったので、2. のソースコードで知識を補完しました。ここでは具体的には以下を参考にしました。
前者は主に Pick 機能(ポップアップ)の実装について、後者はポップアップを常駐型のサイドバーにしようとしたときに読み込みました(Omnibox を操作する拡張のソースコードも幾つか読んだのですが、失念していまいました)。

ちなみにポップアップではなく常駐型のサイドバーにするのは途中で断念しました。理由は幾つかあるのですが、サイドバーを実装しようとすると content script もしくは executeScript と insertCSS によって表示しているページにスクリプトと CSS をインジェクトする必要があるのですが、後者が表示しているページの CSS とコンフリクトするためです。今回は手間を省くために CSS Framework を利用したかったのと、すべてのウェブページに対応しようと考えていたため、コンフリクトを回避するのはコストが高くつきそうだなぁと判断して取りやめた次第です。また、その他の理由としては、後述する Yeoman の generator との相性が悪かったというのもあります。JS ファイル内で依存スクリプト / CSS をインジェクトするため、grunt-usemin を使った Yeoman のビルドフローに乗せるのが面倒でした。

さて、幾つか下準備をしたあと、具体的な開発に入りました。最初は自分でちまちまビルドスクリプトを書こうと思っていたのですが、丁度 Yeoman の generator があることに気付いたので、こちらを使うことにしました。この generator を使うと extension のパッケージングまで含めたビルドフロー全般、また livereload を使ったデバッグ環境まで一式整えてくれるため、Chrome 拡張の開発に慣れていない人にとってはなかなか便利だと思います。ディレクトリ一式も良い感じに作ってくれるので、今回の拡張ではこの generator を使ってこんな感じの構成にしています。


具体的なソースコードについては特に複雑なことはしておらず、NewsPicks のサーバーが提供する REST API を叩いているだけです。あえて工夫した点を挙げると以下になるでしょうか。

まず popup については MVC で開発しました。今回はそれほど複雑な画面ではないので Backbone を利用していますが、フォームのモデルとのバインディングだけ面倒だったので backbone.stickit を利用しています。

backbone.stickit は Backbone にバインディング機能を提供してくれるライブラリです。これを Marionette と組み合わせて使う場合は、次のような Behavior を定義しておくと便利です。

class StickitBindingBehavior extends Backbone.Marionette.Behavior

  createBindings: ($root, attr="name", ignores={}) ->
    bindings = Backbone.$.extend true, {}, @options.bindings
    $root.find("[#{attr}]").each ->
      $el = $(@)
      attribute = $el.attr attr
      return if bindings[attribute]
      for ignore in ignores
        if _.isString ignore
          return if ignore is attribute
        else if _.isObject ignore
          return if ignore.test and ignore.test attribute
      selector = "[#{attr}='#{attribute}']"
      tag = $el.prop("tagName").toLowerCase()
      key = "#{tag}#{selector}"
      return if bindings[key]
      bindings[key] = "observe": attribute
    bindings

  onRender: ->
    @view.bindings = @createBindings @$el, @options.attr, @options.ignores
    @view.stickit()

  onDestroy: ->
    @view.unstickit()

Backbone.Marionette.Behaviors.behaviorsLookup = ->
  stickit: StickitBindingBehavior

これを使うと基本的には何も書かなくてもそれっぽくバインドしてくれるようになります。

class FormView extends Backbone.Marionette.ItemView

  template: "#form"
  behaviors:
    stickit: {}

ちなみに Backbone.Marionette + backbone.stickit + Browserify を使ったテンプレートを以前自分で作って github に公開しているので、良ければこちらをご参照下さい。
backbone.marionette.example

Omnibox については Chrome の extension と API を組み合わせているだけですが、操作感については「.」を打つことでページを切り替えられるような UI にしました。また、Chrome の Omnibox は一度に表示出来る候補リストのサイズが少ないため、サーバーの負荷を減らすためにサーバー側から取得した検索結果のバッファをページングし、終端まで辿り着いた段階で API を叩いてリモートから次のページを取得するようにしています。また、当然ですがキーボード入力は適当に間引いて負荷を減らしています。


4. まとめ

以上が今回開発した内容の概要になります。
Chrome 拡張の開発についてのまとめです。

  • Chrome の API ドキュメントは良く整備されており、Chrome extension のソースコードも GitHub などに沢山公開されているため、キャッチアップは比較的容易(但し GitHub に公開されている extension のコードは玉石混淆なため、安易に引用するのはオススメしません)
  • Yeoman の generator を使うと開発環境は何も考えずにセットアップできる
  • popup や omnibox の実装は、それほど Chrome 固有の知識を要求されるものではなく、HTML と JavaScript の知識があれば十分

「Chrome 拡張の開発」と言うとなんとなく敷居が高く感じてしまいますが、それほど難しい概念があるわけでもないので、皆さんも気楽に開発してみると楽しいのではないでしょうか。


NewsPicks では一緒に開発してくれるエンジニアを募集しています!様々なバックグラウンドを持つエンジニアや編集部の皆と世界一の経済メディアをつくりましょう!興味の在る方は是非 Wantedly などでお気軽にオフィスまでお越し下さい!


2014年12月4日木曜日

Scalaのパーサコンビネータにふれる

技術チームインターンの中村です。

内製化されたシステムを抱えた会社にいると,エンジニア以外の方のためにドメイン特化言語を構築するようなこともあるかと思います。 uzabaseの場合,アナリストがSPEEDAに載せる業界概要の記事を効率良く書けるようになるために,Markdownに似た軽量マークアップ言語が作られました。

作る言語が構文木が不要なほど小規模ならば,文字用ユーティリティだけで十分に言語実装が可能かと思います。 一方で,言語が大規模であったり効率の良いコンパイルが求められたりするのであれば,LexやYaccのようなパーサージェネレータが必要になるかもしれません。

 今回はその間くらい,つまり単純な文字列処理では足りないものの,パーサージェネレータを使うほどでもないくらいの言語を構築するときに便利なScalaのパーサーコンビネータについて紹介します。

受け取った入力の結果を返す関数としてパーサーを組合せることで,目的のパーサーを高階関数(コンビネータ)として構築したものがパーサーコンビネータです。 パーサーは文法構造の連続,繰り返し,選択などを実現する関数で組み合わされます。 このとき,言語が関数あるいはメソッドの中置記法をサポートするなら,パーサーコンビネータの定義はEBNFの生成規則と似たものになります。 たとえば,論理式のEBNFとこれに従う論理式をパースするパーサーコンビネータの対応は以下のようになります。

EBNF
expr ::= termA {"|" termA}
termA ::= termN {"&" termN}
termN ::= factor | {"!" factor}
factor ::= "TRUE" | "FALSE" | "(" expr ")"
|は選択,{}は0以上の繰り返し

パーサーコンビネータ
def expr: Parser[Any] = termA~rep("|"~termA)
def termA: Parser[Any] = termN~rep("&"~termN)
def termN: Parser[Any] = factor | "!"~factor
def factor: Parser[Any] = "TRUE" | "FALSE" | "("~expr~")"
|は選択,~は連続,rep()は0回以上の繰り返し
  
|, ~, repをメソッドとして別のParserを引数にとり,より複雑な文字列を受理するようなParserを返す,この繰り返しによって論理式を受理するパーサを構築しています。 上の定義は宣言的なので,どのような計算がパース過程で行われるのか定義から読み取ることができません。 そこで,アトミックなパーサと,パーサ同士を逐次的に繋げた場合の計算がどのような行われるか見て行きたいと思います。

最小のパーサはsuccess, failureというメソッドです。 これらは入力をまったく消費しません。 2つのメソッドは以下のように定義されています。

def success[T](v: T) = Parser{ in => Success(v, in) }

def failure(msg: String) = Parser { in => Failure(msg, in) }

ヘルパーメソッド
def Parser[T](f: Input => ParseResult[T]): Parser[T] = new Parser[T]{
  def apply(in: Input) = f(in)
}
  
Parserというのが,|や~をメソッドに持つパーサーのクラスです。 Inputは,パーサーが生の文字列だけではなく,トークンのストリームも読めるようにするために,パース対象を抽象化するクラスです。 Success, FailureはParseResultの子クラスとして,それぞれ成功,失敗したパース結果と残りの入力をまとめるクラスです。これらのクラスの宣言は以下のように定義されています。

abstract class Parser[+T] extends (Input => ParseResult[T]) {..}

case class Success[+T](result: T, override val next: Input) extends ParseResult[T] {
  ..
}

case class Failure(override val msg: String, override val next: Input) 
  extends NoSuccess(msg, next) {..}
  
success, failureの次に仕事をするパーサが以下のelemです。 これは,入力を適用すると,入力の先頭がeであれば,resultにe, nextに2番目以降の入力もつSuccessを返します。 また,先頭がeでなければFailureを返します。

def elem(e: Elem): Parser[Elem] = accept(e)
  
ここまでで,Parserが入力(Input)を受けつけ,ヘルパーメソッドのfにinputを適用した結果(result)と入力の残り(next)を返す関数であるようなイメージを持って頂けたと思います。
次に逐次的にパーサ同士をつなげるメソッド~を見ていきます。 ~は以下のように定義されています。

def ~[U](q: => Parser[U]): Parser[~[T, U]] = { lazy val = p = q
  (for {
    a<- this
    b<-p
  } yield new ~(a, b)).named("~")
}
  
new ~(a, b)は繋げられた2つのパーサーの結果をひとつにまとめるためケースクラス~のインスタンスです。 また,for式に必要なflatMap, mapの定義は以下になります。

def flatMap[U](f: T => Parser[U]): Parser[U] = Parser{ in => 
  this(in) flatMapWithNext(f) 
}

def map[U](f: T => U): Parser[U]  = Parser{ in => this(in) map(f)}
  
上の定義で使われるSuccessにおけるflatMapWithNextとmapは以下のようになります。

def flatMapWithNext[U](f: T => Input => ParserResult[U]): ParseResult[U] =
   f(result)(next)

def map[U](f: T => U) = Success(f(result), next)
  
そして,これらを用いてfor式を地道に展開していくと例えば次のように書き直せます。

new Parser[T~U] {
  def apply(in: Input) = Parser.this(in) match {
    case Success(r, n) => p(n) match {
      case Success(r1, n1) => Success(new ~(r, r1), n1)
      case Failure(msg, next) => Failure(msg, next)
    }
    case Failure(msg, next) => Failure(msg, next)
  }
}
  
最初のパーサーが返すParseResultにある残りの入力が次のパーサに渡されることで,計算が進めるようです。 上の定義を見ると,はじめのパーサが失敗すれば計算は終了。 成功すれば,次のパーサpに残りの入力(n)を渡す。 そこで失敗すれば後続する計算がなされず全体の計算が終了。 成功すれば両パーサの計算結果としてケースクラスの~が返されています。

 ここまでで,パーサーコンビネータの実体が引数をパース対象として受付け,関数適用の結果と残りのパース対象の組を返す関数のように振舞うことがなんとなく伝わったかと思います。

 さらに詳しく知りたい方はこちらを参考にパーサーコンビネータそのものを自作してみるとよいかもしれません。 リンク先の資料ではパーサコンビネータがモナド値であることを踏まえた上で実装方法を紹介しています。 事実,ScalaのParserやParseResultはモナド値となっています。
資料ではParserが次のように定義されています。

newtype Parser a = Parser (String -> [(a,String)])
  
InputがString, ParseResultが[(a, String)]とそれぞれ対応しています。 空リストがFailureを意味します。 最後に上のpdfの実装をScalaで試すときに,モナド則の確認やfor式の記述に最低限必要な定義を残しておくので,ご参考にしていただければと思います。

trait Parsers {
  def Parser[A](f: String => List[(A, String)]): Parser[A] = new Parser[A]{
    def apply(input: String) = f(input)
  }

  def success[A](v: A): Parser[A] = Parser{ input => List((v, input)) }

  def failure[A]: Parser[A] = Parser { input => List() }

  trait Parser[A] extends (String => List[(A, String)]) {
    def apply(input: String): List[(A, String)]

    def flatMap[B](f: A => Parser[B]) = Parser{ (input: String) =>
      for {
        (a, input2) <- this(input)
        b <- f(a)(input2)
      } yield b
    }

    def withFilter(pred: A => Boolean): Parser[A] = Parser { input: String =>
      for {
        elm <- this(input) if pred(elm._1)
      } yield elm
    }

    def map[B](f: A => B) = Parser {input =>
      for {
        a <- this(input)
      } yield(f(a._1), a._2)
    }
  }
}
  
付録, for式の展開過程

this.flatMap {
  a => p.map(b => new ~(a, b))
}

Parser {
  in => Parser.this(in) match {
    case Success(r, n) => ((a: T) => p.map(b => new ~(a, b)))(r)(n)
    case Failure(msg, next) => Failure(msg, next)
  }
}

Parser { in =>
  Parser.this(in) match {
    case Success(r, n) => Parser { in => p(in).map(b => new ~(r, b)) }(n)
    case Failure(msg, next) => Failure(msg, next)
  }
}

Parser { in =>
  Parser.this(in) match {
    case Success(r, n) => p(n) match {
      case Success(r1, n1) => Success(new ~(r, r1), n1)
      case Failure(msg, next) => Failure(msg, next)
    }
    case Failure(msg, next) => Failure(msg, next)
  }
}
  

2014年12月1日月曜日

【えびスタ!】12/18(木)勉強会を開催します【クックパッド × Retty × VASILY × UZABASE】


いま勢いのある恵比寿近辺のスタートアップを中心とした、エンジニア向けの技術勉強会です。
  • クックパッド
  • Retty
  • VASILY
  • UZABASE
のエンジニア陣が、最近の各社の取り組みについてお話させて頂きます。
後半は軽食を交えつつ、カジュアルな QA セッションの場や各社エンジニアとの懇親会も予定しておりますので、ご興味のある方は是非ご参加下さい!(エビスビールとピザもあります!)

残りの一般参加枠は9名(12/1現在)となっております。
興味のある方はお早めにお申し込みください!

申込みはこちらから(compass)
えびスタ!#1【クックパッド × Retty × VASILY × UZABASE】