Sử dụng Rails 5 ActionCable và RethinkDB để xây dựng một ứng dụng Reactive Websocket

Tài liệu: Using Rails 5 ActionCable and RethinkDB to build a Reactive WebSocket App

Trong bài viết này chúng tôi sẽ trình bày cách xây dựng một ứng dụng WebSockets sử dụng ActionCable của Rails 5 và với chức năng changefeeds của RethinkDB chúng ta có thể lờ đi việc dùng event broadcasting service giống như Redis, và code sẽ ngắn gọn và rõ ràng hơn.

Ứng dụng mà chúng tôi xây dựng là ứng dụng collaborative spreadsheet giống như Google Sheets. Nó sẽ báo đến mỗi người dùng khi họ đã lựa chọn các ô bằng những màu khác nhau, dữ liệu trong mỗi ô và bảo vệ phản ứng lại khi có nhiều người dùng cùng chỉnh sửa vào một ô giống nhau.

Đầu tiên chúng tôi sẽ giới thiệu ngắn gọn cách hoạt động của ActionCable và RethinkDB. Tiếp đến chúng tôi sẽ cài đặt một ứng dụng nhỏ nhưng rất mạnh. Bạn có thể lấy code sẵn có trực tiếp trước ở đây. spreadsheet-full.gif

Tạo Một ActionCable Channel Cơ Bản ActionCable: là một tính năng mới trong Rails 5 cho phép các developers sử dụng WebSockets trong ứng dụng của họ một cách đơn giản nhất. GoRails có một hướng dẫn rất đầy đủ về ActionCable ở đây. Trong bài viết này chỉ là những gì cần thiết nhất (đủ để dùng actioncable).

đầu tiên tạo một rails project: rails new action-cable-demo

trong file config/routes.rb thêm route tới ActionCable Server như sau:

Rails.application.routes.draw do
   mount ActionCable.server => "/cable"
end

tạo ra một ActionCable Channel: rails g channel active_users

sau đó hiển thị một trang mà chúng ta có thể làm với kết nối WebSockets. chúng ta tạo ra một controller: rails g controller spreadsheet index

cuối cùng add route tới spreadsheet: root 'spreadsheet#index'

tuần tự theo các bước đã cung cấp cho chúng ta với đầy đủ các chức năng của một websockets server. Chúng ta có thể kiểm tra bằng cách chạy passenger start hoặc rails s, sau đó mở trình duyệt tới http://localhost:3000 mở inspector và kiểm tra kết nối WebSockets /cable. Bạn sẽ nhìn thấy một subscription được tạo tới active_users channel và được truyền một cách đều đặn trong frames. active_users.gif

Giới Thiệu Về RethinkDB RethinkDB: là một lưu trữ dữ liệu theo kiểu document-oriented giống như MongoDB, nhưng có một số lợi thế chủ chốt. Nó là một ngôn ngữ truy vấn mà hoạt động tốt với các ngôn ngữ như Ruby, Node.JS và Python. Nó cũng hỗ trợ các tính năng tự động là sharding(mảnh lưu trữ) và replication(nhân bản) tạo ra một sự lựa chọn linh hoạt và an toàn cho các ứng dụng có thể mở rộng số lượng lớn người dùng. Tuy nhiên, tính năng khác biệt mà chúng ta quan tâm ở đây là changefeeds.

Các queries của changefeeds ban đầu cũng giống như bất kỳ các câu query thông thường nào khác. Nhưng thay vì query một lần trên collection và sau đó trả về kết qủa của nó thì query vẫn tồn tại và trả về bất kỳ kết qủa nào mới khi có dũ liệu nhập vào trong database.

Chúng tôi sẽ chỉ ra sử dụng changefeeds như thế nào để tạo ra một kiến trúc reactive mạnh mẽ nhất cho ứng dụng web của chúng ta trong các phần kế tiếp.

Theo yêu cầu, đầu tiên cài đặt RethinkDB làm theo hướng dẫn ở đây tùy theo hệ điều hành của bạn. Mở terminal mới và chạy rethinkdb để bắt đầu với hệ thống database. Bạn có thể mở trình duyệt tới http://localhost:8080 để nhìn thấy giao diện quản lý của nó. rethinkdb.png

