Remote Procedure Invocation
บริการ Order ต้องการที่อยู่จัดส่งของผู้ซื้อ ซึ่งอยู่ในบริการ Customer สิ่งที่เป็นธรรมชาติที่สุดในโลกคือการขอที่อยู่นั้นแล้วรอคำตอบ เหมือนกับที่ monolith เดิมเรียก method getAddress เป๊ะ ๆ ผู้เรียกต้องการการตอบกลับเพียงครั้งเดียวและทันที เพื่อจะได้สร้างคำสั่งซื้อต่อไปได้
คุณต้องการให้บริการหนึ่งเรียกใช้การทำงานบนอีกบริการหนึ่งและรับผลลัพธ์กลับมา โดยมีพิธีรีตองให้น้อยที่สุด ทีมงานคิดในแง่ของ method ที่รับอาร์กิวเมนต์และคืนค่าอยู่แล้ว และการทำงานนี้เป็นคำถามที่ต้องการคำตอบจริง ๆ ก่อนที่ผู้เรียกจะไปต่อได้ คุณจะทำให้การเรียกผ่านเครือข่ายรู้สึกเหมือนการเรียกฟังก์ชันที่มาแทนที่ได้อย่างไร โดยยังต้องระบุอย่างชัดเจนถึงสิ่งที่การเรียกฟังก์ชันไม่เคยต้องกังวล — การ serialize, timeout และ contract ที่ทั้งสองฝั่งเห็นพ้องกัน?
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”Remote Procedure Invocation (RPI) คือรูปแบบ synchronous, one-to-one ผู้เรียก (client) ส่งคำขอไปยัง instance บริการเดียวแล้วบล็อกจนกว่าการตอบกลับจะมาถึง โดยมี proxy หรือ client library ซ่อนกลไกไว้ ทำหน้าที่ serialize อาร์กิวเมนต์ ส่งผ่านสายไป รอ แล้ว deserialize ผลลัพธ์กลับมาเป็นค่าคืนธรรมดา
มีสอง transport ที่ครองพื้นที่:
- REST over HTTP — ทรัพยากรระบุด้วย URL การทำงานแสดงผ่าน HTTP verb และ payload มักอยู่ในรูป JSON อ่านโดยมนุษย์ได้ มีอยู่ทุกหนทุกแห่ง และทดสอบได้ง่ายด้วย browser หรือ
curl - gRPC — contract นิยามไว้ในไฟล์
.protopayload เป็น Protobuf แบบ binary ผ่าน HTTP/2 พร้อม client และ server stub ที่ถูกสร้างให้ กระชับกว่าและเร็วกว่า มี schema ที่เคร่งครัดและมีเวอร์ชัน และรองรับ streaming เป็นชั้นหนึ่ง
ไม่ว่าจะ transport ใด การโต้ตอบก็มีรูปร่างเดียวกัน: หนึ่งคำขอ หนึ่งการตอบกลับ และผู้เรียกรออยู่ระหว่างนั้น
sequenceDiagram
participant C as Client (Order Service)
participant S as Customer Service
C->>S: GET /customers/42/address
activate S
S->>S: load customer 42
S-->>C: 200 OK { address }
deactivate S
Note over C: caller was blocked until the response arrived ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”client แบบมี type สำหรับดึงที่อยู่จัดส่งของลูกค้า client ห่อหุ้ม transport ไว้เพื่อให้ผู้เรียกเขียนอะไรที่ใกล้เคียงกับการเรียก method ธรรมดา แต่ timeout ทำให้การมีอยู่ของเครือข่ายชัดเจนขึ้น แต่ละตัวอย่างสมบูรณ์ในตัวเองและเป็นสำนวนของภาษานั้น ๆ
type Address = { line1: string; city: string; postalCode: string };
class CustomerClient { constructor(private baseUrl: string) {}
async getAddress(customerId: string): Promise<Address> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); try { const res = await fetch(`${this.baseUrl}/customers/${customerId}/address`, { signal: controller.signal, }); if (!res.ok) { throw new Error(`customer service returned ${res.status}`); } return (await res.json()) as Address; } finally { clearTimeout(timer); } }}
const customers = new CustomerClient('http://customer-service');const address = await customers.getAddress('42');from dataclasses import dataclassimport httpx
@dataclassclass Address: line1: str city: str postal_code: str
class CustomerClient: def __init__(self, base_url: str) -> None: self._base_url = base_url
async def get_address(self, customer_id: str) -> Address: async with httpx.AsyncClient(timeout=2.0) as client: res = await client.get(f"{self._base_url}/customers/{customer_id}/address") res.raise_for_status() body = res.json() return Address(body["line1"], body["city"], body["postalCode"])
customers = CustomerClient("http://customer-service")address = await customers.get_address("42")type Address struct { Line1 string `json:"line1"` City string `json:"city"` PostalCode string `json:"postalCode"`}
type CustomerClient struct { baseURL string http *http.Client}
func NewCustomerClient(baseURL string) *CustomerClient { return &CustomerClient{baseURL: baseURL, http: &http.Client{Timeout: 2 * time.Second}}}
func (c *CustomerClient) GetAddress(ctx context.Context, customerID string) (*Address, error) { url := fmt.Sprintf("%s/customers/%s/address", c.baseURL, customerID) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) res, err := c.http.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("customer service returned %d", res.StatusCode) } var addr Address if err := json.NewDecoder(res.Body).Decode(&addr); err != nil { return nil, err } return &addr, nil}use std::time::Duration;use serde::Deserialize;
#[derive(Deserialize)]struct Address { line1: String, city: String, #[serde(rename = "postalCode")] postal_code: String,}
struct CustomerClient { base_url: String, http: reqwest::Client,}
impl CustomerClient { fn new(base_url: impl Into<String>) -> Self { let http = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build() .expect("client"); Self { base_url: base_url.into(), http } }
async fn get_address(&self, customer_id: &str) -> Result<Address, reqwest::Error> { let url = format!("{}/customers/{customer_id}/address", self.base_url); self.http.get(url).send().await?.error_for_status()?.json().await }}ผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้:
- ความเรียบง่าย โมเดลนี้คือสิ่งที่นักพัฒนาทุกคนรู้จักดีอยู่แล้ว: เรียก รอ ได้ผลลัพธ์ ไม่มี broker ให้ดูแล และไม่มีสถานะกลางทางให้คอยติดตาม
- contract ที่ชัดเจน โดยเฉพาะกับ gRPC type ของคำขอและการตอบกลับถูกนิยามไว้ครั้งเดียวและบังคับใช้กับทั้งสองฝั่ง ดังนั้นความไม่ตรงกันจะถูกจับได้ตั้งแต่ตอน build
สิ่งที่คุณต้องจ่าย:
- Temporal coupling (การผูกมัดในเชิงเวลา) เพราะผู้เรียกบล็อก ทั้งสองบริการจึงต้องพร้อมใช้งานในขณะเดียวกัน ถ้าฝั่งที่ถูกเรียกล่มหรือกำลัง deploy คำขอของผู้เรียกจะล้มเหลวทันที — ไม่มีบัฟเฟอร์คั่นกลาง
- ความล้มเหลวลุกลามเป็นทอด ๆ (cascading failures) ฝั่งที่ถูกเรียกที่ช้าทำให้ผู้เรียกช้าตามไปด้วย thread หรือ connection ของผู้เรียกกองสะสมรออยู่ และความช้าก็แพร่ขึ้นไปต้นน้ำจนทั้งสายของบริการติดขัด service เดียวที่กำลังดิ้นรนจึงล้มทุกอย่างที่พึ่งพาอยู่ได้
- ความพร้อมใช้งานที่ลดลง ความพร้อมใช้งานโดยรวมของสายการเรียกแบบ synchronous คือผลคูณของความพร้อมใช้งานของแต่ละข้อต่อ ดังนั้นยิ่งคำขอกระโดดมากครั้งเท่าใด ก็ยิ่งเปราะบางมากขึ้นเท่านั้น
เพราะรูปแบบความล้มเหลวเหล่านี้ อย่าเรียกแบบ synchronous ดิบ ๆ โดยไม่มีการป้องกัน ให้ห่อไว้ด้วย timeout, retry และ Circuit Breaker เพื่อให้ dependency ที่ล้มเหลวล้มเร็วแทนที่จะลากผู้เรียกล้มตามไปด้วย ในกรณีที่การโต้ตอบไม่จำเป็นต้องได้คำตอบทันทีจริง ๆ ให้เลือกใช้ Messaging แทน
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Messaging — ทางเลือกแบบ asynchronous ที่ขจัด temporal coupling
- Service Discovery — วิธีที่ client ค้นหาที่อยู่ที่จะส่งคำขอไปหา
- API Gateway — มักเป็นประตูหน้าที่การเรียก RPI จากภายนอกเข้ามา
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| เข้าใจง่าย — เหมือน function call ข้าม network | ผูก caller กับ callee อย่างแน่นหนา — ล้มทั้งคู่ |
| response ทันที — รู้ผลทันทีว่าสำเร็จหรือไม่ | network latency และ partial failure ซ่อนอยู่ |
| ง่ายต่อการ test และ debug | cascade failure — service หนึ่งช้าทำให้ caller ช้าตาม |
| เหมาะกับ query ที่ต้องการ response ทันที | availability ขึ้นกับทุก dependency ใน chain |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Synchronous Call Chain — service เรียก service เรียก service หลายชั้น
อาการ:
- request เดียวเรียก service ห้าตัวแบบ sequential
- latency รวม = sum ของ latency ทุก hop
- service ตัวใดตัวหนึ่งช้าทำให้ทั้ง chain ช้า
Tight Coupling via RPC — caller รู้ implementation detail ของ callee
อาการ:
- เปลี่ยน schema ของ response ใน service B ทำให้ service A พัง
- ต้อง deploy service A และ B พร้อมกันเสมอ
💡 ตัวอย่างจากของจริง
Google (gRPC):
- สร้าง gRPC สำหรับ internal service communication
- ใช้ Protocol Buffers เพื่อ strong contract และ efficient serialization
- ปัจจุบัน open source และใช้แพร่หลายใน microservices
Netflix:
- ใช้ทั้ง REST และ gRPC ขึ้นกับ latency requirement
- internal high-frequency call ใช้ gRPC, external API ใช้ REST