Edited at

railsとvueでリアルタイムな注文システムを作ってみた

More than 1 year has passed since last update.

こんにちは

はじめまして。ぐりと申します。

広島県は呉市の呉高専でrubyistのはしくれを名乗っております。以後お見知りおきを。

さて、相変わらず何事もなく過ごしていたわけですが、もう12月なわけですよ。

お坊さんが走り回る12月。

で、12月といえば?? そう、アドベントカレンダーですね。

ということで今年は我らが呉高専の有志学生による非公式アドベントカレンダー

呉高専エンジニア勉強会 Advent Calendar 2017

に参加します!!

非公式ですよ。いいですね??

この記事は2日目の記事です。1日目の記事はおにぎりくんbotの誕生、繁栄、そして死です。物騒ですね。

ではでは本題へ


目次


  • 概要

  • 使用した技術

  • 作り方(サーバー)

  • 作り方(クライアント)

  • 解説

  • まとめ


概要

僕たちは学園祭で模擬店を出すんですが、その管理の方法が紙とペンなんですよ。

これはいけてないなということで RailsVue.js で会計システムを作りました。

ちなみに売るものはフランクフルトです。🐖

また、前々から使ってみたいと思っていたWebSocketを使ってみました。

そのおかげで受け付けた注文をリアルタイムに反映できます。

(doneの同期は許してください…)

コードはここにあります。


使用した技術


サーバー

サーバーにはみんな大好きrailsを使いました。

そして今回の目玉であるwebsocketはrails5から実装されたactioncableを使っています。

actioncableの使い方についてはこの記事を参考にしました。


クライアント

フロントはvue.jsと、そのコンポーネントライブラリであるElementを使いました。

また、websocketはactioncableがnpmのパッケージとして公開されていたのでそれを使っています。

railsとvueの環境構築についてはこの記事を参考にしました。


作り方(サーバー)


gemについて

本来この規模だと必要ないのですが、色々使ってみたかったので以下のgemを導入しています。



  • ridgepole

    いちいちマイグレーションファイルを生成しなくて良くなるgem

    各テーブルをschemaファイルで定義する


  • active_model_serializer

    jbuilderよりも簡単にjsonを作れるgem


下準備

先ほどの環境構築の記事にそってこの項まですすめます。


モデルの作成

注文を管理するCustomerモデルとそれに紐づくFrankfurtモデルを作ります。


テーブル構造は以下のとおりです。


customers

Customer

done
boolean
null: false


frankfurts

Frankfurt

customer_id
integer
null: false, index: true

ketchup
boolean
null: false

mustard
boolean
null: false


  • ridgepoleを導入


Gemfile

gem 'ridgepole'



  • db/Schemafileを作る


db/Schemafile

create_table :customers do |t|

t.boolean :done, null: false

t.timestamps
end

create_table :frankfurts do |t|
t.integer :customer_id, null: false
t.boolean :ketchup, null: false
t.boolean :mustard, null: false

t.timestamps
end

add_foreign_key :frankfurts, :customers

add_index :frankfurts, :customer_id



  • aliasを書く


env.sh

alias rid="./bin/bundle exec ridgepole -c config/database.yml\

-E development --apply --dry-run -f db/Schemafile"

alias rida="./bin/bundle exec ridgepole -c config/database.yml\
-E development --apply -f db/Schemafile"


書かなくても大丈夫ですが、便利になるので書いておきます。

rakeタスクにする方法もあるようです。(RakeでRidgepoleコマンドを実行する)


  • DBに適用

$ rida

ちなみに

$ rid

で適用後どうなるかの確認ができます。(適用はされない)


  • バリデーションとリレーションの記述


app/models/customer.rb

class Customer < ApplicationRecord

has_many :frankfurts, dependent: :destroy
accepts_nested_attributes_for :frankfurts

validates :done, presence: true
end



app/models/frankfurt.rb

class Frankfurt < ApplicationRecord

belongs_to :customer

validates :ketchup,
:mustard,
presence: true
end



APIの作成


  • ルーティングの設定


config/routes.rb

Rails.application.routes.draw do

root to: 'home#index'
resources :customers, only: %i[index create] # ここを追記
post '/customers/:id/done', to: 'customers#done' # ここを追記
end


  • メソッドの作成


app/controllers/customers_controller.rb

class CustomersController < ApplicationController

