ある日突然、大慌てでWebアプリを作らなくてはいけなくなった
このエントリは、第二のドワンゴ Advent Calendar 2020の23日目です。
このエントリは、大急ぎでRails+Reactのアプリケーションを作るときにやったこと前編の続きです
このエントリは、自分は仕事でRails+ReactのAPI+SPAプロジェクトをいくつか経験してきたが、0からその環境を作ったことがないということに気づいてしまった私の記録です。多くの躓きを経て、非常に非常にかんたんな機能しか持たないアプリをつくるのにのべ20時間ほどの時間を要しまし、自分はReact+Railsエンジニアになったつもりでいたという反省文、その話の後編です。これから書くことは、0からRails+React環境を用意したことのない人に向けて書く、まさに書くは一時の恥、書かぬは一生の恥のエントリです。
前回までにやったこと
前編では、サンプルアプリ「ねこねこかわいい」を作るために以下の工程を説明しました。
- rails new
- DBの用意
- Webpackerの設定
- RailsとReactのインテグレーション
- UI FrameworkとしてSmartHR UIの導入
後編のこのエントリでは
- axiosのクライアントを用いてReactからRailsに通信
- Google OAuthでユーザー登録
- S3に画像の投稿
- Herokuへのデプロイ
までを解説します。
axiosクライアントの作成
ReactからRailsのAPIに通信するためには、axiosを使う。なんでaxioかというと、手でFetchAPIを書くよりはだいぶ書き心地が良いことと、将来的にOpenAPIなどでスキーマ定義を作成して通信クライアントを自動生成するときにaxiosを使ったクライアントを選択できるからと考えたから。自動生成されたaxiosのクライアントに切り替えるときに、大きなショックが無いようにということですね。それ以外は特に積極的な理由はないです。
axiosをインストール。ついでに、Cookieもいい感じに処理してほしいので、axios-cookiejar-supportとtouch-cookieも入れる。ただ、入れてから気づいてまだ検証してないけど、Cookie系はもしかしたら必要ない可能性もある(とはいえ、最終的にSPAに持っていくときにはいると思うので入れておいて悪いことはないんじゃないかな、くらいに思っている)。
1 2 |
yarn add axios @types/axios yarn add axios-cookiejar-support tough-cookie @types/tough-cookie |
[yarn add axios @types/axios · kinoppyd/nekonekokawaii@8a9e5fd]
[yarn add axios-cookiejar-support tough-cookie @types/tough-cookie · kinoppyd/nekonekokawaii@6c0a6cd]
入れたら、どこのコンポーネントからでも使えるClientクラスをつくておく。また、Railsとの通信ではCSRFで保護してほしいので、CSRFトークンも送れるようにしておく。ここでのCSRFトークンは、Rails側のViewレンダリングでheaderに埋め込まれたものを取得して使う。つまり、いまはRailsのViewにReactコンポーネントを埋め込んでいるので対応可能だが、完全SPAなどにシフトしていくためには、また違う方法をとる必要がでてくるということになる。が、とりあえず今はこれで十分。
1 2 3 4 5 6 7 8 9 10 11 12 |
import axios from 'axios' import axiosCookieJarSupport from 'axios-cookiejar-support' import { CookieJar } from 'tough-cookie' axiosCookieJarSupport(axios) const element: HTMLMetaElement = document.getElementsByName('csrf-token')[0] as HTMLMetaElement axios.defaults.headers.common['X-CSRF-Token'] = element.content const cookieJar = new CookieJar() const client = axios.create({jar: cookieJar}) export default client |
これを実際に利用するときには、各API用のクラスを作成して分割しておくと、後々ごちゃごちゃしなくて良いと思う。ひとまず簡単に、/postsにPOSTするだけのクライアントを作る。
1 2 3 4 5 6 |
import { AxiosInstance } from 'axios' import client from './client' export const createPost = (title: string, body: string) => { return client.post('posts', {title, body}) } |
[Add api client · kinoppyd/nekonekokawaii@56b9d11]
もちろん、Rails側にこのリクエストに対応するエンドポイントもはやしておく必要がある。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class PostsController < ApplicationController def index @posts = Post.all.order(created_at: :desc) end def show @post = Post.find(params[:id]) end def create post = Post.create(create_params) post.save! render json: post end private def create_params params.permit(:title, :body) end end |
[Add Create post action · kinoppyd/nekonekokawaii@cb536da]
最後に、フロント側に非常に雑ではあるがAPI Clientを使ってPOSTを実行するコードを追加して、動作を確認する。非常に雑な感じではあるが、フォームの内容はStateで覚えておき、ボタンのクリックハンドラでAPIを呼び出すときに投げる。成功したらページリロード。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import React, { useState } from 'react' import styled from 'styled-components' import { Input, PrimaryButton, Textarea } from 'smarthr-ui' import Post, { PostProps } from '../../organisms/Post' import { createPost } from '../../../../api/posts' export interface PostsTemplateProps { posts: PostProps[] } const PostsTemplate: React.FC<PostsTemplateProps> = ({posts}) => { const [title, setTitle] = useState("") const [body, setBody] = useState("") const handleSubmit = () => { createPost(title, body) .then(() => {document.location.reload()}) } return( <Content> <List> <li> <p>タイトル</p> <Input type="text" onChange={(e) => setTitle(e.target.value)}/> </li> <li> <p>本文</p> <Textarea onChange={(e) => setBody(e.target.value)}/> </li> <li> <PrimaryButton onClick={handleSubmit}>submit</PrimaryButton> </li> </List> {posts.map(post => ( <Post {...post} /> ))} </Content> ) } const Content = styled.div` margin: 32px 10%; min-width: 800px; ` const List = styled.ul` padding: 0 24px; list-style: none; & > li:not(:first-child) { margin-top: 16px; } ` export default PostsTemplate |
[Add new post form for index · kinoppyd/nekonekokawaii@eca716b]
ここまでで、axiosを使ってRails側のAPIを呼び出して、Reactから新しいPOSTを作れるまでの一連の流れが完成した。
GoogleのOAuthでユーザー登録
ここはあまり真面目に読む必要はない。Google API ConsoleからOAuth2の認証情報を追加して、OmniAuthを使ってGoogleアカウントからEmailなどの情報の認可を受け、それをもとにユーザー登録などを行うだけだ。正直、ググったほうがここより詳しい解説が出てくると思う。
ここでは、単にエッセンシャルなユーザーログインだけを書いていく。
まずは、email, display_name, avator の3つのフィールだけを持ったUserモデルを作成する。
1 2 |
rails g model user email:string display_name:string avatar:string rails db:migrate |
[rails g model user email:string display_name:string avatar:string · kinoppyd/nekonekokawaii@8ba68dc]
Userモデルは、OAuthのコールバックのハッシュをもとにユーザーを作成するつもりで、次のようなユーザー作成ヘルパを書いておく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class User < ApplicationRecord class << self def find_or_create_from_auth_hash(auth_hash) find_or_create_by(auth_hash_to_entity(auth_hash)) end private def auth_hash_to_entity(auth_hash) { display_name: auth_hash["extra"]["id_info"]["name"], email: auth_hash["extra"]["id_info"]["email"], avatar: auth_hash["extra"]["id_info"]["picture"] } end end end |
[Create User from oauth hash · kinoppyd/nekonekokawaii@891b186]
次に、omniauthとomniauth-google-oauth2を導入する
1 |
bundle add omniauth omniauth-google-oauth2 |
[bundle add omniauth omniauth-google-oauth2 · kinoppyd/nekonekokawaii@22afc7c]
Omniauthの設定は、config/initializers/omniauth.rb 内に記述する。
1 2 3 4 5 6 7 8 |
Rails.application.config.middleware.use OmniAuth::Builder do provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], { scope: 'userinfo.email, userinfo.profile', prompt: 'select_account', image_aspect_ratio: 'square', image_size: 50 } end |
[Add omniauth initializer · kinoppyd/nekonekokawaii@70880d6]
そして、OAuthからのコールバックを受けてUserの作成を行う、セッションコントローラの作成と、ルーティングの追加を行う。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SessionsController < ApplicationController def create @user = User.find_or_create_from_auth_hash(auth_hash) session[:email] = @user.email redirect_to '/' end def destroy session[:email] = nil end protected def auth_hash request.env['omniauth.auth'] end end |
1 2 3 4 5 6 7 |
Rails.application.routes.draw do resources :posts # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html get '/auth/:provider/callback', to: 'sessions#create' delete '/session', to: 'sessions#destroy' end |
[Add sessions controller and routes · kinoppyd/nekonekokawaii@a1ef697]
最後に、React側にログインボタンを追加して、 /auth/google_oauth2/ にリンクを貼れば、ユーザーの作成とログインまでの流れが完成する。
簡単に言ったが、これはController側でユーザーのログインをチェックしたり、状態によってUIを出し分けたりする処理が入るので、そう簡単にはできないし分量が多い。下記のコミットを参照してほしい。
[User login interface · kinoppyd/nekonekokawaii@410e365]
何をやったかを簡単にまとめておくと、OAuthの認可情報を取れていればUserモデルをセッションという名前でReact側に渡して、それによって処理を切り分けるようにしている。Sessionがなければログインボタンを出し、あればユーザーアイコンとPostようのフォームを表示している。とにかくしちめんどうなことが書かれているので、コミットの方を参考にしてほしい。
S3に画像の投稿
ねこが可愛いことを伝えるために、画像も投稿したい。当然そう思うので画像も投稿できるようにするには、ActiveStorageを使う。
まず、S3のバケット作成とIAM設定を行う。これは手順が本質的じゃない話なので、いくつか参考になるブログを見てほしい。
[ActiveStorageでファイルの保存先にAWS S3を利用するための準備 – Qiita]
この準備ができたら、まずActiveStorageを使えるようにする。今回はあえてrails newするときに省いたので、次のコマンドで使えるようにしていく。
1 2 3 |
bundle exec rails active_storage:install bundle exec db:migrate bundle add aws-sdk-s3 |
[rails active_storage:install · kinoppyd/nekonekokawaii@46ae493]
[bundle add aws-sdk-s3 · kinoppyd/nekonekokawaii@9ac7424]
これにによって、ActiveStorageの使うDBのテーブルが作成され、S3と通信するようのGemも入る。
次に、config/storage.ymlを編集してS3を使うようにする。
1 2 3 4 5 6 |
amazon: service: S3 access_key_id: <%= ENV.fetch('AWS_ACCESS_KEY') %> secret_access_key: <%= ENV.fetch('AWS_SECRET_ACCESS_KEY') %> region: ap-northeast-1 bucket: nekonekokawaii |
面倒なので本番環境でも開発環境でもS3を触るようにする。本来ならばバケットを分けるべきだが、これはチュートリアルなので気にしない。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
diff --git a/config/environments/development.rb b/config/environments/development.rb index 5ac4cd0..7cd289c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -29,7 +29,7 @@ Rails.application.configure do end # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :amazon # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 9b7a9c3..94ef36e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -30,7 +30,7 @@ Rails.application.configure do # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :amazon # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # config.force_ssl = true |
さらに、PostモデルにもActiveStorageのBlobを扱えるようにリレーションを書いておく。
1 2 3 4 5 6 7 8 |
diff --git a/app/models/post.rb b/app/models/post.rb index b2a8b46..7140e3e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,2 +1,3 @@ class Post < ApplicationRecord + has_many_attached :pictures end |
[Add ActiveStorage configures and relations · kinoppyd/nekonekokawaii@8554224]
これで、PostにActiveStorageで保存した画像を紐付ける準備ができた。
最後に、Reactから画像を受け取りそれを保存するControllerと、更にReact側からどうやって画像を送るのかのコードを追加していく。ActiveStorageは、Railsのエコシステムとがっちり組み合わさって動くため、本来であればViewHelperを使ってファイルをアップロードする専用のフォームを作るのだが、フロントは全部Reactで書きたい。であれば、どのようにしてReactから送られてくるリクエストをもとに、ActiveStorageでBlobを作成すればよいのか? 最も手っ取り早い方法は、input type=”file” のフォームを用意し、Base64をJSONに入れて送り、それをController内でデコードしてStringIOに詰め直すことだ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index d8f802e..21f44c5 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -12,6 +12,13 @@ class PostsController < ApplicationController def create post = Post.create(create_params) + if params[:picture] + blob = ActiveStorage::Blob.create_after_upload!( + io: StringIO.new(decode(params[:picture][:data]) + "\n"), + filename: params[:picture][:name] + ) + post.pictures.attach(blob) + end post.save! render json: post end @@ -21,4 +28,8 @@ class PostsController < ApplicationController def create_params params.permit(:title, :body) end + + def decode(str) + Base64.decode64(str.split(',').last) + end end |
このように、リクエストパラメータにpictureが詰まっていれば、その中のdataというBase64エンコードされた文字列を、Base64.decode64でもとに戻してStringIOに詰める。それだけだ。フロントから送られてくるときは、Base64の文字列の先頭にファイル属性などの文字列が付いているので、カンマでスプリットしてデータ部分のみを取り出す(これは別にフロントでやっても良い処理だが、Rubyのほうが楽だった)。
フロント側は、このように変更を加えている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
diff --git a/client/src/components/templates/Posts/Index.tsx b/client/src/components/templates/Posts/Index.tsx index fff36aa..1054625 100644 --- a/client/src/components/templates/Posts/Index.tsx +++ b/client/src/components/templates/Posts/Index.tsx @@ -14,12 +14,27 @@ export interface PostsTemplateProps { const PostsTemplate: React.FC<PostsTemplateProps> = ({posts, session}) => { const [title, setTitle] = useState("") const [body, setBody] = useState("") + const [img, setImg] = useState({data: "", name: ""}) const handleSubmit = () => { - createPost(title, body) + createPost(title, body, img) .then(() => {document.location.reload()}) } + const handleImageSelect = (e: React.FormEvent) => { + const reader = new FileReader() + const files = (e.target as HTMLInputElement).files + if (files) { + reader.onload = () => { + setImg({ + data: reader.result as string, + name: files[0] ? files[0].name : "unknownfile" + }) + } + reader.readAsDataURL(files[0]) + } + } + return( <Content> { @@ -33,6 +48,9 @@ const PostsTemplate: React.FC<PostsTemplateProps> = ({posts, session}) => { <p>本文</p> <Textarea onChange={(e) => setBody(e.target.value)} /> </li> + <li> + <input type="file" accept="image/*;capture=camera" onChange={handleImageSelect} /> + </li> <li> <PrimaryButton onClick={handleSubmit}>submit</PrimaryButton> </li> |
FileReaderを使って、inputから渡されたファイルをBase64化した上でStateに保持し、リクエスト時にクライアントに渡している。クライアントはコードを次のように変えた。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
diff --git a/client/api/posts.ts b/client/api/posts.ts index 29f7c9b..bb9b41c 100644 --- a/client/api/posts.ts +++ b/client/api/posts.ts @@ -1,6 +1,6 @@ import { AxiosInstance } from 'axios' import client from './client' -export const createPost = (title: string, body: string) => { - return client.post('posts', {title, body}) +export const createPost = (title: string, body: string, picture?: {data: string, name: string}) => { + return client.post('posts', {title, body, picture}) } \ No newline at end of file |
また、Postモデルが画像を扱えるようになったことで、Rails側のViewではこうやって画像情報を渡している。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
diff --git a/app/views/posts/index.html.erb b/app/views/posts/index.html.erb index 95406d7..53a2326 100644 --- a/app/views/posts/index.html.erb +++ b/app/views/posts/index.html.erb @@ -1 +1,7 @@ -<%= react_component("templates/Posts/Index", { posts: @posts, session: @current_user }) %> +<%= react_component( + "templates/Posts/Index", + { + posts: @posts.map { |post| post.pictures.attached? ? post.attributes.merge({picture: url_for(post.pictures.first)}) : post }, + session: @current_user + }) +%> |
React側では、受け取ったURLを表示する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
diff --git a/client/src/components/organisms/Post.tsx b/client/src/components/organisms/Post.tsx index c7aa768..ade21b0 100644 --- a/client/src/components/organisms/Post.tsx +++ b/client/src/components/organisms/Post.tsx @@ -7,13 +7,16 @@ import styled from "styled-components"; export interface PostProps { title: string body: string + picture?: string + } -const Post: React.FC<PostProps> = ({title, body}) => { +const Post: React.FC<PostProps> = ({title, body, picture}) => { return( <Base> <Title type='blockTitle' tag='h1'>{title}</Title> <div>{body}</div> + { picture ? <div><img src={picture} /></div> : null } </Base> ) } |
全体的にやや雑な実装だが、これはプロトタイプなので気にしない。本気になったときにブラッシュアップしてほしい。
ここまでの流れで、Reactから渡した画像はRails経由でS3にアップロードされ、それを参照することも可能になった。
[Implements API and Front · kinoppyd/nekonekokawaii@2ae89d0]
Herokuにデプロイ
ここまでくれば、あとはアプリケーションをデプロイするだけだ。といっても、Herokuを使えば何も問題なくすべてが終わる。ちょっともう時間がないので、こればっかりは自分で調べてほしい。GCPのキーとAWSのキーを設定するのを忘れないように。
完成したアプリ
https://nekonekokawaii.herokuapp.com/posts
おわりに
React+Railsのアプリケーションを0から作って形にする一連の流れを解説した。なるべく、いくらでも拡張が効くようにしっかりとしたベースを作るつもりでコードを書けたと思う。これさえわかれば、あとはいつも仕事でやっているように、自分の好きなようにアプリを拡張できるようになると思う。
これでようやく、RailsとReactは書けるけど自分では何も生み出せないという疑念から自分を開放できた気がする。気持ちが凄く楽になった。