JavaScript
Twitter
Meteor
React
material-ui

Meteor and Reactによるリアクティブシステム「ラーメン野郎を追いかけろ! @ Twitter」を作ってみた

More than 1 year has passed since last update.

Netflix Falcorについて(1)
Falcor+Reactのフルスタック開発環境

ラーメン野郎を追いかけろ! @ Twitter
http://www.mypress.jp/

 Webアプリのクライアント側はJavaScript以外の選択肢はないのですが、サーバ側はプログラミング言語なら何でもありだと思われます。個人でアプリを作成しする場合、サーバ側もJavaScriptを選択するのは自然のような気がします。もちろんNetflixのような巨大企業でもnodeのexpress(restify?)を採用している例もあるので、個人に限った話ではありませんが。

 サーバ側のWebフレームワークとして、私が一番気に入っているのはMeteorです。本当に手軽にアプリ作成を行えるからです。しかも、Reactと組み合わせれば、バックエンドのDBからクライアントまで通して、リアクティブなシステムを簡単に作ることができるからです。

 今回それを実証すべく簡単なWebアプリを作成してみました。タイトルは「ラーメン野郎を追いかけろ! @ Twitter」です。TwitterのAPIを使って、「ラーメン」というキーワードを含むTweetを取得して、ブラウザ上のマップに投稿位置と投稿内容を表示するといううものです。ジャックバウアー風に言えば「全てはリアルタイムに進行する」です。ブラウザ(chrome)で以下のサイトにアクセスするだけで、Twitterの投稿をリアルタイムタイムに取得して、リアルタイムに表示してくれます。
http://www.mypress.jp/

 Meteorでのプログラミング作成は、インストールや設定も簡単です。サーバプログラムも簡単です。クライアントプログラムも簡単です。以下にそれを示していきたいと思います。

 まずはインストール・設定です。あわせてパッケージも一気にインストールしておきます。説明は後で行います。作業だけを列挙していきます。

 まだMeteorをインストールしていない場合は以下のコマンド一発でインストールできます。nodeやMongoDBもこれだけでインストールできます。npmやWebpack、Babelのインストールも不要です。

Meteorのインストール
curl https://install.meteor.com/ | sh

 公式サイトも参照してみてください。ここのtutorialは秀逸です。
https://www.meteor.com/

 次にプロジェクト作成です。初期プログラムを削除しておきます。

プロジェクト作成
meteor create qiita-twitter-leaflet
cd qiita-twitter-leaflet
rm client/*
rm server/*

 次に今回のプログラムに必要なパッケージをインストールします。

パッケージのインストール
meteor npm install react react-dom leaflet react-leaflet --save
meteor npm install --save material-ui
meteor add react-meteor-data
meteor npm install twitter --save
meteor npm install kuromojin --save

 meteorのセキュリティ設定です。最初にクライアントからサーバのデータを変更する際のセキュリティを設定し、次の行でクライアントから読み取ることができるサーバデータの制限を行います。デフォルトでは全データにアクセスできるので、そのデフォルト設定を解除しているわけです。

セキュリティ設定
meteor remove insecure
meteor remove autopublish

 以上で、インストールと設定を終えましたので、システム&プログラムの説明に移ります。

 まずはシステムについてですが、Meteor と Reactの役割について簡単にまとめておきます。

 ReactはブラウザのViewのみに特化したライブラリです。Meteorはそれ以外のサーバとクライアント全てをカバーするインフラです。ReactはMeteorの上で動作しているイメージです。

システム全体図
Twitter -- <StreamAPI> --> Meteor (MongoDB) -- <DDP> --> Minimongo -- <createContainer> --> React
                           |----サーバ-----|             |-------------クライアント--------------|

※Minimongoはブラウザで動作するMongoDBのサブセット
※DDPはMeteorのサーバとクライアント通信のための独自プロトコル
※createContainerはReactをMeteor上で利用するための関数

 システムは4個の要素であるTwitterとMeteor(MongoDB)、Minimongo、Reactから構成されています。それぞれの要素の接続は、<StreamAPI>と<DDP>、<createContainer>が担っています。今回のシステムの一番の肝は、この3個の接続がそれぞれリアクティブに動作する点です。つまりTwitterで投稿があると、即座にReact画面に反映されます。ブラウザは自動的に更新されます。ブラウザを開いているだけで、24時間、リアルタイムに、ラーメン野郎を追跡できるのです。

 次にサーバ側のプログラムの説明です。server/main.jsの仕事はTwitterのstream dataをリアルタイムに取得してMongoDBにinsertすることです。MongoDBを更新すれば、後は自動的にDDPでMinimongoにその更新が反映されます。このMongoDBとMinimongoの同期こそがMeteorの心臓部と言えるでしょう。プログラムコードのほとんどはTwitter APIのためのものですが、API自体はシンプルな使いやすいものです。また全てのデータはJSONでやりとりされますので、MongoDBへの反映はTweet.insert()の1行で済みます。kuromojを使って形態素解析を行っていますが、ここでは本質的な意味を持ちません。単に「ラーメン」というキーワードを含むかの判定に使っているだけです。

server/main.js
require('../env') /* Twitterのキーを読み込む */
import {Meteor} from 'meteor/meteor'
import Twitter from 'twitter' //npm install twitter

