← Blog

Hotwire không cần Action Cable: Turbo Streams qua Hibira trong Rails

· Hibira

Vì sao chúng tôi làm hibira-rails: giữ nguyên broadcasts_to và turbo_stream_from, còn kết nối WebSocket do Hibira đảm nhận. Phương thức duy nhất được thay, cách stream đã ký vẫn an toàn, chuyện gì xảy ra với phát sóng quá lớn, và hướng dẫn năm phút.

Điều hấp dẫn nhất khi làm cập nhật thời gian thực trong Rails là đúng một dòng code này:

class Message < ApplicationRecord
  broadcasts_to :room
end

Thêm <%= turbo_stream_from @room %> vào trang, mỗi khi có người gửi tin nhắn, mọi trình duyệt đang mở phòng chat đó sẽ cùng lúc có thêm một dòng mới, không cần viết một dòng JavaScript nào. Đây là chỗ Hotwire khiến người ta mê Rails.

Rắc rối chỉ lộ ra khi đưa lên production: đằng sau dòng đó là Action Cable. Các kết nối WebSocket nằm ngay trong Puma của bạn, phát sóng qua nhiều máy cần Redis hoặc Solid Cable, nền tảng hosting giới hạn số kết nối, và khi người dùng tăng lên, bạn phải tách cable server ra để mở rộng riêng.

Bài viết này kể lại cách chúng tôi làm hibira-rails: vì sao chúng tôi muốn làm nó, nó thay đúng một chỗ nào, và cách nối một ứng dụng Rails có sẵn vào chỉ trong năm phút.

Đằng sau một dòng đó là cả một máy chủ

Action Cable không khó dùng. Khó là vận hành nó. Mỗi người dùng đang mở trang chiếm một kết nối dài hạn trên máy chủ ứng dụng của bạn. Ngày thường thì khó nhận ra, nhưng khi lưu lượng tăng vọt, khi khởi động lại để deploy hay khi đổi máy chủ, các kết nối này cùng đứt và cùng kết nối lại, trong khi tiến trình Rails vẫn phải xử lý các request bình thường.

Trình duyệthàng nghìn WebSocket
kết nối dài hạn
Puma của bạnxử lý cả HTTP lẫn WebSocket
phát
Redis hoặc Solid Cableđồng bộ giữa các máy
Redis hoặc Solid Cable
chuyển tiếp
Puma của bạn
đẩy
Trình duyệt
Với Action Cable, kết nối và phát sóng đều nằm trong hạ tầng của bạn

Chúng tôi làm Hibira để các đội không phải tự vận hành lớp đó. Hibira là dịch vụ đẩy thời gian thực được lưu trữ, tương thích giao thức Pusher Channels, nên các thư viện có sẵn như pusher-js kết nối thẳng vào được. Vấn đề là lập trình viên Rails không dùng pusher-js, họ dùng broadcasts_to và turbo_stream_from. Nếu đổi dịch vụ mà phải viết lại mọi model và view thì đó không phải "nối vào", mà là "viết lại".

Vì vậy chúng tôi đặt ra một mục tiêu duy nhất: không sửa model, không sửa view. Cài xong là chính dòng đó đi qua Hibira.

Chỉ thay đúng một chỗ

Đọc mã nguồn turbo-rails sẽ thấy: dù bạn dùng broadcasts_to, broadcast_append_to, broadcasts_refreshes hay broadcast_replace_later_to chạy trong background job, tất cả cuối cùng đều đi vào cùng một phương thức: Turbo::StreamsChannel.broadcast_stream_to. Nó trao HTML Turbo Stream đã render cho Action Cable.

Đó là một điểm cắt rất gọn. hibira-rails chỉ ghi đè đúng phương thức này, gửi cùng đoạn HTML đó tới một kênh trên Hibira. Phía trang cũng vậy: turbo_stream_from trước đây tạo <turbo-cable-stream-source>, giờ tạo <hibira-turbo-stream-source>, và một đoạn JavaScript nhỏ đăng ký kênh rồi trao nội dung nhận được cho Turbo. Bản thân Turbo hoàn toàn không biết tin nhắn đến từ đâu.

Trước

Modelbroadcasts_to
TurboStreamsChannel
Action CablePuma và Redis của bạn
WebSocket
Trình duyệt

