Viết microservice với RabbitMQ

Microservice là chủ đề mới và ngày càng nóng hổi trong cộng đồng lập trình viên. Bài viết này sẽ giới thiệu sơ lược về cách xây dựng một microservice trên Ruby on Rails.

Bài toán ở đây là, viết một microservice dùng để gửi mail. Microservice này sẽ nhận một message có dạng:

{
  "provider": "framgia",
  "template": "thanks",
  "from": "[email protected]",
  "to": "[email protected]",
  "replacements": {
    "salutation": "VietNH",
    "year": "2016"
  }
}

Và tự động gửi một thanks mail đến địa chỉ [email protected] và sử dụng một số biến số được truyền vào replacements. Đây là một ví dụ hoàn hảo cho một microservice bởi vì service sẽ rất nhỏ, tập chung hoàn thành một chức năng với một giao diện rõ ràng.

Với một microservice, ta cần một cách nào đó để gửi các thông điệp đến nó. Để tránh mất mát dữ liệu, ta sử dụng một hàng đợi các thông điệp. Ta có thể tự viết một hàng đợi như thế, hoặc sử dụng một gem có sẵn để thực hiện điều này. Ở đây, ta chọn sử dụng RabbitMQ, bởi vì những lý do sau:

  • RabbitMQ rất phổ biến với mã hóa chuẩn AMQP.
  • RabbitMQ hỗ trợ nhiều ngôn ngữ lập trình, có thể sử dụng trong các ứng dụng đa ngôn ngữ.
  • RabbitMQ phù hợp với nhiều hoàn cảnh, từ những workflow đơn giản như các hàng đợi có tên đến những hàng đợi có logic phức tạp.
  • RabbitMQ hỗ trợ giao diện đồ họa với một trang admin riêng trên web browser.
  • RabbitMQ có thể được host trên một server khác với ứng dụng.

Việc gửi một thông điệp đến hàng đợi của RabbitMQ có thể thực hiện dễ dàng:

require 'bunny'
require 'json'

connection = Bunny.new
connection.start
channel = connection.create_channel
queue = channel.queue 'mails', durable: true

json = { ... }.to_json
queue.publish json
connection.close

bunny là gem chính thức của RabbitMQ trên Rails. Nếu ta không truyền thêm tham số vào Bunny.new, RabbitMQ sẽ tự động chạy trên cổng localhost:5672. Ta có thể truy cập và sử dụng hàng đợi có tên "mails". Nếu không có hàng đợi có tên tương ứng, một hàng đợi mới sẽ được tạo. Các thông điệp gửi đến microservice sẽ được đẩy trực tiếp vào hàng đợi này. Ở đây, thông điệp được truyền dưới dạng json, nhưng ta hoàn toàn có thể sử dụng bất cứ format nào nếu muốn.

Bây giờ ta bắt đầu xây dựng microservice, một ứng dụng lấy các message về, phân tích và thực hiện gửi mail. Với mục đích đó, ta dùng sneakers, gem hỗ trợ của RabbitMQ. Sử dụng sneakers, ta có thể định nghĩa một worker như sau:

require 'sneakers'
require 'json'
require 'mandrill_api/provider'

class Mailer
  include Sneakers::Worker
  from_queue 'mails'

  def work(message)
    puts "RECEIVED: #{message}"
    option = JSON.parse(message)
    MandrillApi::Provider.new.deliver(options)
    ack!
  end
end

Như đã định nghĩa ở trên, ta đã có một hàng đợi có tên "mails" để nhận các thông điệp. Worker này có nhiệm vụ lần lượt lấy các thông điệp về, parse dưới dạng json (cũng đã được định nghĩa ở trên) và chuyển đến tác vụ gửi mail.

Việc tiếp theo để hoàn thiện microservice là setup các biến môi trường. Giả sử ta có thư mục rails project có dạng như sau:

.
├── Gemfile
├── Gemfile.lock
├── Procfile
├── README.md
├── bin
│   └── mailer
├── config
│   ├── deploy/...
│   ├── deploy.rb
│   ├── settings.yml
│   └── setup.rb
├── examples
│   └── mail.rb
├── lib
│   ├── mailer.rb
│   └── mandrill_api/...
└── spec
    ├── acceptance/...
    ├── acceptance_helper.rb
    ├── lib/...
    └── spec_helper.rb

