オフトゥン大好き。

惰眠系プログラマの作業ログで( ˘ω˘ ) スヤァ…

react_on_railsとreact-routerでシングルページアプリケーションをサーバサイドレンダリングする

やりたいこと

rails + react + react-router (ゆくゆくは + redux)な環境でサーバサイドレンダリング可能なシングルページアプリケーションを作成したい。自分でも「何言ってんだお前」って感じだけど、やりたいんです。

react_on_rails

rails、react間のインテグレーションを実現するgemはreact-railsっていうのとreact_on_railsっていうのがある。react-railsの方が情報量が多かったけど、application.js

//= require react
//= require react_ujs
//= require components

ってぶちまける感じが好きじゃないのでちょっといじって投げ捨てました。そういうわけでreact_on_railsを使います。

gem 'react_on_rails', '~> 6'

インストールする。未コミットの差分があるとインストールできないので注意。

bundle exec rails g react_on_rails:install

コントローラにReactコンポーネントレンダリングするメソッド生やす。

ここから拝借したコード。下記のコードはRouterコンポーネントレンダリングする。まだそのコンポーネントを登録してないので後で{window,global}.ReactOnRailsに登録しないといけない。

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  def home
    render_for_react()
  end

  private

  # ref: http://r7kamura.hatenablog.com/entry/2016/10/10/173610
  def common_props
    {
      currentUser: current_user,
    }
  end

  def render_for_react(props: {}, status: 200)
    if request.format.json?
      response.headers["Cache-Control"] = "no-cache, no-store"
      response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
      response.headers["Pragma"] = "no-cache"
      render(
        json: common_props.merge(props),
        status: status,
      )
    else
      render(
        html: view_context.react_component(
          "Router",
          prerender: true,
          props: common_props.merge(props).as_json,
        ),
        layout: true,
        status: status,
      )
    end
  end
end

npmを足しとく

# in client/
yarn add react-router react-helmet

{server,client}Registration.jsx

それぞれサーバサイドとブライザで実行するjsをwebpackでバンドルする際のエントリファイル。 react-router使わないならデフォルトで生成されるregistration.jsxだけでいいんだけど、使う場合はそれぞれ別のRouterを用意する必要があった。この辺で頭がおかしくなってコードが釈迦になりそう。

こっちはブラウザ用のコード。

// client/app/bundles/Application/startup/clientRegistration.jsx
import React from 'react';
import { Router as ReactRouter, browserHistory } from 'react-router';
import ReactOnRails from 'react-on-rails';
import routes from '../routes/routes';

const Router = (props, railsContext) => {
  const history = browserHistory;
  return (
    <ReactRouter history={history}>
      {routes}
    </ReactRouter>
  );
};

// This is how react_on_rails can see the Router in the browser.
ReactOnRails.register({
  Router,
});

こっちはサーバサイドレンダリング用のコード。routes.jsxは両方でimportして共有している。

// client/app/bundles/Application/startup/serverRegistration.jsx
import React from 'react';
import { match, RouterContext } from 'react-router';
import ReactOnRails from 'react-on-rails';
import Helmet from 'react-helmet';
import routes from '../routes/routes';

// for header title server side rendeting on first load
// ref: http://r7kamura.hatenablog.com/entry/2016/10/10/173610
global.Helmet = Helmet;

const Router = (props, railsContext) => {
  let error;
  let redirectLocation;
  let routeProps;
  const { location } = railsContext;

  match({ routes, location }, (_error, _redirectLocation, _routeProps) => {
    error = _error;
    redirectLocation = _redirectLocation;
    routeProps = _routeProps;
  });

  if (error || redirectLocation) {
    return { error, redirectLocation };
  }

  return (
    <RouterContext {...routeProps} />
  );
};

ReactOnRails.register({
  Router,
});

webpackの設定も書き換えておく。

// client/webpack.config.js
/**
 * from
 */
entry: [
    'es5-shim/es5-shim',
    'es5-shim/es5-sham',
    'babel-polyfill',
    './app/bundles/Application/startup/registration',
],
output: {
    filename: 'webpack-bundle.js',
    path: '../app/assets/webpack',
},


/**
 * to
 */
entry: {
    vendor: [
      'es5-shim/es5-shim',
      'es5-shim/es5-sham',
      'babel-polyfill',
    ],
    app: [
      './app/bundles/Application/startup/clientRegistration',
    ],
    server: [
      './app/bundles/Application/startup/serverRegistration',
    ]
},
output: {
    filename: '[name]-bundle.js',
    path: '../app/assets/webpack',
},