Sau

Modelbroadcasts_to
TurboStreamsChannel
phát một lần qua HTTPS
Hibiraphân phối
WebSocket
Trình duyệt
Cài hibira-rails xong, cùng dòng broadcasts_to đi qua Hibira

Từ đó tiến trình Rails chỉ làm việc nó giỏi: xử lý request, render HTML, phát một lần. Kết nối WebSocket, kết nối lại và phân phối là việc của Hibira.

Quy tắc bảo mật giữ nguyên: không có chữ ký thì không đăng ký được

Điều đáng sợ nhất khi thay tầng truyền tải là vô tình thay luôn cả quyền truy cập. Quy tắc của Action Cable rất đơn giản: turbo_stream_from đặt tên stream đã ký vào trang, và máy chủ chỉ chấp nhận đăng ký có chữ ký đúng. Chỉ ai xem được trang mới có chữ ký.

Chúng tôi giữ nguyên quy tắc đó. Mỗi Turbo stream ứng với một kênh riêng tư trên Hibira, tên kênh được tạo từ mã băm của tên stream. Để đăng ký, trình duyệt phải mang chữ ký trên trang về ứng dụng Rails của bạn để đổi lấy cấp quyền. Chữ ký sai, hoặc kênh không khớp với stream, đều bị từ chối.

Trình duyệtỨng dụng RailsHibiraTrang chứa tên stream đã kýturbo_stream_fromKết nốinhận socket_idPOST /hibira/turbo/authsocket_id, kênh, chữ kýChữ ký cấp quyềnký bằng app secret, secret không rời máy chủsubscribeprivate-turbo-…broadcasts_to được gọimột sự kiện turbo-streamturbo-streamtrao cho Turbo cập nhật trang
Khi đăng ký kênh riêng tư, ứng dụng Rails kiểm tra chữ ký

App secret chỉ nằm trên máy chủ của bạn. Trình duyệt chỉ nhận app key công khai.

Làm thử: nối ứng dụng có sẵn trong năm phút

Chúng tôi dùng một dự án Rails 8.1 hoàn toàn mới, cố ý tạo với --skip-action-cable, để cho thấy toàn bộ quá trình không cần máy chủ Action Cable.

Cài gem rồi chạy trình cài đặt:

bundle add hibira-rails
bin/rails generate hibira:install

Trình cài đặt tạo config/initializers/hibira.rb, ghim pusher-js bằng importmap (dự án jsbundling thì cài bằng trình quản lý gói của bạn) và thêm dòng import "hibira/turbo" vào app/javascript/application.js.

Tiếp theo, đặt ba giá trị trên trang ứng dụng trong bảng điều khiển Hibira vào biến môi trường, hoặc dưới hibira: trong bin/rails credentials:edit:

HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secret

Ở bước này có hai chỗ hay bị vướng. Secret chỉ hiển thị một lần ngay sau khi tạo ứng dụng, nên hãy lưu lại ngay lúc đó; app ID và key thì luôn xem được ở Tổng quan → Thông tin xác thực trên trang ứng dụng. Ngoài ra, gói Free không cho dùng * làm origin được phép: hãy thêm mọi origin mà trang của bạn chạy trên đó, ví dụ http://localhost:3000 khi phát triển và tên miền production, nếu không trình duyệt sẽ bị từ chối khi kết nối.

Rồi kiểm tra:

$ bin/rails hibira:check
ok    App 1 on ws.hibira.io:443
ok    Credentials accepted by the REST API
ok    Turbo Streams go through Hibira
info  Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)

Model và view đúng như trong sổ tay Turbo, không đổi một ký tự. Mở hai cửa sổ, gửi tin nhắn ở một bên, bên kia hiện ra ngay mà không cần tải lại:

Hai cửa sổ trình duyệt: tin nhắn gửi từ cửa sổ B hiện ngay ở cửa sổ A, ở giữa không có Action Cable.
Hai cửa sổ trình duyệt: tin nhắn gửi từ cửa sổ B hiện ngay ở cửa sổ A, ở giữa không có Action Cable.

Chuyển trang cũng vậy. Khi Turbo đổi trang, đăng ký của trang cũ được giữ lại một chút; nếu trang mới đăng ký cùng stream thì dùng lại luôn, không ngắt rồi kết nối lại.

