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 completionFunctions_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 ในบล็อกstartupResponseและ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:

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


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


Voila! 🎉 Cloud Function ของเราถูก deploy เรียบร้อยแล้ว
Cloud Functions Limits เทียบ AWS Limits
ด้านล่างคือการเปรียบเทียบรายละเอียดของ provider serverless functions 2 อันดับหน้า:

Functions Framework Contract เทียบ Serverless Framework
ในบทความนี้เราโฟกัสสร้าง cloud functions สำหรับ Google Cloud Functions ในส่วนนี้อยากเปรียบเทียบการสร้างด้วย Functions Framework vs Serverless Framework
Serverless Frameworkbased onserverless.ymlส่วน Functions Framework based onFunctions Framework Contractใช้ deploy serverless functions ข้าม Google Cloud Infrastructure- กับ
Serverless Frameworkมีตัวอย่างน้อย และไม่ชัดเจนนักว่าจะสร้างและ deploy serverless functions ด้วย Ruby ไป Google serverless environments ต่างๆ (Cloud Functions, Cloud Run, Knative) ได้อย่างไร กับFunctions Framework Contractfunction การสร้างด้วย 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 โค้ดเต็มๆ พร้อมใช้งาน ที่นี่