Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

Serverless Functions เป็นพาราดิมใหม่ของการพัฒนาและ deploy cloud services ในโลก serverless เรา abstract การ provisioning, maintenance และ scaling ของ backend services ให้ cloud provider ทำหน้าที่ สิ่งนี้เพิ่ม productivity ของนักพัฒนาอย่างมาก โดยให้โฟกัสแก้ปัญหาเฉพาะเจาะจง แม้จะมี pros และ cons ของการสร้าง serverless functions แต่สิ่งหนึ่งที่ต้องพิจารณาคือ language support 近期 Google ประกาศรองรับ Ruby 2.7 สำหรับ Google Cloud Functions ในบทความนี้จะโฟกัสที่การสร้าง ทดสอบ และ deploy serverless function ด้วย Ruby บน Google Cloud Functions รวมถึง pros และ cons

สร้างระบบ OTP แบบ Serverless

One-time passwords (OTPs) เป็นรหัสตัวเลขสั้นใช้เพื่อ authentication เช่น เมื่อธนาคารส่ง OTP ผ่าน SMS เพื่อยืนยันตัวตน

ในบทความนี้เราจะสร้าง OTP function ที่จัดการ 3 หน้าที่หลัก:

POST /otp: สร้างและส่งข้อความ OTP ไปยัง phone_number ที่ให้มา

# 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: ยืนยัน OTP ต่อ OTP ที่ผู้ใช้ให้มา

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

PUT /otp/resend: พยายามสร้างและส่ง OTP ใหม่ไปยัง phone_number ที่ให้มา

# 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" } }

เพื่อความเรียบง่าย cloud function ของเราจะ backed by Cloud MemoryStore (Redis หรือ Memcache บน GCP) แทน SQL หรือ NoSQL database ทั่วไป นอกจากนี้ยังช่วยเรียนรู้ shared states ใน stateless environment

เขียน Google Cloud Function ด้วย Ruby

เพื่อเขียน functions ใน GCF เราจะพึ่งพา Functions Framework ที่ Google Cloud team 提供 สำหรับสร้าง GCF functions (รายละเอียดเพิ่มเติมภายหลัง)

ก่อนอื่น สร้าง app directory และเข้าไป:

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

ต่อไป สร้าง Gemfile และติดตั้ง:

เหมือน Ruby apps มาตรฐาน เราจะใช้ bundler จัดการ dependencies ของ 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

สร้าง Function

โดยปกติ hosting environments ต่างๆ อนุญาตให้ระบุไฟล์ต่างกันสำหรับ functions แต่ Google Cloud Functions กำหนดให้ต้องเป็น app.rb ใน root ของ project directory ตอนนี้พร้อมเขียน function แล้ว

เปิด app.rb และสร้าง 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

โค้ดเยอะมาก เราจะแยกส่วนอธิบาย:

  • Functions_Framework.on_startup เป็นบล็อกโค้ดที่รันต่อ Ruby instance ก่อน functions เริ่มประมวลผล requests เหมาะสำหรับ initialization ก่อนเรียก functions ในที่นี้ใช้สร้างและ share connection pool ไปยัง Redis server:

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

    ทำให้เรา share pool ของ Redis connection objects ข้าม multiple concurrent function invocations ได้โดยไม่กลัว สามารถกำหนด multiple startups ได้ จะรันตามลำดับที่กำหนด สำคัญคือ Functions Framework ไม่มี hook 特別สำหรับรันหลัง function completion

  • Functions_Framework.http 'otp' do |request| จัดการ request และ response processing ของ functions รองรับ 3 route patterns สามารถ define function types อื่นๆ ได้ (เช่น Functions_Framework.cloud_event 'otp' do |event|) จัดการ events จาก Google services อื่นๆ ก็ได้ สามารถ define multiple functions ในไฟล์เดียวกันแต่ deploy แยกกันได้

  • ใน store = Store.new(global(:redis_client)) เมธอด global ใช้ retrieve object ที่เก็บใน global shared state ตามที่ใช้ข้างบน เรา retrieve Redis client จาก Connection pool ที่กำหนดใน global setup ในบล็อก startup

  • Response และ Models::OtpResponse จัดการ response serialization ด้วย active_model_serializers ให้ JSON responses ที่ formatted ถูกต้อง

ทดสอบ Function ในเครื่อง