Ta thực hiện các setting trong file bin/mailer.rb:

#!/usr/bin/env ruby
require_relative '../config/setup'
require 'sneakers/runner'
require 'logger'
require 'mailer'
require 'httplog'

Sneakers.configure(
  amqp: SETTINGS['amqp_url'],
  daemonize: false,
  log: STDOUT
)
Sneakers.logger.level = Logger::INFO
Httplog.options[:log_headers] = true

Sneakers::Runner.new([Mailer]).run

Từ bây giờ mỗi khi chạy file bin/mailer ta sẽ thấy log có dạng:

... WARN: Loading runner configuration...
... INFO: New configuration:
#<Sneakers::Configuration:0x007f96229f5f28 ...>
... INFO: Heartbeat interval used (in seconds): 2

Từ đây, mỗi khi gửi một message, ta sẽ nhận được log có dạng:

... RECEIVED: {"provider":"framgia","template":"thanks", ...}
D, ... [httplog] Sending: POST
https://mandrillapp.com:443/api/1.0/messages/send-template.json
D, ... [httplog] Data: {"template_name":"invoice", ...}
D, ... [httplog] Connecting: mandrillapp.com:443
D, ... [httplog] Status: 200
D, ... [httplog] Response:
[{"email":"[email protected]","status":"sent", ...}]
D, ... [httplog] Benchmark: 1.698229061003076 seconds

Việc xây dựng một microservice không tốn qúa nhiều thời gian và công sức. Nhưng phần khó nhất ở đây là việc deploy. Deploy một microservice thường phải thỏa mãn một số yêu cầu:

  • Microservice được chạy ở một process riêng và chạy ngầm so với rails server.
  • Microservice phải được log riêng biệt với log của server
  • Microservice sẽ được restart mỗi khi server được restart.
  • Microservice phải có các lệnh start/stop/restart để sử dụng khi cần.

Tất cả những việc này đều có thể làm được với Ruby, nhưng ta có thể sử dụng các ứng dụng của hệ điều hành (ở đây là Linux) để làm điều này. Ở đây, ta có thể sử dụng một tool tên là foreman. Với foreman, ta có thể xác định các process cần phải chạy trong một Procfile. Ta sử dụng foreman để export các biến môi trường để có thể cài đặt ở nhiều server. Ví dụ, một file của foreman export có dạng:

[Unit]
PartOf=-.target

[Service]
User=mailer_user
WorkingDirectory=/var/www/mailer_production/releases/16
Environment=PORT=5000
Environment=PATH=
/home/deploy/.rvm/gems/ruby-2.2.3/gems/bundler-1.11.2:...
Environment=ENVIRONMENT=production
ExecStart=/bin/bash -lc 'bin/mailer'
Restart=always
StandardInput=null
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=%n
KillMode=process

Với các biến môi trường này, ta có thể nhìn rõ các service cần dùng, lệnh để chạy các service này, và có thể cài đặt chúng thành các lệnh theo ý thích. Ta có thể cài đặt để chạy mỗi khi khởi động hệ thống với lệnh sudo systemctl enable mailer.target. Và với log output, ta chỉ quy định standard output cho service.

Để ra lệnh cho foreman export các biến môi trường, trước tiên cần cài đặt các process trên môi trường lập trình. Ta có thể ghi chúng vào file .env:

$ echo "PATH=$(bundle show bundler):$PATH" >> .env
$ echo "ENVIRONMENT=production" >> .env

Sau đó ta dùng foreman để export systemd trong khi đọc các biến môi trường trong file .env:

$ sudo -E env "PATH=$PATH" bundle exec foreman\
  export systemd /etc/systemd/system\
  -a mailer -u mailer_user -e .env

Sau đó reload lại để nhận các biến môi trường mới:

$ sudo systemctl daemon-reload
$ sudo systemctl reload-or-restart mailer.target

Sau đó, ta bắt đầu khởi động service:

$ sudo systemctl enable mailer.target

Bây giờ, service của chúng ta đã có thể chạy trên server, sẵn sàng nhận các message.