/* ラーメンの文字列を検出するために形態素解析を行うが、あまり必要はない*/
const kuromojin = require("kuromojin");

let Tweet = new Mongo.Collection('tweet');

/* remove autopublish は行っておく */
Meteor.publish('tweet', function tweetPublication() {
  return Tweet.find();
});


/* Twitter の開発用のキーは予め取得しておく */
let client = new Twitter({consumer_key: process.env.CONSUMER_KEY,
                          consumer_secret: process.env.CONSUMER_SECRET, 
                          access_token_key: process.env.ACCESS_TOKEN_KEY, 
                          access_token_secret: process.env.ACCESS_TOKEN_SECRET});

let filter_phrase="ラーメン";

Meteor.startup(() => {

  const parser = (data) => {
    if ( data.text.includes(filter_phrase) ) { // tweetがphrase_filterを含めばMongoにinsertする
      let d = new Date();
      let n = d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds();

      kuromojin(data.text).then(tokens => {
          //score = analyze(tokens); //ここで形態素解析の結果をもとに何らかのスコアを計算することができる
          Tweet.insert({
            time: n,
            score: 1,
            phrase: filter_phrase,
            tweet: data.text,
            coordinates: data.coordinates,
            timestamp: Date.now()
          });
      });
    }
  }

  /* Twitter Streaming API */
  let stream_geo = client.stream('statuses/filter', {'locations': '-180,-90,180,90'}); //全世界

  stream_geo.on('data', Meteor.bindEnvironment(data => {
    if (data.lang === 'ja') {
      if(data.coordinates) { 
          parser(data)
      }
    }
  }));

});

 次にクライアント側のプログラムです。client/main.htmlとclient/main.jsはReact(Meteor)のいつものファイルです。唯一の注意点はmain.htmlで2つのstylesheetを読み込んでいる点です。これは地図ライブラリのLeafletを使用する点で必須なものです。leafletを初めて使ったときに知らずに悩みました。

client/main.html
<head>
  <title>ラーメン野郎を追いかけろ!  Twitter</title>
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/7.0.0/normalize.min.css">
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/leaflet/1.1.0/leaflet.css">
    <style>
      h1, h2, p {
        font-family: sans-serif;
        text-align: center;
      }
      .leaflet-container {
        height: 480px;
        width: 80%;
        margin: 0 auto;
      }
    </style>
</head>

<body>
  <div id="render-target"></div>
</body>
client/main.js
import React from 'react';
import { Meteor } from 'meteor/meteor';
import { render } from 'react-dom';

import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import getMuiTheme from 'material-ui/styles/getMuiTheme';

import App from './App';

Meteor.startup(() => {
  const target = document.getElementById('render-target');
  const node =(
    <MuiThemeProvider muiTheme={getMuiTheme(lightBaseTheme)}>
      <App />
    </MuiThemeProvider>
  );

  render(node, target);
});

 ですから以下のclient/App.jsがクライアント側の唯一のプログラムファイルと言えます。サーバとクライアントでそれぞれプログラムファイルが1個で済んでいます。地図はgoogle mapではなくleaflet というライブラリを使っています。React用にreact-leafletというパッケージもインストールします。

 またUIとしてMaterial-UIを使っています。Tabs componentでマップ画面とタイムライン画面を切り替えていますが、ソース上は1つのcomponentです。React-router等は使っておらずシンプルなものです。タイムライン画面の方は、更にMaterial-UIのPaperとListという2つのcomponentを使って見た目を整えています。