Thêm RethinkDB Model tới Ứng Dụng Cách dễ nhất để thêm các RethinkDB Models tới một ứng dụng Ruby on Rails đó là sử dụng gem NoBrainer. Nó chú trọng đến các kết nối và bao bọc các document của bạn trong các đôí tượng với các relations(quan hệ) và attributes(thuộc tính) giống như một ActiveRecord.

Để sử dụng nó đơn giản chỉ việc thêm nó tới Gemfile:

gem 'nobrainer' gem 'nobrainer_streams'

Sau đó bundlerails g nobrainer:install để ứng dụng của bạn sử dụng NoBrainer thay cho ActiveRecord.

NoBrainer tự động tạo databases và tables cho bạn. Bởi vậy chúng ta có thể bỏ qua bước viết migration và chuyển tới ngay việc tạo model. Đơn giản thêm dòng code sau tới app/models/user.rb:

class User
  include NoBrainer::Document
  field :selected_cell
end

Bây giờ chúng ta có thể sử dụng nó trong channel active_users. Thao tác các lifecycle hooks và thêm vào một hành động. Chỉnh sửa file app/channels/active_users.rb như sau:

class ActiveUsersChannel < ApplicationCable::Channel
    include NoBrainer::Streams

  def subscribed
    @user = User.create
    stream_from User.all, include_initial: true
  end

  def unsubscribed
    @user.destroy
  end
end

Hàm subscribed tạo ra một user mới mỗi lần có một channel được thiết lập. stream_from triệu gọi chạy một truy vấn RethinkDB, truy vấn này sẽ được truyền trực tiếp tới WebSocket Client. Nó cũng truyền tới tất cả users tồn tại và sau đó tiếp tục truyền tới các users bất cứ khi nào chúng được tạo hay hủy.

Hàm unsubscribed sẽ hủy các liên kết user với channel. Chú ý điều này không phải là cách hiệu quả cho quản lý các sessions, bởi vì session có thể kết thúc mà không cần unsubscribed được gọi trong một số trường hợp. rethinkdb.gif

Render dự liệu phía Client Để hiển thị kết quả của query chúng ta sẽ xây dựng một view đơn giản. Thêm code HTML sau tới app/views/spreadsheet/index.html.erb:

<section id="active_users">
    <h2>Active users</h2>
    <ul id="active_users_list"></ul>
</section>

Ở view này đầu tiên chúng ta chỉnh sửa method receive của cài đặt channel phía client trong app/assets/javascripts/channels/active_users.coffee để update model phía client bất cứ khi nào có dự liệu mới:

    App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
      received: (data) ->
        if data.old_val && !data.new_val
          App.spreadsheet.remove_user(data.old_val)
        else if data.new_val
          App.spreadsheet.new_user(data.new_val)

Sau đó chúng ta sẽ update view với một vài câu lệnh jQuery trong app/assets/javascripts/spreadsheet.coffee:

App.spreadsheet =
    active_users: {}

    new_user: (user) ->
        @active_users[user.id] = user
        @render_active_users()

    remove_user: (user) ->
        delete @active_users[user.id]
        @render_active_users()

    render_active_users: () ->
        $('#active_users_list').html(
            ("<li>#{user.id}</li>" for id, user of @active_users).join("")
        )

Mở trình duyệt đi tới localhost:3000 và sau đó mở nhiều tab và nhìn danh sách user được update ngay lập tức active_users_2.gif

Bây giờ chúng ta đã đi qua các thiết lập cơ bản của ActionCable channel từ backend cho tới frontend, chúng ta sẽ chuyển tới làm ứng dụng phức tạp hơn và cài đặt một multi-user spreadsheet.

Thiết lập một multi-user-spreadsheet Từ điểm này chúng ta chỉ chỉ ra điểm cốt lõi của thay thổi cần thiết để mỗi tính năng làm việc và thêm vào một số tính năng không quá phức tạp. Nếu bạn muốn làm theo cài đặt mỗi bước thì có thể tìm ở đây this gitbub repo. Mỗi bước được gói gọn trong một commit.

