Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Serverless Functions là paradigm lập trình mới phát triển và deploy cloud services. Trong thế giới serverless, chúng ta trừu tượng hóa provisioning, maintenance, scaling backend services cho cloud provider. Điều này cải thiện đáng kể developer productivity bằng cách cho dev tập trung giải quyết vấn đề cụ thể. Mặc dù có nhiều ưu/nhược điểm xây dựng serverless functions, một điều cần cân nhắc là language support. Gần đây, Google 발표했다 hỗ trợ Ruby 2.7 cho Google Cloud Functions, và bài này tập trung vào build, test, deploy serverless function Ruby trên Google Cloud Functions cùng ưu/nhược điểm serverless functions.

Xây dựng hệ thống OTP Serverless

One-time passwords (OTPs) là mã số ngắn dùng cho authentication, như khi ngân hàng gửi OTP qua tin nhắn xác thực danh tính.

Trong bài này, chúng ta xây dựng OTP function xử lý ba trách nhiệm cốt lõi:

POST /otp: Tạo và gửi OTP message đến phone_number cung cấp.

# Request
{ "phone_number": "+2347012345678" }
# Response
{ "status": true, "message": "OTP sent successfully", "data": { "phone_number": "+2347012345678", "otp": 6872, "expires_at": "2021-02-09 07:15:25 +0100" } }

PUT /otp/verify: Xác thực OTP đối với OTP user cung cấp.

# Request
{ "phone_number": "+2347012345678", "otp": 7116 }
# Response
{ "status": true, "message": "OTP verified", "data": {} }

PUT /otp/resend: Thử tạo và gửi lại OTP đến phone_number cung cấp.

# Request
{ "phone_number": "+2347012345678" }
# Response
{ "status": true, "message": "OTP sent successfully", "data": { "phone_number": "+2347012345678", "otp": 8533, "expires_at": "2021-02-09 08:59:16 +0100" } }

Để đơn giản, cloud function của chúng ta sẽ backed bởi Cloud MemoryStore (Redis hoặc Memcache trên GCP) thay vì full SQL/NoSQL Database. Điều này cũng cho chúng ta học về shared state trong môi trường stateless.

Viết Google Cloud Function với Ruby

Để viết functions trong GCF, chúng ta dựa vào Functions Framework do Google Cloud team cung cấp cho building GCF functions (chi tiết sau).

Đầu tiên, tạo App directory và vào directory.

mkdir otp-cloud-function && cd otp-cloud-function

Tiếp theo, tạo Gemfile và cài đặt.

Giống hầu hết Ruby app tiêu chuẩn, chúng ta dùng bundler quản lý dependencies của function.

source "https://rubygems.org"
# Core
gem "functions_framework", "~> 0.7"
# Twilio for Sms
gem 'twilio-ruby', '~> 5.43.0'
# Database
gem 'redis'
# Connection Pooling
gem 'connection_pool'
# Time management
gem 'activesupport'
# API Serialization
gem 'active_model_serializers', '~> 0.10.0'
group :development, :test do
  gem 'pry'
  gem 'rspec'
  gem 'rspec_junit_formatter'
  gem 'faker', '~> 2.11.0'
end
bundle install

Tạo Function

Khác nhau các hosting environment cho phép chỉ định file khác nhau chứa functions. Google Cloud Functions, tuy nhiên, yêu cầu phải là app.rb ở root project directory. Bây giờ chúng ta sẵn sàng viết function.

Mở app.rb và tạo function:

# Cloud Functions Entrypoint
require 'functions_framework'
require 'connection_pool'
require 'active_model_serializers'
require './lib/store'
require './lib/send_sms_notification'
require './lib/response'
require './lib/serializers/models/base_model'
require './lib/serializers/models/otp_response'
require './lib/serializers/application_serializer'
require './lib/serializers/base_model_serializer'
require './lib/serializers/otp_response_serializer'

FunctionsFramework.on_startup do |function|
  # Setup Shared Redis Client
  require 'redis'
  set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
end

# Define HTTP Function
FunctionsFramework.http "otp" do |request|
  store = Store.new(global(:redis_client))
  data = JSON.parse(request.body.read)
  if request.post? && request.path == '/otp'
    phone_number = data['phone_number']
    record = store.get(phone_number)
    unless record.nil? || record.expired?
      data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
      json = Response.generate_json(status: true, message: 'OTP previously sent', data: data)
      return json
    end
    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call
    data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
    Response.generate_json(status: true, message: 'OTP sent successfully', data: data)
  elsif request.put? && request.path == '/otp/verify'
    phone_number = data['phone_number']
    record = store.get(phone_number)
    if record.nil?
      return Response.generate_json(status: false, message: "OTP not sent to number")
    elsif record.expired?
      return Response.generate_json(status: false, message: 'OTP code expired')
    end
    is_verified = data['otp'] == record['otp']
    if is_verified
      return Response.generate_json(status: true, message: 'OTP verified')
    else
      return Response.generate_json(status: false, message: 'OTP does not match')
    end
  elsif request.put? && request.path == '/otp/resend'
    phone_number = data['phone_number']
    store.del(phone_number)
    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call
    data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
    json = Response.generate_json(status: true, message: 'OTP sent successfully', data: data)
  else
    Response.generate_json(status: false, message: 'Request method and path did not match')
  end
end

