react_on_railsでreduxを使う
前回の続きでreduxを導入しました。
必要なnpmを追加
redux-thunkは非同期アクションを実現するパッケージなのであまり関係ありませんが、後々入れることになると思うのでついでに入れておきます。
yarn add redux react-redux react-router-redux redux-thunk
コードにstoreを追加する
サーバサイドのルーター。
ブラウザのルーター。
コントローラにもstoreを追加する
# application_controller.rb 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 redux_store( "store", props: common_props.merge(props).as_json, ) render( html: view_context.react_component( "Router", prerender: true, ), layout: true, status: status, ) end end
テンプレートに<%= redux_store_hydration_data %>
を追加。
reducerを追加
propsとrailsContextをinitialStateにぶち込み、react-router-reduxの提供しているルーティングに関するreducerと後で作るその他のreducersを全部放り込んでcombineReducersでまとめます。
const initialState = { ...props, railsContext, }; const reducer = combineReducers({ ...reducers, routing: routerReducer, });
この時点でrailsContextReducerがないとエラーが発生するので、こいつを作っていきます。とはいっても最初のロード以外でこいつが処理を行うことは基本的にありませんのでstate
をそのまま返すだけのものです。
export default function railsContextReducer(state = {}) { return state; }
props
を出力すると以下のようにrouting
とrailsContext
が含まれていることがわかります(reduxのconnect
でstate
をそのままprops
にマッピングした場合の出力)。
関連記事
react_on_railsとreact-routerでシングルページアプリケーションをサーバサイドレンダリングする
- やりたいこと
- react_on_rails
- コントローラにReactコンポーネントをレンダリングするメソッド生やす。
- npmを足しとく
- {server,client}Registration.jsx
- turbolinksを殺す
- 起動
- 参考になったページとか
- それなりに動くようになったもの
- 今後やりたいこと
やりたいこと
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.js
、app-bundle.js
、server-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-router
のLink
コンポーネントで遷移した時と、URL直だたきのときで全く同じページが表示されるはず。
違うページが表示されたりエラーが出る場合は、だいたいcontrollerを作ってなかったとか、routes.rb
でマウントし忘れてたとかいうオチ。
参考になったページとか
- Using React Router | react on rails
- react-webpack-rails-tutorial
- Ruby on Rails on React on SSR on SPA | r7kamura
それなりに動くようになったもの
今後やりたいこと
放映中のアニメリストを教えてくれる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> . . .
使い方
データは例のごとくAnnictから取得させていただくので、事前にAnnictの登録が必要。
Annict | アニクト - 見たアニメを記録して、共有しよう
Annictにデベロッパー登録する
Annictにはまだユーザがトークンを発行する機能が用意されていないので、デベロッパー登録を行って手動でトークンを吐き出させる。
登録したら[認可する]ボタンで認証コードを取得する。
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の波、きます!
国際化の波を感じる!🙏🙏🙏 / Fix some English grammar by magnonellie · Pull Request #653 · annict/annict https://t.co/X7qNLfL4PQ
— Koji Shimba (@shimbaco) January 10, 2017
Chatbotの波も感じる!!🙏🙏🙏 https://t.co/WcGOxiA7jW
— Koji Shimba (@shimbaco) January 10, 2017
国際化の流れPart2だ!🙏🙏🙏 / Fix some English grammar II by wopian · Pull Request #655 · annict/annict https://t.co/CEVXyrHsO6
— Koji Shimba (@shimbaco) January 11, 2017
@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に変わります。
参考文献
- Rails4/viewsのerbをSlimに一括変換するRubyワンライナー | もっちブログ - Webサービス開発や起業日記 / Labit 鶴田浩之の個人ブログ
- [Rails4,Rails5] erbファイルをSlimに一括変換するGemとRubyワンライナー - Qiita
- railsにslimを導入し、erbファイルをslimファイルに一括変更する方法 - Qiita
関連記事
ウェブアプリ開発日誌:Rails5でユーザ登録機能を実装するまでのあれこれ
Railsアプリにユーザ登録・認証を実装するまでの手順を書きます。ここでいうユーザ登録は、よくあるユーザ名とメールアドレスを入力して届いた認証リンクにアクセスするとアクティベートされるタイプのやつです。
環境
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.rb
とconfig/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にアクセスしてアプリケーションの登録を行います。
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