Library Functions Framework ช่วยให้เราทดสอบ functions ในเครื่องก่อน deploy ไป cloud ได้ง่าย รัน:

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

--target ใช้เลือก function ที่จะ deploy

แม้ manual testing จะดี แต่ automated tests และ self-testing software คือ holy grail ของ testing Functions Framework มี helper methods สำหรับ Minitest และ RSpec ทดสอบ functions สำหรับ http และ cloudevents handlers ตัวอย่าง 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 ของเรา

ก่อนอื่น ต้อง deploy Redis server ด้วย Google Cloud Memorystore ซึ่ง function ของเรา dependent อยู่ ไม่จะลึกลงรายละเอียด deploy Redis server ไป GCP เพราะอยู่นอกขอบเขต

มีหลายวิธี deploy function ไป Google Cloud Functions environment: deploy จากเครื่อง, จาก GCP console, และจาก code repositories Software engineering สมัยใหม่ส่งเสริม CI/CD processes สำหรับ development ส่วนใหญ่ ในบทความนี้จะโฟกัส deploy Cloud Function จาก Github ด้วย Github Actions ใช้ deploy-cloud-functions

ตั้งค่า 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

ในโค้ดข้างบน บรรทัดสุดท้ายอนุญาตให้ระบุ environment variables ที่จะพร้อมใช้งานใน function ใน Google Cloud environment โปรดทราบว่าจากเหตุผลด้านความปลอดภัย เราไม่ expose variables เหล่านี้ใน code-base แต่ใช้ Github actions secrets เพื่อเก็บข้อมูลนี้เป็นส่วนตัว ตรวจสอบว่า tokens deploy ถูกต้อง ดู cloud function ใน Google Console:

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

Authentication

สร้าง Service Account ด้วย roles Cloud Functions Admin และ Service Account User

Service account ใช้สำหรับ machine-to-machine IAM ดังนั้นเมื่อระบบ (ไม่ว่าจะรันบน Google Cloud หรือไม่) พูดคุยกับระบบอื่นบน Google Cloud ต้องใช้ service account ช่วย identify ใคร request access ทรัพยากร Google ของเรา Roles Cloud Functions Admin และ Service Account User ช่วยกำหนดว่า user authorized เข้าถึง resource หรือไม่ ในสถานการณ์นี้ Github Action runner ติดต่อ Google Cloud authenticate เป็น service account ด้วย permissions ที่จำเป็นเพื่อ deploy function

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

สร้าง Service Account Key ดาวน์โหลด JSON และเพิ่มใน GitHub Secrets

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

Voila! 🎉 Cloud Function ของเราถูก deploy เรียบร้อยแล้ว

Cloud Functions Limits เทียบ AWS Limits

ด้านล่างคือการเปรียบเทียบรายละเอียดของ provider serverless functions 2 อันดับหน้า:

เชี่ยวชาญ Ruby บน Google Cloud Functions: สร้าง ทดสอบ และ Deploy ได้ราบรื่น

Functions Framework Contract เทียบ Serverless Framework

ในบทความนี้เราโฟกัสสร้าง cloud functions สำหรับ Google Cloud Functions ในส่วนนี้อยากเปรียบเทียบการสร้างด้วย Functions Framework vs Serverless Framework

  • Serverless Framework based on serverless.yml ส่วน Functions Framework based on Functions Framework Contract ใช้ deploy serverless functions ข้าม Google Cloud Infrastructure
  • กับ Serverless Framework มีตัวอย่างน้อย และไม่ชัดเจนนักว่าจะสร้างและ deploy serverless functions ด้วย Ruby ไป Google serverless environments ต่างๆ (Cloud Functions, Cloud Run, Knative) ได้อย่างไร กับ Functions Framework Contract function การสร้างด้วย Ruby ข้าม Google products ต่างๆ ตรงไปตรงมา
    • จากจุดก่อนหน้า Functions Framework Contract ทำให้สลับ backing language หลัง function ได้ง่ายโดยไม่ต้องเปลี่ยนแปลงมากใน deployment process
  • ณ เวลานี้ Functions Framework รองรับ interoperability ข้าม Google Cloud Serverless Environments และ Knative environments เท่านั้น Serverless Framework รองรับ multiple platforms ข้าม multiple providers

สำหรับ reference โค้ดเต็มๆ พร้อมใช้งาน ที่นี่