Test không bị hỏng

Khi phát sóng được gửi tới dịch vụ bên ngoài, thứ dễ hỏng nhất là test: mỗi lần chạy test lại gọi mạng, hoặc assert_turbo_stream_broadcasts có sẵn của Turbo bỗng dưng thất bại.

Vì vậy mặc định là turbo = :auto: đi qua Hibira khi đã đặt khóa, nhưng trong môi trường test vẫn giữ hành vi của Action Cable. Các assertion phát sóng hiện có vẫn qua, CI cũng không gọi ra ngoài. Muốn bật hoặc tắt cưỡng bức thì đặt true hoặc false.

Khi tin nhắn quá lớn

Đây là phần chúng tôi phải suy nghĩ kỹ nhất. Turbo Stream gửi HTML đã render. Một thẻ sản phẩm có thể vài trăm byte, cả một bảng thì có thể vài KB. Và mỗi gói đều có giới hạn kích thước cho một sự kiện: gói Free là 4 KB.

Vượt giới hạn thì Hibira từ chối sự kiện đó. Nếu không xử lý gì, màn hình của người dùng khác sẽ lặng lẽ ngừng cập nhật, đó là kết quả tệ nhất.

Cách chúng tôi làm: gặp phát sóng quá lớn, tự động gửi một page refresh của Turbo 8 thay thế. Nó chỉ vài chục byte, và trang nhận được sẽ tự tải lại nội dung mới nhất. Trang sẽ không chỉ thêm một dòng như append, nhưng chắc chắn sẽ đúng.

Modelbroadcast_append_to
12 KB HTML
Hibiravượt giới hạn, từ chối
Hibira
báo quá lớn
hibira-railsgửi refresh thay thế
hibira-rails
vài chục byte
Hibira
Trình duyệttải lại trang, luôn đúng
Phát sóng vượt giới hạn sự kiện tự động thành page refresh

Nếu trang của bạn vốn là danh sách dài, chúng tôi khuyên dùng luôn broadcasts_refreshes của Turbo 8 kết hợp turbo_refreshes_with method: :morph: mọi phát sóng đều nhỏ, trang cập nhật bằng morph, vị trí cuộn không bị nhảy. Nếu không muốn tự động refresh, bạn có thể đặt để ghi log rồi bỏ, hoặc ném ngoại lệ.

Không chỉ Turbo

Đã nối Rails với Hibira rồi, chúng tôi làm luôn vài việc thường gặp:

  • Kênh riêng tư và presence của riêng bạn: đặt authorize_channel và dùng current_user bên trong để quyết định ai được đăng ký kênh nào; với kênh presence thì trả về thông tin người dùng. Nó kế thừa ApplicationController của bạn, nên trạng thái đăng nhập và bảo vệ CSRF vẫn như thường.
  • Webhook: nhận webhook của Hibira tại /hibira/webhooks, chữ ký được kiểm tra sẵn. Trong thời gian ân hạn khi xoay vòng secret, webhook ký bằng secret cũ vẫn được nhận, nên bạn không lỡ sự kiện trước khi deploy lại.
  • Thông báo đẩy: Hibira.push gửi thông báo đẩy tới iOS, Android và trình duyệt, còn /hibira/push/token cấp token người dùng cho SDK.

Phạm vi hỗ trợ

  • Rails 7.0 đến 8.1, Ruby 3.1 trở lên, turbo-rails 1.5 đến 2. Mọi tổ hợp đều chạy CI.
  • Dùng được với importmap và jsbundling (đã kiểm tra assets:precompile với Propshaft).
  • Dùng được cả với dự án tạo bằng --skip-action-cable.

hibira-rails đã được phát hành trên RubyGems, mã nguồn theo giấy phép MIT trên GitHub. Nếu bạn có một ứng dụng Hotwire đang dựa vào Action Cable, hãy thử xem: gói miễn phí của Hibira không cần thẻ tín dụng, và cài xong thì dòng broadcasts_to đó không còn phải do máy chủ của bạn gánh nữa.

Chúng tôi đang làm những tích hợp tương tự cho các framework và engine khác. Cái nào sẽ đến tiếp theo? Chúng tôi sẽ tiết lộ trong bài sau. Hãy đón xem.