これでvendor-bundle.jsapp-bundle.jsserver-bundle.jsの3つが出力されるようになった。さらに、react_on_railsの設定ファイルも書き換えておく。さっき分けたサーバサイド用のjsファイルをExecJSで実行するように指定する。

# config/initializers/react_on_rails.rb
# from
config.webpack_generated_files = %w( webpack-bundle.js )
# to
config.webpack_generated_files = %w( app-bundle.js server-bundle.js vendor-bundle.js )

# from
config.server_bundle_js_file = "webpack-bundle.js"
# to
config.server_bundle_js_file = "server-bundle.js"

turbolinksを殺す

react-routerでトランジションするSPAという状況下で、いらない子になったturbolinksを殺す。さようなら。お前に苦しめられた日々、忘れないよ。 Universalの苦しみ、こんにちは。

// app/assets/javascripts/application.js
//= require vendor-bundle
//= require app-bundle
//= require jquery
//= require jquery_ujs

もちろんGemfileからも抹殺する。それからlayoutを書き換える。

<!-- app/views/layout/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <%= server_render_js("Helmet.rewind().title.toString()") %>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag 'application', media: 'all' %>
  </head>
  <body>
    <%= yield %>
    <%= javascript_include_tag 'application' %>
  </body>
</html>

起動

# in app root dir
foreman start -f Procfile.dev

ブラウザのインスペクタを開いてconsoleにエラー吐いてなかったら多分大丈夫。ちゃんとなってればreact-routerLinkコンポーネントで遷移した時と、URL直だたきのときで全く同じページが表示されるはず。 違うページが表示されたりエラーが出る場合は、だいたいcontrollerを作ってなかったとか、routes.rbでマウントし忘れてたとかいうオチ。

参考になったページとか

それなりに動くようになったもの

今後やりたいこと

  • 今のところdeviseがSPA構成をガン無視していてつらい。コントローラとかビューをreact_componentヘルパ向けに書き直す必要がありそう。
  • ブラウザに配信するjsの出力先を/publicにしてsproketsを使わないようにする。jqueryとかもnpmを使う。
  • jbuilderを投げ捨ててrails-apiが搭載のactive_model_serializersをpropsのシリアライズに使いたい。
  • テストの知見を貯めたい。

放映中のアニメリストを教えてくれるhubot-annictを作った

とあるエンジニア集団が集うSlackにhubotを飼うことになってアニメ関連のコマンドが欲しいというissueを立てた。hubotはcoffeeがデフォルトだけどjavascriptを使えるので去年作ったannict.jsで現在のクールに放映中のアニメリストを表示するためのhubot scriptを作った。

概要

以下のように動作する。

hubot> anime now
hubot> Shell: <a href="http://project-itoh.com/">虐殺器官</a>
<a href="http://konosuba.com/">この素晴らしい世界に祝福を!2</a>
<a href="http://www.kuzunohonkai.com/">クズの本懐</a>
<a href="http://demichan.com/">亜人ちゃんは語りたい</a>
<a href="http://masamune-tv.com/">政宗くんのリベンジ</a>
<a href="http://chaoschildanime.com/">CHAOS;CHILD</a>
<a href="http://tv.littlewitchacademia.jp/">リトルウィッチアカデミア</a>
<a href="http://www.tbs.co.jp/anime/urara/">うらら迷路帖</a>
<a href="http://youjo-senki.jp/">幼女戦記</a>
<a href="http://sao-movie.net/">劇場版 ソードアート・オンライン -オーディナル・スケール-</a>
<a href="http://www.tbs.co.jp/anime/seiren/">セイレン</a>
.
.
.

https://gyazo.com/0b6876326dbad1a5cffa38c68ebd4943

使い方

データは例のごとくAnnictから取得させていただくので、事前にAnnictの登録が必要。

Annict | アニクト - 見たアニメを記録して、共有しよう

Annictにデベロッパー登録する

Annictにはまだユーザがトークンを発行する機能が用意されていないので、デベロッパー登録を行って手動でトークンを吐き出させる。

Annictアプリケーション登録

https://gyazo.com/c5731b3ccae4da5e26a38ff6d4436ae9

登録したら[認可する]ボタンで認証コードを取得する。

Annict APIのアクセストークンを取得する

コマンドラインから以下のようにcurlでトークンを取得する。起動時に使用するのでaccess_tokenフィールドをメモっておく。