Đây là nhiều code, chúng ta sẽ phân tích:

  • Functions_Framework.on_startup là block code chạy per Ruby instance trước khi functions bắt đầu xử lý request. Lý tưởng để chạy initialization trước khi functions được gọi. Ở đây, tôi dùng để tạo và share connection pool đến Redis server:

    set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }

    Điều này cho phép share pool Redis connection objects qua nhiều concurrent function invocations. Nhiều startup có thể định nghĩa. Chúng chạy theo thứ tự định nghĩa. Cũng quan trọng: Functions Framework không cung cấp hook đặc biệt chạy sau function completion.

  • Functions_Framework.http 'otp' do |request| xử lý request và response processing của functions. Function này hỗ trợ ba route pattern khác nhau. Có thể định nghĩa các loại function khác (ví dụ: Functions_Framework.cloud_event 'otp' do |event|) xử lý event từ các Google services khác. Cũng có thể định nghĩa multiple functions cùng file nhưng deploy độc lập.

  • Trong store = Store.new(global(:redis_client)), method global dùng để retrieve object lưu trong global shared state. Như dùng ở trên, chúng ta retrieve Redis client từ Connection pool định nghĩa trong global setup trong block startup.

  • Response và Models::OtpResponse xử lý response serialization với active_model_serializers để cho JSON response format đúng.

Test Function Local

Thư viện Functions Framework cho phép dễ dàng test functions local trước khi deploy lên cloud. Để test local, chạy

bundle exec functions-framework-ruby --target=otp --port=3000

--target dùng chọn function deploy.

Mặc dù manual testing tốt, automated tests và self-testing software là thánh grail trong testing. Functions Framework cung cấp helper methods cho cả Minitest và RSpec giúp test functions cho cả http và cloudevents handlers. Đây là ví dụ test:

require './spec/spec_helper.rb'
require 'functions_framework/testing'
describe 'OTP Functions' do
  include FunctionsFramework::Testing
  describe 'Send OTP', redis: true do
    let(:phone_number) { "+2347012345678" }
    let(:body) { { phone_number: phone_number }.to_json }
    let(:headers) { ["Content-Type: application/json"] }
    it 'should send OTP successfully' do
      load_temporary "app.rb" do
        request = make_post_request "/otp", body, headers
        response = call_http "otp", request
        expect(response.status).to eq 200
        expect(response.content_type).to eq("application/json")
        parsed_response = JSON.parse(response.body.join)
        expect(parsed_response['status']).to eq true
        expect(parsed_response['message']).to eq 'OTP sent successfully'
      end
    end
  end
end

Deploy Function

Đầu tiên, cần deploy Redis server dùng Google Cloud Memorystore, mà function phụ thuộc. Tôi không đi sâu vào cách deploy Redis server lên GCP, vì ngoài phạm vi bài viết.

Có nhiều cách deploy function lên môi trường Google Cloud Functions: deploy từ máy, deploy từ GCP console, deploy từ code repositories. Modern software engineering khuyến khích CI/CD cho hầu hết development, và bài này tập trung deploy Cloud Function từ Github với Github Actions dùng deploy-cloud-functions.

Hãy setup deployment file (.github/workflows/deploy.yml).

name: Deployment
on:
  push:
    branches:
      - main
jobs:
  deploy:
    name: Function Deployment
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - id: deploy
        uses: google-github-actions/deploy-cloud-functions@main
        with:
          name: otp-cloud-function
          runtime: ruby26
          credentials: ${{ secrets.gcp_credentials }}
          env_vars: "TWILIO_ACCOUNT_SID=${{ secrets.TWILIO_ACCOUNT_SID }},TWILIO_AUTH_TOKEN=${{ secrets.TWILIO_AUTH_TOKEN }},TWILIO_PHONE_NUMBER=${{ secrets.TWILIO_PHONE_NUMBER }},REDIS_URL=${{ secrets.REDIS_URL }}"

Environment Variables

Trong code trên, dòng cuối cho phép chỉ định environment variables có sẵn cho function trong môi trường Google Cloud. Lưu ý vì lý do bảo mật, chúng ta không expose variables trong code-base; thay vào đó, dùng Github actions secrets giữ thông tin private. Để check tokens đã deploy đúng, check cloud function trong Google Console, như dưới:

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Authentication

Tạo Service Account với roles Cloud Functions Admin và Service Account User.

Service account dùng cho machine-to-machine IAM. Vì vậy, khi một system, dù chạy trên Google Cloud hay không, nói chuyện với system khác trên Google Cloud, service account cần thiết để identify ai đang request access đến Google resource. Roles Cloud Functions Admin và Service Account User cho phép xác định user có authorized access resource không. Trong trường hợp này, Github Action runner giao tiếp với Google Cloud authenticate as service account với permissions cần thiết deploy function.

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Tạo Service Account Key, download JSON, thêm vào GitHub Secrets.

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Voila! 🎉 Cloud Function đã deploy thành công.

Cloud Functions Limits vs. AWS Limits

Dưới là so sánh chi tiết hai provider Serverless function lớn nhất:

Thành thạo Ruby trên Google Cloud Functions: Build, Test, Deploy Liền Mạch

Functions Framework Contract vs. Serverless Framework

Bài này tập trung build cloud functions cho Google Cloud Functions. Phần này so sánh build với Functions Framework vs. Serverless Framework.

  • Serverless Framework dựa trên serverless.yml, Functions Framework dựa trên Functions Framework Contract, dùng deploy serverless functions trên Google Cloud Infrastructure.
  • Với Serverless Framework, chỉ có vài ví dụ, không rõ cách build và deploy serverless functions với Ruby lên các Google serverless environments (Cloud Functions, Cloud Run, Knative). Với Functions Framework Contract, build với Ruby trên các Google products khác nhau rất straightforward.
    • Theo điểm trước, Functions Framework Contract làm rất dễ chuyển backing language đằng sau function mà không nhất thiết thay đổi nhiều deployment process.
  • Tính đến lúc viết, Functions Framework chỉ hỗ trợ interoperability trên Google Cloud Serverless Environments và Knative environments. Serverless Framework tuy nhiên hỗ trợ multiple platforms trên multiple providers.

Tham khảo, full code available tại đây.