Để xem qua về spreadsheets chúng ta có thể xem nhanh spreadsheet sử dụng jQuery và HandsOnTable. Hơn nữa tìm từ spreadsheet này developer có thể refactor nó sử dụng React hay Polymer.

Chúng ta thêm spreadsheet element tới HTMl trong app/views/spreadsheet/index.html.erb:

<% content_for(:head) do %>
    <%= javascript_include_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.js" %>
    <%= stylesheet_link_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.css" %>
<% end %>

<!-- ... -->

<section id="spreadsheet">
</section>

Sau đó thêm setup function tới app/assets/javascripts/spreadsheet.coffee:

App.spreadsheet =
    # ...
    setup: () ->
        container = document.getElementById('spreadsheet')
        @hot = new Handsontable(container,
            minSpareCols: 1
            minSpareRows: 1
            rowHeaders: true
            colHeaders: true
            contextMenu: true
        )

$ -> App.spreadsheet.setup()

spreadsheet.png

Xác định các trường lựa chọn bằng các ô màu Tính năng hoàn hảo của multi-user spreadsheet đó là bạn có thể nhìn thấy các ô mà người dùng khác đã chọn. Để thiết lập tính năng này chúng ta thêm một action lưu trữ một ô của user đã chọn tới channel trong app/channels/active_users.rb

class ActiveUsersChannel < ApplicationCable::Channel
  # ...
  def select_cells(message)
    @user.update! selected_cells: message['selected_cells']
  end
end

Sau đó phía javascript chúng ta thêm một function để gọi hành động kết nối websocket trong app/assets/javascripts/channels/active_users.coffee

App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
  # ...
  select_cells: (cells) ->
    @perform('select_cells', selected_cells: cells)