$ curl -F client_id=<アプリケーションID> \
-F client_secret=<シークレットキー> \
-F grant_type=authorization_code \
-F redirect_uri=urn:ietf:wg:oauth:2.0:oob \
-F code= <認証コード> \
-X POST https://api.annict.com/oauth/token

hubot-annictのセットアップ

hubotのルートディレクトリでインストールする。

$ yarn add hubot-annict

その後、external-scripts.json'hubot-annict'を追加。これでインストール完了。

hubot起動

環境変数HUBOT_ANNICT_TOKENに取得したAnnictトークンを設定して起動する。

$ HUBOT_ANNICT_TOKEN="<annict_access_token>" ./bin/hubot

終わりに

Annictの波、きます!

@shimbacoさん、最近海外のアニメ記録サイト大手Kitsuの中の人と仲良くなってて、ワールドワイドな協定が結ばれて欲しいと個人的な願望を抱いている。

参考

関連記事

ウェブアプリ開発日誌:テンプレートエンジンをerbからslimに変えるあれこれ

erbは入門にはわかりやすいですが慣れてくると冗長です。slimが良いらしいのでerbから入れ替えてみます。

slimをインストールする

Gemfileにslimを追加してbundle installします。

gem 'slim-rails'

erb2slimで既存のテンプレートを変換する

$ gem install html2slim
$ for i in app/views/**/*.erb; do erb2slim $i ${i%erb}slim && rm $i; done

ワンライナーapp/views/以下のファイルをまとめて変換できます。一回使ったら間違ってERBファイルを生成してしまわない限り、もう使わないのでGemfileには書かなくていいです。rbenvなどを使用していて、エラーが出る場合はrbenv rehashしてください。

deviseを使用する場合などは、

自動生成されるファイルもslimにする

config/application.rbに以下を追記する。

module MyApp
  class Application < Rails::Application
    config.i18n.default_locale = :ja
    config.generators.template_engine = :slim
  end
end

これでrails gで生成されるビューファイルもslimに変わります。

参考文献

関連記事

nukosuke.hatenablog.jp

ウェブアプリ開発日誌:Rails5でユーザ登録機能を実装するまでのあれこれ

Railsアプリにユーザ登録・認証を実装するまでの手順を書きます。ここでいうユーザ登録は、よくあるユーザ名とメールアドレスを入力して届いた認証リンクにアクセスするとアクティベートされるタイプのやつです。

環境

  • Ruby 2.3.0
  • Rails5 (5.0.2)
  • devise (4.2.0)
  • omniauth, omniauth-twitter
  • figaro

rails new

プロジェクトディレクトリを作ります。今回はデータベースにMySQLを使うのと、テストにはRSpecを使いたいのでデフォルトのテストは外しておきました。

rails new myapp -T -B -d mysql