client/App.js
import React, { Component, PropTypes }  from 'react'
import {render} from 'react-dom'
import { createContainer } from 'meteor/react-meteor-data';
import {Map, Marker, Popup, TileLayer, LayerGroup} from 'react-leaflet'

import Paper from 'material-ui/Paper';
import {Tabs, Tab} from 'material-ui/Tabs';
import FontIcon from 'material-ui/FontIcon';
import {List, ListItem} from 'material-ui/List';
import Divider from 'material-ui/Divider';
import Subheader from 'material-ui/Subheader';
import Avatar from 'material-ui/Avatar';

const Tweet = new Mongo.Collection('tweet');

class App extends React.Component {
  constructor() {
    super();
    this.state = {lat: 40, lng: -74, zoom: 6,};
  }

  render() {
    const {tweets} = this.props;
    let center_position = [this.state.lat, this.state.lng];
    if( tweets && tweets[0]) {
        center_position = [ tweets[0].coordinates.coordinates[1], tweets[0].coordinates.coordinates[0] ];
    }

    let Markers = [];
    for (let i = 0; i < tweets.length; i++) {
        let tweet_position = [ tweets[i].coordinates.coordinates[1], tweets[i].coordinates.coordinates[0] ];
        let tweet_coordinates = JSON.stringify(tweets[i].coordinates);
        Markers.push(
            <Marker position={tweet_position} key={tweets[i]._id}>
              <Popup>
                <div>
                  <div>{tweets[i].time}</div>
                  <div>{tweets[i].tweet}</div>
                  <div>{tweet_coordinates}</div>
                </div>
              </Popup>
            </Marker>
        )
    }

    const items = tweets.map((data) => (
        <div key={data._id}>
          <ListItem 
            leftAvatar={<Avatar>野郎</Avatar>}
            primaryText={data.time}
            secondaryText={data.tweet}
            secondaryTextLines={2} />
          <Divider inset={true} />
        </div>
      ));


    return (
    <Tabs>
      <Tab label="ラーメンマップ">
        <br />
        <h1>ラーメン野郎を追いかけろ! Map</h1>
        <p>世界中のラーメン野郎を追いかけます  Twitter</p>
        <p>食レポはリアルタイムで進行する --- マップは食レポが投稿され次第、自動的に更新されます。</p>
        <p>マーカーをクリックするとTweetsが表示されるよ</p>
        <Map center={center_position} zoom={this.state.zoom}>
          <TileLayer attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' url='http://{s}.tile.osm.org/{z}/{x}/{y}.png' />
          <LayerGroup>
            {Markers}
          </LayerGroup>
        </Map>
      </Tab>

      <Tab label="ラーメンタイムライン">
        <br />
        <h1>ラーメン野郎を追いかけろ! Tweets</h1>
        <p>ラーメン野郎の食レポだよ!</p>
          <Paper style={{width: '90%', margin: 40}} zDepth={1}>
            <List>
                <Subheader>食レポはリアルタイムで進行する</Subheader>
                {items}
            </List>
          </Paper>
      </Tab>
    </Tabs>
    );
  }

}

App.propTypes = {
  tweets: PropTypes.array.isRequired,
};

export default createContainer(() => {
  Meteor.subscribe('tweet');
  return {
    tweets: Tweet.find({},{limit:15, sort:{timestamp:-1}}).fetch(),
  };
}, App);

 以上で今回の説明は終わりますが、Meteorを使えばリアルタイムなシステムが簡単に作成できることを少しでも感じ取って頂ければ幸いです。個人的にはラーメンの食レポをマップ上で見ているのは楽しいものです。ちなみに7月ぐらいはキーワードを「花火大会」に変えて、日本国中から上がってくる「花火大会」の報告を楽しんでいました。