ActionCable前后端分离实现

作者 陈越 日期 2020-07-06
ActionCable前后端分离实现

1 简介

Action Cable 将 WebSocket 与 Rails 应用的其余部分无缝集成。有了 Action Cable,我们就可以用 Ruby 语言,以 Rails 风格实现实时功能,并且保持高性能和可扩展性。Action Cable 为此提供了全栈支持,包括客户端 JavaScript 框架和服务器端 Ruby 框架。同时,我们也能够通过 Action Cable 访问使用 Active Record 或其他 ORM 编写的所有模型。

2 Pub/Sub 是什么

Pub/Sub,也就是发布/订阅,是指在消息队列中,信息发送者(发布者)把数据发送给某一类接收者(订阅者),而不必单独指定接收者。Action Cable 通过发布/订阅的方式在服务器和多个客户端之间通信。

3 发布与订阅的实现

3.1 连接

连接是客户端-服务器通信的基础。每当服务器接受一个 WebSocket,就会实例化一个连接对象。所有频道订阅(channel subscription)都是在继承连接对象的基础上创建的。连接本身并不处理身份验证和授权之外的任何应用逻辑。WebSocket 连接的客户端被称为连接用户(connection consumer)。每当用户新打开一个浏览器标签、窗口或设备,对应地都会新建一个用户-连接对(consumer-connection pair)。

==连接是 ApplicationCable::Connection 类的实例。对连接的授权就是在这个类中完成的,对于能够识别的用户,才会继续建立连接。==

示例(当前门店连接的身份信息识别):

# app/channel/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_hotel

def connect
# 设置当前门店的授权信息,必须做这一步才能使用cable
self.current_hotel = find_verified_hotel
# 设置打印日志时,打印出当前的门店名称,方便日志查找
logger.add_tags 'ActionCable', current_chain.chain_name
end

protected

def find_verified_hotel
hotel = Hotel.find_by(id: request.params[:hotel_id])
hotel || reject_unauthorized_connection
end
end
end

全栈可以通过cookie方法,前后端分离可以使用request.params方法,所以此次连接的可以使用[EXAMPLEHOST]/cable?hotel_id=1这样的格式。

3.2 频道(channel)

和常规 MVC 中的控制器类似,频道用于封装逻辑工作单元。默认情况下,Rails 会把 ApplicationCable::Channel 类作为频道的父类,用于封装频道之间共享的逻辑。

3.2.1 父频道设置

# app/channels/application_cable/channel.rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

==以后的新频道都是继承于这个类,可以在这个类里定义通用方法。==

3.2.2 订单频道设置

使用下列命令新建一个channel:(OrderChannel)

rails g channel order

该频道使用了根据对象来订阅频道的方法,因此,该频道建立在已经存在hotels表的前提,在rails console可以使用下列命令检查。

# rails console
Hotel.first

这里涉及到两个方法(stream_from,stream_for)见下面代码。

class OrderChannel < ApplicationCable::Channel
def subscribed
hotel_id = JSON.parse(params[:room])['hotel_id']
hotel = Hotel.find_by(id: hotel_id)
stream_for hotel
# stream_from "order_channel"
end

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

可以看出stream_from的参数是一个字符串,可以用于一个固定的频道的订阅,stream_for的参数可以是一个具体的关联对象。我们主要是使用这个stream_for方法去做与门店的关联。这样可以订阅 Z2lkOi8vVGVzdEFwcC9Qb3N0LzE 这样的广播。==所以这里推荐用stream_for。==

3.2.3 频道的订阅

这里以react为例子讲解订阅的过程

step1

在react项目中引入actioncable,actioncable-client-react两个npm包。

npm方式:

npm i --save actioncable actioncable-client-react

yarn方式:

yarn add actioncable actioncable-client-react

step2

在代码中引入ActionCable组件

...

import { ActionCableProvider, ActionCable } from 'actioncable-client-react'
import actioncable from 'actioncable'

...

step3

使用ActionCable组件实现消息订阅(actioncable.createConsumer)及消息接收方法(onReceived)

const cable = actioncable.createConsumer(`ws://192.168.1.43:3000/cable?hotel_id=${this.props.hotelInfo.sourceId}`)
//创建了一个长连接对象cable,使用query的方式,
//传递到后端的connection类里,可以使用request.params[:hotel_id]去获取。

<ActionCableProvider cable={cable}>
<ActionCable channel={'OrderChannel'} room={JSON.stringify({'hotel_id': 1})} onReceived={this.handleReceived} />
</ActionCableProvider>
// 这里的channel和room是这个npm组件中的prop,
// channel指向的是要订阅的channel(这里指的是OrderChannel)
// room是一个固定的参数,因为这个npm包的组件
// 是一个聊天室的示例,故使用room,不做修改,
// room的参数可以用json形式,后端的具体channel
// 可以用params[:room]去取出来

这样就完成了前端的订阅了

3.2.4 信息的发布

假设想给hotel_id为1的门店推送新订单,因为这个项目中使用了stream_for一个对象的方式,因此可以使用下列的方法去推送信息:

new_order = Order.last
hotel = Hotel.find_by(id: 1)
OrderChannel.broadcast_to(hotel, 'order': new_order)

这样的话,前面订阅了hotel的门店就可以收到我们发出的信息了,在react项目中的
ActionCableProvider组件中的onReceived方法中就可以得到后端发布的消息了。
==tips:这里最好使用Job去调用,不然在消息过于密集的时候会出现接收不到推送的问题。==

4 本地调试(开发环境)

step1

安装nginx

step2

在rails项目的 config/environments/development.rb

# 允许任何源访问
config.action_controller.allow_forgery_protection = false

config/application.rb

# 访问/cable的http请求指向action_cable
config.action_cable.mount_path = "/cable"
# 日志打印美化
config.action_cable.log_tags = [
:action_cable, ->(request) { request.uuid }
]

step3

这步是为了在局域网中调试用的,若是没有这种要求的话可以不改,本地的前端项目只需要订阅0.0.0.0加上启动项目的端口就行了。

nginx.conf

...

server {
listen 3000;
server_name YOUR_LOCAL_IP;

location /cable {
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering on;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Forwarded-Proto https;
proxy_pass http://127.0.0.1:3000;
gzip off;
}
}

...

这样所有通过ip访问你本地3000端口的请求都能转发到你的localhost:3000了,reload一遍nginx,就可以在同一个局域网内访问你本地的服务了。