before_action :set_customer, only: %i[done]

def index
@customers = Customer.where(done: false)

render json: @customers
end

def create
@customer = Customer.new(customer_params)

if @customer.save
render json: @customer, status: :created
else
render @customer.errors
end
end

def done
if @customer.done
handle_400 error_details: ['すでに完了済みです']
else
@customer.done = true
if @customer.save
render json: @customer, status: :ok
end
end
end

private

def set_customer
@customer = Customer.find(params[:id])
end

def customer_params
params.require(:customer).permit(frankfurts_attributes: [:ketchup, :mustard])
end
end



  • active_model_serializerの導入


Gemfile

gem 'active_model_serializers', '~> 0.10.0'



app/serializer/customer_serializer.rb

class CustomerSerializer < ActiveModel::Serializer

attributes :id, :done, :created_at

has_many :frankfurts
end



app/serializer/order_serializer.rb

class FrankfurtSerializer < ActiveModel::Serializer

attributes :id, :ketchup, :mustard

belongs_to :customer
end


ここまででhttp://localhost:3000/customersにアクセスして空の配列が表示されたらOKです。


actioncableの導入(rails)


  • actioncableのインストール


Gemfile

gem 'actioncable'



  • ルーティングの設定


config/routes.rb

Rails.application.routes.draw do

root to: 'home#index'
resources :customers, only: %i[index create]
post '/customers/:id/done', to: 'customers#done'

mount ActionCable.server => '/cable' # ここを追記
end



  • channnelの作成


$ rails g channel order


app/channels/order_channel.rb

class OrderChannel < ApplicationCable::Channel

def subscribed
# stream_from 'order_channel'
end

def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end



  • データの送信


app/controllers/customers_controller.rb

def create

@customer = Customer.new(customer_params)

if @customer.save
# ----- 追記ここから -----
params = {
id: @customer.id,
done: @customer.done,
created_at: @customer.created_at,
frankfurts: @customer.frankfurts
}
ActionCable.server.broadcast 'order_channel', customer: params
# ----- ここまで -----
render json: @customer, status: :created
else
render @customer.errors
end
end



作り方(クライアント)


ページの作成


  • プラグインのインストール

$ npm install axios

$ npm install element-ui


  • 設定


app/javascript/packs/hello_vue.js

import Vue from 'vue'

import ElementUI from 'element-ui'
import locale from 'element-ui/lib/locale/lang/ja'
import 'element-ui/lib/theme-default/index.css'
import axios from 'axios'
import VueAxios from 'vue-axios'
import App from '../app.vue'
import Que from '../components/que.vue'

Vue.use(ElementUI, {locale})
Vue.use(VueAxios, axios)
Vue.component('que', Que)

document.addEventListener('DOMContentLoaded', () => {
document.body.appendChild(document.createElement('app'))
const app = new Vue(App).$mount('app')
})



  • 注文を表示するコンポーネントを作る


app/javascript/components/que.vue

<template>

<el-table
:data="tableData"
border
:show-header="false"
style="width: 100%">
<el-table-column>
<template slot-scope="scope">
<el-table
:data="scope.row.frankfurts"
:show-header=false
style="width: 100%">
<el-table-column align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.ketchup" type="danger">ketchup</el-tag>
<el-tag v-if="scope.row.mustard" type="warning">mustard</el-tag>
<el-tag v-if="!(scope.row.ketchup || scope.row.mustard)" type="info">none</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column
label="Time"
width="180">
<template slot-scope="scope">
<el-icon name="time"></el-icon>
<span style="margin-left: 10px"> {{ scope.row.created_at }}
</span>
</template>
</el-table-column>
<el-table-column width="100">
<template slot-scope="scope">
<el-button
size="small"
type="success"
@click="done(scope.$index, scope.row)">Done</el-button>
</template>
</el-table-column>
</el-table>
</template>

<script>
export default {
data() {
return {
tableData: []
}
},
methods: {
done(index, row) {
this.tableData.splice(index, 1)
this.axios.post('http://localhost:5000/customers/' + row.id + '/done', {
}).then((response) => {
console.log(response.data)
})
},
},
mounted: function() {
this.axios.get('http://localhost:5000/customers').then((response) => {
this.tableData = response.data
})
},
}
</script>

<style scoped>
p {
font-size: 2em;
text-align: center;
}
</style>



  • トップページを作る


