Rials5带来了很多新的功能,能让我们更加容易地书写web app, json API 的接口,但最激动人心的应该是 actioncable,长链接 websocket 变得如此简单,来我们用30分钟来写一个简单的即时通讯的 web app,前端用 JS,服务端用 Ruby。actioncable 的外链
应用的功能是这样的,打开网页,注册,或者已注册用户登录,新建聊天室,或进入已有的聊天室,同一个聊天室容纳多个人,一个聊天室内所有人会收到新的聊天信息的推送。
建立应用,User建模,数据迁移
环境:Ruby Version: 2.3.3,Rails Version: 5.0.2
打开ternimal, cd到相关目录下,建立工程
rails new im_demo -T
先不写测试了,所以没有加入自带的测试框架
打开Gemfile
...
gem 'devise'
gem 'redis' , '~> 3.2'
gem 'bootstrap' , '4.0.0.alpha6'
...
group :development , :test do
gem 'sqlite3'
end
...
group :production do
gem 'pg'
gem 'rails_12factor'
end
这个Device是用户认证用的,简化注册登录流程,外链
bootstrap 主要是样式,scss 是面向对象的 css,可以嵌套使用,而且默认提供了一部分组件样式。redis 是内存数据库,因为 instance message 需要及时响应,mac 上 brew install redis,完成以后记得bundle install
增加scss样式表,同时删除原来的css
remove app/assets/stylesheets/application.css
touch app/assets/stylesheets/application.scss
写入@import “bootstrap”;
User建模
rails generate devise:install
rails generate devise User
rails generate devise:views
rails db:migrate
增加约束,进入网站其他页面,必须是认证用户
subl app/controllers/application_controller.rb
写入
before_action :authenticate_user!
聊天室
rails g model ChatRoom title:string user:references
rails db:migrate
检查 chat_room.rb,确认看到 belongs_to :user
检查 users.rb,确认看到 has_many :chat_rooms, dependent: :destroy
聊天室需要一个标题,在用户登录以后,选择聊天室时可见,而且聊天室是由用户创建的,所以和创建他的用户绑定,如果创建他的用户被删除了,那么聊天室的内容也会被删除,类似于SQL中的 DELETE CASCADE 效果,完了记得映射 model 到数据库 migrate 操作
为chatroom创建controller
touch app/controllers/chat_rooms_controller.rb
写入
class ChatRoomsController < ApplicationController
def index
@chat_rooms = ChatRoom . all
end
def new
@chat_room = ChatRoom . new
end
def show
@chat_room = ChatRoom . includes ( :messages ). find_by ( id: params [ :id ])
@message = Message . new
end
def create
@chat_room = current_user . chat_rooms . build ( chat_room_params )
if @chat_room . save
flash [ :success ] = 'Chat room added!'
redirect_to chat_rooms_path
else
render 'new'
end
end
private
def chat_room_params
params . require ( :chat_room ). permit ( :title )
end
end
注意 Message 这个类现在还没有创建。index,展现所有 chatrooms,这回事一个 list,在首页,用户登录以后会看到。new 是 chatroom 的新建操作。create 是登录用户可以新建一个聊天室,聊天室创建以后会和当前用户关联。chat_room_params 中的 permit是参数限制,防止 sql 注入攻击
建立简单的 view,显示所有聊天室的 view,views/chat_rooms/index.html.erb
< h1 > Chat rooms < /h1>
<p class="lead"><%= link_to 'New chat room', new_chat_room_path, class: 'btn btn-primary' %></ p >
< ul >
< %= render @chat_rooms %>
</ul>
views/chat_rooms/_chat_room.html.erb,进入聊天室的,点击按钮,以及事件
< li >< %= link_to "Enter #{chat_room.title}", chat_room_path(chat_room) %></li>
views/chat_rooms/new.html.erb,用户新建聊天室,看到那些参数,就是 controller 中会用到的
< %= form_for @chat_room do |f| %>
<div class= "form-group" >
< %= f.label :title %>
<%= f . text_field :title , autofocus: true , class: 'form-control' %>
< /div>
<%= f.submit "Add!", class: 'btn btn-primary' %>
<% end %>
接下去建立 message 模块
rails g model Message body:text user:references chat_room:references
rails db:migrate
检查 models/chat_room.rb,看到 has_many :messages, dependent: :destroy
检查 models/users.rb,看到 has_many :messages, dependent: :destroy
检查 models/message.rb,看到 belongs_to :user,belongs_to :chat_room
1:n 的关系,在 Rails 中是1的 model 对应 has_many,n的 model 对应 belongs_to,一个聊天室对应多个消息,没毛病。现在 chat_rooms_controller.rb 中的 show 操作,变得有意义了,因为添加了 message 模块。说人话,进入对应的聊天室,展现所有的消息,不管是谁发的,对吧
views/chat_rooms/show.html.erb,聊天室详细页面,展现该聊天室的所有消息
< h1 >< %= @chat_room.title %></h1>
<div id= "messages" >
< %= render @chat_room.messages %>
</div>
views/messages/_message.html.erb,每一条消息的展现,默认组件 card
< div class = "card" >
< div class = "card-block" >
< div class = "row" >
< div class = "col-md-1" >
< %= gravatar_for message.user %>
</div>
<div class= "col-md-11" >
< p class = "card-text" >
< span class = "text-muted" >< %= message.user.name %> at <%= message . timestamp %> says</span> < br >
< %= message.body %>
</p>
</div>
</div>
</div>
</div>
gravatar_for 头像,message.user.name 用户名字,message.timestamp 消息时间戳,message.body 消息内容
用户注册的邮件不能直接显示在列表中,属于个人隐私,所以我们只截取@之前的那一部分作为用户的名字,models/user.rb
def name
email . split ( '@' )[ 0 ]
end
数据库中的时间,对用户不是很友好,我们改下显示格式,models/message.rb
def timestamp
created_at . strftime ( '%H:%M:%S %d %B %Y' )
end
记得写在 gravatar_for 方法,在 application_helper.rb 中
module ApplicationHelper
def gravatar_for ( user , opts = {})
opts [ :alt ] = user . name
image_tag "https://www.gravatar.com/avatar/ #{ Digest :: MD5 . hexdigest ( user . email ) } ?s= #{ opts . delete ( :size ) { 40 } }" ,
opts
end
end
就是 www.gravatar.com 这个网站的服务,你的邮箱和你的头像绑定的第三方服务
默认的样式不好看,但是在 application.scss 中可以添加样式
最后加上跳转的路由,config/routes.rb
resources :chat_rooms , only: [ :new , :create , :show , :index ]
root 'chat_rooms#index'
根目录就是聊天室的列表页面
好的,模型,界面基本完成,我们要增加核心功能
ActionCable封装好的websocket服务,发消息,全局广播,即刻响应
看到config/cable.yml
development:
adapter: async
test:
adapter: async
production:
adapter: redis
url: < %= ENV["REDISCLOUD_URL"] %>
REDISCLOUD_URL 是线上环境变量
看到config/routes.rb,增加
mount ActionCable . server => '/cable'
检查javascripts/cable.js,看到
// = require action_cable
// = require_self
// = require_tree . / channels
( function () {
this . App || ( this . App = {});
App . cable = ActionCable . createConsumer ();
}). call ( this );
看到javascripts/application.js,增加
// = require cable
让cable.js的代码生效
什么是Consumer,给出外链
主要是解决,一个用户在多个频道,用户又有多个终端,比如不同的浏览器中同步消息
consumer 可以订阅(subscribe)多个 cable channels, 每个 channel 封装了逻辑单元,consumer 创建以后至少订阅一个 channel。consumer 订阅 channel 以后就成为了订阅者,一个 consumer 可以多次成为同一个 channel 的订阅者,订阅以后消息的发送和接收是双向的。你会看到其他用户发的消息,是 server 传输给你的,你发的消息先传给 server,然后 server广播给其他用户,在同一个 channel上,对吧。对于 websocket 的链接来说,consumer 是 client side, channel 是 server side,channel 类似于 controller,但是他处理的是 streaming 数据流,不是 http 的 request,因为一点连接建立以后,传递的是数据流,而用 http 的长链接做轮训,本质上还是会 close 一个 session 的,效率很低
我们来新建一个channel吧,javascripts/channels/rooms.coffee
jQuery ( document ). on 'turbolinks:load' , ->
messages = $ ( '#messages' )
if $ ( '#messages' ). length > 0
App . global_chat = App . cable . subscriptions . create {
channel: "ChatRoomsChannel"
chat_room_id: messages . data ( 'chat-room-id' )
},
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: ( data ) ->
messages . append data [ 'message' ]
send_message: ( message , chat_room_id ) ->
@perform 'send_message' , message: message , chat_room_id: chat_room_id
$ ( '#new_message' ). submit ( e ) ->
$this = $ ( this )
textarea = $this . find ( '#message_body' )
if $. trim ( textarea . val ()). length > 1
App . global_chat . send_message textarea . val (), messages . data ( 'chat-room-id' )
textarea . val ( '' )
e . preventDefault ()
return false
CoffeeScript 是一门编译到 JavaScript 的小巧语言,外链
逻辑上,如果有在页面上有 messages block,那么 client subscribe to the channel,订阅这个频道,订阅的动作依赖于room’s id,submit就是前端提交message,app会在channel内广播消息,同时输入框清空
views/chat_rooms/show.html.erb,输入消息的表单
< %= form_for @message, url: '#' do |f| %>
<div class= "form-group" >
< %= f.label :body %>
<%= f . text_area :body , class: 'form-control' %>
< small class = "text-muted" > From 2 to 1000 characters < /small>
</ div >
< %= f.submit "Post", class: 'btn btn-primary btn-lg' %>
<% end %>
@message 应该在 controller 中初始化,看到 chat_rooms_controller.rb 中 show 方法中是不是创建了一个 @message 对象
增加message的约束,models/message.rb
validates :body , presence: true , length: { minimum: 2 , maximum: 1000 }
message的body属性,必须存在,2-1000个字符之间
看到 views/chat_rooms/show.html.erb,@chat_room.id 这个参数是 subscriptions 中需要的
好了,下面看下 Server 的代码。channels/chat_rooms_channel.rb
class ChatRoomsChannel < ApplicationCable :: Channel
def subscribed
stream_from "chat_rooms_ #{ params [ 'chat_room_id' ] } _channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def send_message ( data )
# process data sent from the page
current_user . messages . create! ( body: data [ 'message' ], chat_room_id: data [ 'chat_room_id' ])
end
end
感受下,是不是对应了js代码,订阅,取消订阅,发送消息,之前 set 的参数,在这里 get。好了么?还没有,Devise 的 current_user,我们还没定义,channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable :: Connection :: Base
identified_by :current_user
def connect
self . current_user = find_verified_user
logger . add_tags 'ActionCable' , current_user . email
end
protected
def find_verified_user # this checks whether a user is authenticated with devise
if verified_user = env [ 'warden' ]. user
verified_user
else
reject_unauthorized_connection
end
end
end
end
完了以后,current_user在channel 开始生效,没有认证的用户无法广播他们的消息。logger.add_tags 是在 console 中查看 debug 信息。Devise 的认证是建立在Warden的基础上的,外链 ,env[‘warden’].user 是取得当前的 login 用户
前端数据到server内存,对了还有数据库,我得把消息存起来,models/message.rb,增加
after_create_commit { MessageBroadcastJob . perform_later ( self ) }
看字面意思,广播消息以后,存到数据库
打开,jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
queue_as :default
def perform ( message )
ActionCable . server . broadcast "chat_rooms_ #{ message . chat_room . id } _channel" ,
message: render_message ( message )
end
private
def render_message ( message )
MessagesController . render partial: 'messages/message' , locals: { message: message }
end
end
perform 方法做了广播的事情,得到的消息需要 controller 来处理,新建 MessagesController,messages_controller.rb
class MessagesController < ApplicationController
end
重新看到客户端 rooms.coffee,貌似已经 ready 了。但是你会发现,新的消息不是在最后,但我要的效果是slack那种,新的消息在底部显示,但是聊天窗口会滚动,新增
...
if $ ( '#messages' ). length > 0
messages_to_bottom = -> messages . scrollTop ( messages . prop ( "scrollHeight" ))
messages_to_bottom ()
...
received: ( data ) ->
messages . append data [ 'message' ]
messages_to_bottom ()
...
最后,稍微美化下导航栏。layouts/application.html.erb
<! DOCTYPE html >
< html >
< head >
< title > IMDemo < /title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</ head >
< body >
< nav class = "navbar navbar-toggleable-md navbar-light bg-faded" >
< %= link_to 'IMDemo', root_path, class: 'navbar-brand' %>
<% if user_signed_in? %>
<ul class= "navbar-nav mr-auto" >
< li class = "nav-item active" >
< %= link_to 'Log out', destroy_user_session_path, method: :delete, class: 'nav-link' %>
</li>
</ul>
<span class= "navbar-text" > Logged in as < strong >< %= current_user.name %></strong></span>
<% end %>
</nav>
<div class= "container" >
< %= yield %>
</div>
<footer>
<div class= "container" >
< p class = "text-muted" > Created by < %= link_to 'Hongli', 'http://hlyu.cn' %></p>
</div>
</footer>
</body>
</html>
现在完成了,打开 rails s,打开浏览器: (http://localhost:3000),注册,以后直接就登录了,在不同浏览器,打开同一个聊天室,不同的账号登陆,试一试。
最后推送到heroku线上环境,首先你要redis服务,免费的插件(https://elements.heroku.com/addons), 就选Rediscloud吧,30m免费
看到config/environments/production.rb
config . action_cable . allowed_request_origins = [ 'https://radiant-fjord-14017.herokuapp.com' ,
'https://radiant-fjord-14017.herokuapp.com' ]
config . action_cable . url = 'wss://radiant-fjord-14017.herokuapp.com/cable'
mark (https://radiant-fjord-14017.herokuapp.com) 这是我的线上地址,需要替换成你自己的地址。
这是线上的demo地址: (https://radiant-fjord-14017.herokuapp.com/)
源代码的地址:(https://github.com/HongliYu/im_demo)