Hàm này được gọi mỗi lần có một lựa chọn được thực hiện hay một lựa chọn được hủy trên spreadsheet. Nếu chúng ta thực sự subscribed tới các users thì các thay đổi sẽ ngay lập tức truyền tới channel, do đó chúng ta thêm vào một render_selected_cells function mà thêm user-<num> CSS class tới các ô được lựa chọn. Cả hai thêm vào trong app/assets/javascripts/spreadsheet.coffee

 App.spreadsheet =
     setup: () ->
         # ...
         @hot = new Handsontable(container,
             afterSelection: () => @select_cells(arguments)
             afterDeselect: () => @deselect_cells()
             # ...

    select_cells: (cells) ->
        App.active_users.select_cells(r: cells[0], c: cells[1], r2: cells[2], c2: cells[3])

    deselect_cells: () ->
        App.active_users.select_cells(null)

    render_selected_cells: () ->
        for cells in @selected_cells
            cell = @hot.getCell(cells.r, cells.c)
            if cell.classList.contains("current")
                cell.classList = "current"
            else
                cell.classList = ""

        @selected_cells = []
        for id, user of @active_users
            if id != @current_user.id && (cells = user.selected_cells)
                @selected_cells.push(cells)
                cell = @hot.getCell(cells.r, cells.c)
                cell.classList.add('user-' + user.num)

Bây giờ định nghĩa màu sắc mở rộng trong /app/assets/stylesheets/spreadsheet.scss:

    @mixin colored-border($color) {
        box-shadow:inset 0px 0px 0px 2px $color;
    }
    .user-1 { @include colored-border(#33a02c);}
    .user-2 { @include colored-border(#e31a1c);}
    .user-3 { @include colored-border(#ff7f00);}
    .user-4 { @include colored-border(#6a3d9a);}
    .user-5 { @include colored-border(#b15928);}
    .user-6 { @include colored-border(#a6cee3);}
    .user-7 { @include colored-border(#b2df8a);}
    .user-8 { @include colored-border(#fb9a99);}
    .user-9 { @include colored-border(#fdbf6f);}
    .user-10 { @include colored-border(#cab2d6);}
    .user-11 { @include colored-border(#ffff99);}
    .user-12 { @include colored-border(#1f78b4);}

Mở một ít trình duyệt để nhìn thấy sự thay đổi trong spreadsheet colored_cells.png

Truyền các giá trị trường (Field value) Phần này thực sự đơn giản, nó chính là lõi của ứng dụng. Đầu tiên chúng tôi giới thiệu về model cho spreadsheet cells trong app/models/spreadsheet_cell.rb:

class SpreadsheetCell
  include NoBrainer::Document
  field :location
  field :value
end

Sau đó chúng ta phát sinh một channel (sử dụng rails g channel spread_sheet_cells) và thực hiện nó với một stream cho các cell values và một action để update một ô trong app/channels/spread_sheet_cells_channel.rb:

class SpreadSheetCellsChannel < ApplicationCable::Channel
  include NoBrainer::Streams

  def subscribed
    stream_from SpreadsheetCell.all, include_initial: true
  end

  def set_cell_value(message)
    location = message['location']
    SpreadsheetCell.upsert! location: location, value: message['value']
  end
end

Sau đó chúng ta mở phía client ở app/assets/javascripts/channels/spread_sheet_cells.coffee và thiết lập điểm cuối của giao tiếp

App.spread_sheet_cells = App.cable.subscriptions.create "SpreadSheetCellsChannel",
  received: (data) ->
    App.spreadsheet.update_cell(data.new_val)

  set_cell_value: (location, value) ->
    @perform('set_cell_value', location: location, value: value)

Bây giờ chỉ có một thứ duy nhất chưa làm đó là thiết lập controller phía client ở app/assets/javascripts/spreadsheet.coffee. Chúng ta thêm một afterChanged event để lưu giá trị mới và một update_cell action để bắt dữ liệu đến.

App.spreadsheet =
    # ...
    setup: () ->
        # ...
        @hot = new Handsontable(container,
            afterChange: (changes, source) =>
                if source != 'remote' && changes
                    for change in changes
                        App.spread_sheet_cells.set_cell_value(
                            { r: change[0], c: change[1] },
                            change[3]
                        )
            # ...
        )

    update_cell: (update) ->
        location = update.location
        value = update.value
        @hot.setDataAtCell(location.r, location.c, value, 'remote')

Chỉ với đôi dòng chúng ta đã kết thúc một ứng dụng khá ấn tượng. Mở một vài trình duyệt windows và thêm một vài dữ liệu. Khía cạnh real-time streaming của WebSockets làm cho ứng dụng thực sự tốt.

Để làm cho ứng dụng này mạnh mẽ chúng ta sẽ thêm tính năng cao cấp hơn một chút là edit locks.

Thiết lập locks để ngăn cản chỉnh sửa cùng lúc Khi một user bắt đầu nhập dữ liệu trong ô, có thể không may mắn có một user khác cũng bắt đầu chỉnh sửa ô đó vào cùng một lúc và một trong chỉnh sửa của họ bị mất. Giải pháp dễ làm nhất để giải quyết vấn đề này là khóa ô cho bất cứ ai lựa chọn ô chỉnh sửa đầu tiên.

Chúng ta sẽ thiết lập một khóa bảo vệ chỉnh sửa là tính năng cuối cùng của ứng dụng spreadsheet. Quay trở lại backend, đầu tiên thêm hai phương thức sau tới lớp User trong app/models/user.rb

class User
  include NoBrainer::Document
  field :selected_cells

  before_destroy :unlock_cell

  def lock_cell(location)
    NoBrainer.run do |r|
      SpreadsheetCell.rql_table
        .get(location)
        .replace do |row|
          r.branch(
            row.eq(nil),
            { location: location, lock: id },
            row.merge(
              r.branch(row['lock'].eq(nil), {lock: id},{})
            ))
        end
    end
  end

  def unlock_cell
    SpreadsheetCell.where(lock: id).update_all lock: nil
  end
end

Bất cứ khi nào một user mở ô để chỉnh sửa, trước đó ô cho phép user chỉnh sửa nó sẽ đề nghị server khóa. Truy vấn RethinkDB tìm kiếm phức tạp trong phương thức lock_cell, trong thao tác: khóa ô, kiểm tra nếu nó được khóa và nếu nó không được khóa thiết lập một khóa tới id của user. Do chúng ta thiết lập khóa trong SpreadSheetCell document mà tất cả users đã subscribed tới, nên tất cả users sẽ tiếp nhận bất kỳ update nào về trạng thái khóa trong ô.

Lệnh unlock_cell tìm toàn bộ document để tìm các khóa và giải phóng chúng. Chúng tôi cũng giới thiệu hàm before_destroy mà khi user đóng kết nối của họ thì bất kỳ khóa nào user nắm giữ sẽ được giải phóng.

Về phía client chúng tôi thêm một monkey patch tới HandsOnTable cho phép chúng ta chặn bởi các hàm beginEditing và finishEditing, bởi cài đặt thuộc tính acquireEditLock và releaseEditLock. Chúng được viết như sau:

App.spreadsheet =
    # ...
    setup: () ->
        @selected_cells = []
        @cell_lock_callback = {}
        container = document.getElementById('spreadsheet')
        @hot = new Handsontable(container, ..)

        @hot.acquireEditLock = (editor, callback) =>
            location = {r: editor.row, c: editor.col}
            @cell_lock_callback[location] = callback
            App.active_users.lock_cell(location)

        @hot.releaseEditLock = (editor, callback) =>
            location = {r: editor.row, c: editor.col}
            App.active_users.unlock_cell(location)
            callback()

    update_cell: (update) ->
        location = r: update.location[0], c: update.location[1]
        value = update.value
        @hot.setDataAtCell(location.r, location.c, value, 'remote')

        if update.lock == @current_user.id
            @cell_lock_callback[location]?()
            delete @cell_lock_callback[location]

    # ...

Tronng acquireEditLock chúng ta lưu hàm cho phép user chỉnh sửa ô họ đã chọn và chúng ta gửi request khóa tới active_users channel.

Trong releaseEditLock chúng ta gửi lệnh unlock_cell tới active_users channel.

Cuối cùng khi trong update_cell một khóa được tìm thấy cho user hiện thời, chúng ta sẽ tìm kiếm nếu ở đó có một hàm được gắn kết và triệu gọi nó.

Công nghệ mới nhất Rails 5 mới được phát hành và ActionCable là một công nghệ mới trong đó. Kết quả của việc làm việc với công nghệ mới giống như một hệ thống mà ta chưa thể hiểu hết về chúng. Mục đích của bài viết này là thiết lập monkey patches tới RethinkDB Ruby driver và NoBrainer ORM để có một tích hợp chắc chắn. Chúng được thêm vào qua gem và ở đó cũng có một số pull request cho cả driverORM. Với một số pull khác sẽ được merged và chúng ta có thể có công nghệ mới mà không bị ảnh hưởng.

Kết Luận spreadsheet-full.gif

Sau giới thiệu ngắn về component của Rails 5 WebSocket: ActionCable và RethinkDB, bài viết này đưa chúng ta đi qua cách thiết lập concurrent & collaborative spreadsheet. SpreadSheet chỉ các ô được lựa chọn, chống lại chỉnh sửa đồng thời cùng một ô, và ngay lập tức cập nhật dữ liệu.

ActionCable thiết lập data channel và truy cập các procedure gọi dễ dàng. RethinkDB tích hợp với ActionCable channel và cho phép chúng ta thiết lập giao tiếp giữa các clients để persisted và broadcasted mà không cần đến message broker.

Chúng tôi hi vọng bài viết này mang cho bạn cảm hứng để xây dựng các tính năng hay ứng dụng mới thú vị hơn với WebSockets.

Nếu bạn thích bài viết này có thể đăng ký tới danh sách địa chỉ email của chúng tôi và hãy upvote nó trên HN hay Reddit. Gần đây chúng tôi đã viết các bài về ActionCable ở đây và sẽ sớm có phần 2 tiếp theo.


All Rights Reserved