app/javascript/app.vue

<template>

<div id="app">
<el-row>
<el-col :span="2"><div>none</div></el-col>
<el-col :span="4"><div><el-input-number v-model="none" :min="0"></el-input-number></div></el-col>
</el-row>
<el-row>
<el-col :span="2"><div>ketchup</div></el-col>
<el-col :span="4"><div><el-input-number v-model="ketchup" :min="0"></el-input-number></div></el-col>
</el-row>
<el-row>
<el-col :span="2"><div>mustard</div></el-col>
<el-col :span="4"><div><el-input-number v-model="mustard" :min="0"></el-input-number></div></el-col>
</el-row>
<el-row>
<el-col :span="2"><div>both</div></el-col>
<el-col :span="4"><div><el-input-number v-model="both" :min="0"></el-input-number></div></el-col>
</el-row>
<!-- <bill :total="total"></bill> -->
<div>
Total: {{ total }}
</div>
<el-button @click="submit" :disabled="submitDisabled">submit</el-button>
<p>
<que></que>
</p>
</div>
</template>

<script>
export default {
data() {
return {
none: 0,
ketchup: 0,
mustard: 0,
both: 0,
price: {
none: 100,
ketchup: 100,
mustard: 100,
both: 100
}
}
},
computed: {
submitDisabled: function() {
return !(this.none || this.ketchup || this.mustard || this.both)
},
total: function() {
return this.none * this.price.none + this.ketchup * this.price.ketchup + this.mustard * this.price.mustard + this.both * this.price.both
},
},
methods: {
submit: function() {
var frankfurts = []
var step = 0
for (step = 0; step < this.none; step++) {
frankfurts.push({ketchup: false, mustard: false})
}
for (step = 0; step < this.ketchup; step++) {
frankfurts.push({ketchup: true, mustard: false})
}
for (step = 0; step < this.mustard; step++) {
frankfurts.push({ketchup: false, mustard: true})
}
for (step = 0; step < this.both; step++) {
frankfurts.push({ketchup: true, mustard: true})
}

this.axios.post('http://localhost:5000/customers', {
customer:
{frankfurts_attributes: frankfurts}
}).then((response) => {
console.log(response.data)
})

this.none = 0
this.ketchup = 0
this.mustard = 0
this.both = 0
},
},
}

</script>

<style scoped>
p {
font-size: 2em;
text-align: center;
}
</style>



actioncableの導入(vue)


  • actioncableをインストール

$ npm install actioncable


  • 設定


app/javascript/packs/hello_vue.js

...

import ActionCable from 'actioncable'
const cable = ActionCable.createConsumer('ws:localhost:5000/cable')
...
Vue.prototype.$cable = cable
...


app/javascript/components/que.vue

<template>

...
</template>

<script>
export default {
...
created: function() {
var that = this
this.orderChannel = this.$cable.subscriptions.create(
{ channel: 'OrderChannel' },
{
received (data) {
that.addData(data)
}
}
)
}
}
</script>

<style>
...
</style>


コードは以上です。


解説


app/javascript/packs/hello_vue.js

const cable = ActionCable.createConsumer('ws:localhost:5000/cable')


の行でActionCableサーバーのURLを指定し、app/javascript/components/que.vueのコードで購読を開始しています。


app/javascript/components/que.vue

created: function() {

var that = this
this.orderChannel = this.$cable.subscriptions.create(
{ channel: 'OrderChannel' },
{
received (data) {
that.addData(data)
}
}
)
}

何かデータを受信したときの処理をreceived(data){}の中に書いていきます。

今回は単純に受け取ったデータを配列に追加していくだけです。

サーバーからデータを送信しているのはCustomers#createの以下の部分です。

Customerモデルに新しいレコードが追加されるのをトリガーにしています。


app/controllers/customers_controller.rb

ActionCable.server.broadcast 'order_channel', customer: params


broadcastなので、OrderChannelを購読しているすべてのクライアントに一斉にデータを送信します。


まとめ

que.vueがコンポーネントなのにどこに繋ぐべきか知っちゃってますし、そもそも注文を表示させるだけだったはずが通信までやっちゃってるのでコンポーネントとしては破綻してるような気がします。

vueで書いた意味が全然ないので今後はコンポーネントの責務について考えながらやっていきたいと思います。(小並感)