付与したオプションは次の通り。

  • -T デフォルトのテストコード生成を外す
  • -B 生成直後のbundleをスキップする
  • -d mysql データベースをMySQLに変更する(デフォルトはPostgreSQL

deviseを導入する

deviseはrailsでユーザ登録・認証機能を実装するgemです。正直ユーザ機能つけるならこれ一択みたいなところがあって、他にもないわけではないですがOAuthやらAPI作成やらとの連携も最も進んでいてわざわざ他のを選ぶ理由がないという感じです。

Gemfileに認証系のgemを追加。

gem 'devise'
gem 'omniauth'
gem 'omniauth-twitter'

bundle install後、bundle exec rails g devise:installでdeviseの設定ファイルを生成します。具体的にはconfig/initializers/devise.rbconfig/locales/devise.en.ymlができますが、それは今のところどうでもよくてその後に表示される手順が重要です。

Some setup you must do manually if you haven't yet:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views

これを一つ一つ順番に実行していきましょう。

Userモデルの作成

認証に用いるモデルを作成します。名前はなんでも構いませんが、特に理由がなければUserが無難だと思います。

$ rails g devise User

できたモデルファイルを変更します。

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable,
         :confirmable, :timeoutable, :omniauthable, omniauth_providers: [:twitter]
end

また、マイグレーションファイルからコメントアウトをいくつか外してカラムを追加します。

class DeviseCreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.string   :current_sign_in_ip
      t.string   :last_sign_in_ip

      ## Confirmable
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at


      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

今回はlockableを省きましたが、アカウントのロック機能を利用する場合はこれもコメントアウトを外します。

figaroを導入

omniauthでtwitter認証を導入するのはいいですがAPIのトークンやシークレットをそのままコードに書くのはダメです。あとで消すつもりでも、うっかりコミットしてしまったりしたら最悪です。 運用では環境変数にこの手のものを指定するのが定石ですが、開発環境でいちいち変数をexportするのも面倒なのでfigaroというgemを使います。 これは変数をファイルに書き出しておいて、それを環境変数として読み込んでくれるやつです。したがって、開発環境ではgitignoreでバージョン管理から外れたファイルで、本番では環境変数で指定といった使い分けができます。

gem 'figaro'

bundle installしたらfigaroで必要になるファイルをインストールしましょう。

bundle exec figaro install

これでconfig/application.ymlというファイルが追加され、.gitignoreにそれが追記されたはずです。このapplication.ymlが環境変数を指定するためのファイルになります。

twitter認証を追加する

Twitter Application Managementにアクセスしてアプリケーションの登録を行います。

https://gyazo.com/5ce2926e4e9f9875b04ed82c2a755d0f

APIトークンとシークレットを設定する

入手したトークンとシークレットをapplication.ymlに記述し、それを環境変数として読み込めるようにします。

development:
    TWITTER_API_KEY: '<token>'
    TWITTER_API_SECRET: '<secret>'

config/initializers/devise.rbにomniauthの設定を記述します。figaroが設定した環境変数を読み出すための初期化コードです。

Devise.setup do |config|
  config.omniauth :twitter, ENV['TWITTER_APP_ID'], ENV['TWITTER_APP_SECRET']
end

これでapplication.ymlを別に共有しておけば開発環境でいちいち変数を設定する手間が省けますし、環境別にファイルを分けたりすることもできます。このファイルの共有方法に関しては議論の余地がありますが、少なくともバージョン管理に含めることが間違いなのは明らかなのでそこからの漏洩リスクはこれでどうにかなりました。

OAuth用のマイグレーションを作成する

ここまでやったらあとはbundle exec rake db:migrateを実行します。これでデータベースに認証に必要なテーブルができました。

callback用のコントローラを作る

twitterアプリ作成時に指定したコールバックURLを処理できるようにコントローラを作ります。

$ rails g controller omniauth_callbacks

omniauth_callbacks_controller.rbを以下のように変更する。

class OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def twitter
    @user = User.from_omniauth(request.env["omniauth.auth"].except("extra"))

    if @user.persisted?
      sign_in_and_redirect @user
    else
      session["devise.user_attributes"] = @user.attributes
      redirect_to new_user_registration_url
    end
  end
end

参考文献

No.227 簡単ポーカー 解法

No.227 簡単ポーカー - yukicoderを解いた。

手札の役を作る処理は決定性有限オートマトンに置き換えられる。

開始状態はNO HANDとし、空のスタックを用意してソートした入力リストを先頭から順に積んでいく。ソートは同じ値をグループ化するために行なっているので昇順でも降順でも構わない。

  • 入力リストから取り出した値がスタックtopと同じならそのまま積む
  • 入力リストから取り出した値がスタックtopと異なるならスタックサイズを入力として状態遷移し、スタックを空にしてから値を積む

状態遷移表は次のようになる。TWO PAIRが必要ないのは入力リストから取り出した値がスタックtopと同じであるかぎり積んでいくのでTWO PAIR => FULL HOUSEという遷移はあり得ないため。

# \ s NO HAND ONE PAIR THREE CARD
2 ONE PAIR TWO PAIR FULL HOUSE
3 THREE CARD FULL HOUSE -

(#:スタックサイズ, s:現在の状態)

これを元にパスを作っていくと次のような状態遷移図が出来上がる。

f:id:nukosuke:20161023162936p:plain:w320

これをPerlで実装した。

sub trans {
  my ($state, $stack_size) = @_;
  if    ($state eq "NO HAND"    && $stack_size == 2) { return "ONE PAIR";   }
  elsif ($state eq "NO HAND"    && $stack_size == 3) { return "THREE CARD"; }
  elsif ($state eq "ONE PAIR"   && $stack_size == 2) { return "TWO PAIR";   }
  elsif ($state eq "ONE PAIR"   && $stack_size == 3) { return "FULL HOUSE"; }
  elsif ($state eq "THREE CARD" && $stack_size == 2) { return "FULL HOUSE"; }
  else  { return $state; }
}

my @A = sort { $a <=> $b } map { $_ + 0 } split ' ', <>;
my $state = "NO HAND";
my @stack = ();

foreach (@A) {
  if ($#stack >= 0 && $_ != $stack[$#stack]) {
    $state = trans($state, $#stack+1);
    @stack = ();
  }
  push @stack, $_;
}
print(trans($state, $#stack+1) . "\n");

関連記事