article

gRPC ที่ทำให้ผมรักแล้วเกลียด แล้วรักอีกครั้ง

8 min read

วันที่ครั้งแรกได้ยิน gRPC

ตอนได้ยิน gRPC ครั้งแรก แรกๆ นึกว่า “อะไรเนี่ย มาทำให้ชีวิตซับซ้อนอีกแล้ว” 😅

เพราะตอนนั้นใช้ REST API แล้วก็พอใจแล้วนะ:

  • GET, POST, PUT, DELETE เข้าใจง่าย
  • JSON response อ่านง่าย
  • HTTP status codes คุ้นเคย

แต่พอเจอปัญหาเรื่อง performance กับ type safety ใน microservices หลายตัวที่ติดต่อกันบ่อยๆ เราก็เริ่มมองหา alternative

ความท้าทายแรก: Protocol Buffers

วันแรกที่ต้องมาเรียนเขียน .proto files รู้สึกเหมือนกลับไปเรียนภาษาใหม่:

// user.proto - ไฟล์แรกของผม (ดู basic มาก)
syntax = "proto3";

package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc CreateUser (CreateUserRequest) returns (User);
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

message GetUserRequest {
  int32 id = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

ข้อสงสัยแรกๆ:

  • ทำไมต้องใส่ = 1, = 2, = 3
  • syntax = “proto3” คืออะไร
  • package กับ namespace ต่างกันยังไง

เรียนรู้ว่าตัวเลขพวกนั้นคือ field tags สำคัญมากในการทำ backward compatibility

เคสจริง: การ Migrate จาก REST

มีระบบ user management ที่ใช้ REST API อยู่:

// REST API - เก่า
app.get('/api/users/:id', async (req, res) => {
  const user = await getUserById(req.params.id);
  res.json(user);
});

app.post('/api/users', async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
});

ปัญหาที่เจอ:

  • API calls เยอะมาก ระหว่าง services
  • Type safety ไม่มี (ส่งผิด format บ่อย)
  • JSON serialization/deserialization ช้า
  • API documentation ไม่ sync กับ code

ลอง Migrate เป็น gRPC:

// gRPC Server
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const packageDefinition = protoLoader.loadSync('./protos/user.proto');
const userProto = grpc.loadPackageDefinition(packageDefinition).user;

const server = new grpc.Server();

server.addService(userProto.UserService.service, {
  async getUser(call, callback) {
    try {
      const user = await getUserById(call.request.id);
      callback(null, user);
    } catch (error) {
      callback({
        code: grpc.status.NOT_FOUND,
        details: 'User not found'
      });
    }
  },

  async createUser(call, callback) {
    try {
      const user = await createUser(call.request);
      callback(null, user);
    } catch (error) {
      callback({
        code: grpc.status.INVALID_ARGUMENT,
        details: error.message
      });
    }
  }
});

ปัญหาใหญ่แรก: Error Handling

การจัดการ error ใน gRPC ต่างจาก HTTP มาก:

// ❌ ความเข้าใจผิดตอนแรก
callback(new Error('Something went wrong')); // ได้ UNKNOWN error

// ✅ วิธีที่ถูกต้อง
callback({
  code: grpc.status.INVALID_ARGUMENT,
  details: 'Invalid input provided',
  metadata: new grpc.Metadata() // สำหรับข้อมูลเพิ่มเติม
});

Status codes ที่ใช้บ่อย:

  • OK - สำเร็จ
  • INVALID_ARGUMENT - input ผิด
  • NOT_FOUND - ไม่เจอข้อมูล
  • INTERNAL - server error
  • UNAUTHENTICATED - ไม่ได้ login
  • PERMISSION_DENIED - ไม่มีสิทธิ์

ความผิดพลาดที่เจอจริง

1. Field Numbers ซ้ำกัน

// ❌ ผิดพลาดที่ทำจริง
message User {
  int32 id = 1;
  string name = 1;  // ซ้ำกับ id!
  string email = 2;
}

Error: Field number 1 has already been used

เรียนรู้ว่า: Field numbers ต้องไม่ซ้ำใน message เดียวกัน

2. Breaking Changes โดยไม่รู้ตัว

// Version 1
message User {
  int32 id = 1;
  string name = 2;
}

// Version 2 - Breaking change!
message User {
  int32 id = 1;
  int32 name = 2;  // เปลี่ยนจาก string เป็น int32
}

ผลคือ: Old clients อ่านข้อมูลไม่ได้!

เรียนรู้เรื่อง Backward Compatibility:

  • ห้ามเปลี่ยน field numbers
  • ห้ามเปลี่ยน field types (ส่วนใหญ่)
  • ใช้ reserved สำหรับ deprecated fields
message User {
  reserved 2;  // field number 2 ห้ามใช้แล้ว
  reserved "old_name";  // field name ห้ามใช้แล้ว
  
  int32 id = 1;
  string full_name = 3;  // ใช้ field number ใหม่
}

3. Streaming ที่งงงวย

แรกๆ งงมากกับ streaming:

service ChatService {
  // Server streaming - server ส่งหลาย response
  rpc GetMessages (GetMessagesRequest) returns (stream Message);
  
  // Client streaming - client ส่งหลาย request  
  rpc SendMessages (stream Message) returns (SendMessagesResponse);
  
  // Bidirectional streaming - สองทางพร้อมกัน
  rpc Chat (stream Message) returns (stream Message);
}

Server streaming example:

// Server ส่งข้อความทีละข้อความ
getMessages(call) {
  const messages = getMessagesFromDB();
  
  messages.forEach(message => {
    call.write(message);
  });
  
  call.end();
}

// Client รับทีละข้อความ
const call = client.getMessages({ roomId: 123 });

call.on('data', (message) => {
  console.log('Received:', message);
});

call.on('end', () => {
  console.log('Stream ended');
});

Bidirectional streaming (Chat):

// Server
chat(call) {
  call.on('data', (message) => {
    // broadcast ไปให้ clients อื่น
    broadcastMessage(message);
    
    // ส่งกลับไป client ที่ส่งมา
    call.write({
      id: generateId(),
      text: `Echo: ${message.text}`,
      timestamp: Date.now()
    });
  });
}

// Client
const call = client.chat();

// ส่งข้อความ
call.write({ text: 'Hello from client' });

// รับข้อความ
call.on('data', (message) => {
  console.log('Received:', message.text);
});

Performance Gains ที่ได้จริง

หลังจาก migrate จาก REST เป็น gRPC:

Before (REST API):

Requests: 1000/sec
Average latency: 85ms
P95 latency: 150ms
CPU usage: 65%
Memory usage: 1.2GB

After (gRPC):

Requests: 1000/sec
Average latency: 12ms
P95 latency: 25ms
CPU usage: 35%
Memory usage: 800MB

สาเหตุ:

  1. Binary serialization เร็วกว่า JSON
  2. HTTP/2 multiplexing
  3. Connection reuse
  4. Smaller payload size

Debugging gRPC ที่ปวดหัว

1. ดูได้ยากกว่า REST

# REST - ง่าย
curl -X GET "http://api.example.com/users/123"

# gRPC - ต้องใช้เครื่องมือพิเศษ
grpcurl -plaintext localhost:50051 user.UserService/GetUser -d '{"id": 123}'

2. Browser ดูไม่ได้ตรงๆ

ต้องใช้ tools:

  • grpcurl - command line
  • BloomRPC/Kreya - GUI client
  • Postman - รองรับ gRPC แล้ว
  • gRPC-Web - สำหรับ browser

3. Load Balancing ที่ซับซ้อน

// Client-side load balancing
const client = new userProto.UserService(
  'dns:///user-service:50051', // DNS resolver
  grpc.credentials.createInsecure(),
  {
    'grpc.lb_policy_name': 'round_robin',
    'grpc.keepalive_time_ms': 30000,
    'grpc.keepalive_timeout_ms': 5000,
    'grpc.keepalive_permit_without_calls': true
  }
);

Advanced Features ที่เรียนรู้

1. Interceptors (Middleware)

// Server interceptor - สำหรับ authentication
const authInterceptor = (call, metadata, next) => {
  const token = metadata.get('authorization')[0];
  
  if (!verifyToken(token)) {
    return next({
      code: grpc.status.UNAUTHENTICATED,
      details: 'Invalid token'
    });
  }
  
  call.metadata.set('user_id', getUserIdFromToken(token));
  next();
};

server.use(authInterceptor);

// Client interceptor - สำหรับ retry logic
const retryInterceptor = (options, nextCall) => {
  return new grpc.InterceptingCall(nextCall(options), {
    start: function(metadata, listener, next) {
      let retryCount = 0;
      const maxRetries = 3;
      
      const retryListener = {
        onReceiveStatus: function(status, next) {
          if (status.code !== grpc.status.OK && retryCount < maxRetries) {
            retryCount++;
            // Exponential backoff
            setTimeout(() => {
              this.retry();
            }, Math.pow(2, retryCount) * 1000);
          } else {
            next(status);
          }
        }
      };
      
      next(metadata, retryListener);
    }
  });
};

2. Health Checking

// health.proto (standard gRPC health checking)
service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

message HealthCheckRequest {
  string service = 1;
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}
// Health check implementation
server.addService(healthProto.Health.service, {
  check: (call, callback) => {
    callback(null, {
      status: healthProto.HealthCheckResponse.ServingStatus.SERVING
    });
  },
  
  watch: (call) => {
    // Send periodic health updates
    setInterval(() => {
      call.write({
        status: healthProto.HealthCheckResponse.ServingStatus.SERVING
      });
    }, 5000);
  }
});

3. Reflection API

// Enable server reflection
const reflection = require('@grpc/reflection');
reflection.addReflectionToServer(server);

ได้อะไร:

  • Tools สามารถ discover services ได้อัตโนมัติ
  • ไม่ต้องแชร์ .proto files
  • grpcurl ใช้ได้โดยไม่ต้องระบุ proto

gRPC กับ REST: เมื่อไหร่ใช้อะไร

หลังจากใช้ gRPC มาสักพัก เรียนรู้ว่า ไม่ใช่ silver bullet

ใช้ gRPC เมื่อ:

  • Internal microservices ที่ติดต่อกันบ่อย
  • Performance critical applications
  • Strongly typed contracts ต้องการ
  • Streaming data เยอะ
  • Multiple languages ใน ecosystem

ใช้ REST เมื่อ:

  • Public APIs สำหรับ third party
  • Browser applications (ยังไงต้องใช้ gRPC-Web)
  • Simple CRUD operations
  • Caching ต้องการใช้ HTTP cache
  • Legacy systems integration

Production Lessons Learned

1. Connection Management

// ❌ สร้าง client ใหม่ทุกครั้ง (ช้า)
function callUserService(id) {
  const client = new userProto.UserService(
    'localhost:50051',
    grpc.credentials.createInsecure()
  );
  
  return new Promise((resolve, reject) => {
    client.getUser({ id }, (err, user) => {
      if (err) reject(err);
      else resolve(user);
    });
  });
}

// ✅ Reuse connection (เร็ว)
const userClient = new userProto.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

function callUserService(id) {
  return new Promise((resolve, reject) => {
    userClient.getUser({ id }, (err, user) => {
      if (err) reject(err);
      else resolve(user);
    });
  });
}

2. Error Handling ระหว่าง Services

// Service A เรียก Service B
async function getUserWithProfile(userId) {
  try {
    // Get user basic info
    const user = await userService.getUser({ id: userId });
    
    // Get user profile (อาจจะ fail)
    try {
      const profile = await profileService.getProfile({ userId });
      return { ...user, profile };
    } catch (profileError) {
      if (profileError.code === grpc.status.NOT_FOUND) {
        // Profile ไม่มีก็ไม่เป็นไร return user อย่างเดียว
        return user;
      }
      throw profileError; // Error อื่นๆ ให้ fail
    }
    
  } catch (userError) {
    // User ไม่มีถือว่า critical error
    throw userError;
  }
}

3. Monitoring & Observability

// Prometheus metrics
const promClient = require('prom-client');

const grpcRequestsTotal = new promClient.Counter({
  name: 'grpc_requests_total',
  help: 'Total gRPC requests',
  labelNames: ['method', 'status_code']
});

const grpcDuration = new promClient.Histogram({
  name: 'grpc_duration_seconds',
  help: 'gRPC request duration',
  labelNames: ['method']
});

// Interceptor สำหรับเก็บ metrics
const metricsInterceptor = (call, metadata, next) => {
  const start = Date.now();
  const method = call.methodName;
  
  next((err, result) => {
    const duration = (Date.now() - start) / 1000;
    const statusCode = err ? err.code : 'OK';
    
    grpcRequestsTotal.inc({ method, status_code: statusCode });
    grpcDuration.observe({ method }, duration);
  });
};

เครื่องมือที่ช่วยให้ชีวิต gRPC ง่ายขึ้น

1. Code Generation

# Generate JavaScript code จาก proto
protoc --js_out=import_style=commonjs:. \
       --grpc_out=grpc_js:. \
       user.proto

# หรือใช้ buf (modern tool)
buf generate

2. Testing

// Jest + gRPC testing
describe('UserService', () => {
  let server;
  let client;
  
  beforeAll(async () => {
    server = new grpc.Server();
    server.addService(userProto.UserService.service, userServiceImpl);
    
    const port = await new Promise((resolve) => {
      server.bindAsync('localhost:0', grpc.ServerCredentials.createInsecure(), (err, port) => {
        resolve(port);
      });
    });
    
    server.start();
    
    client = new userProto.UserService(
      `localhost:${port}`,
      grpc.credentials.createInsecure()
    );
  });
  
  afterAll(() => {
    server.tryShutdown(() => {});
  });
  
  test('should get user by id', async () => {
    const response = await new Promise((resolve, reject) => {
      client.getUser({ id: 1 }, (err, user) => {
        if (err) reject(err);
        else resolve(user);
      });
    });
    
    expect(response.id).toBe(1);
    expect(response.name).toBe('John Doe');
  });
});

3. API Documentation

# buf.gen.yaml - generate docs
version: v1
plugins:
  - plugin: buf.build/protocolbuffers/js
    out: gen/js
  - plugin: buf.build/grpc/grpc-web  
    out: gen/grpc-web
  - plugin: buf.build/bufbuild/connect-web
    out: gen/connect-web

สรุป: gRPC ที่รักแล้วเกลียด แล้วรักอีกครั้ง

ช่วงแรก (รัก): เห็น performance benefits เจ๋งๆ ช่วงกลาง (เกลียด): Debug ยาก, tooling ไม่ครบ, learning curve สูง
ตอนนี้ (รักอีกครั้ง): เข้าใจ trade-offs และรู้ว่าเมื่อไหร่ควรใช้

ข้อดีที่ได้จริง:

  • Performance ดีกว่า REST มาก (latency ลด 70%)
  • Type safety ลด bugs จาก API contract
  • Streaming ทำ real-time features ได้ง่าย
  • Multi-language support ดีเยี่ยม

ข้อเสียที่ต้องยอมรับ:

  • Learning curve สูงกว่า REST
  • Debugging ยากกว่า (แต่มี tools ช่วยแล้ว)
  • Browser support ต้องใช้ gRPC-Web
  • Caching ซับซ้อนกว่า HTTP caching

คำแนะนำ:

  1. เริ่มจาก internal services ก่อน
  2. เรียนรู้ Protocol Buffers ให้เข้าใจ
  3. ตั้ง monitoring ตั้งแต่แรก
  4. มี fallback plan ไว้ (circuit breaker)
  5. Test thoroughly โดยเฉพาะ error scenarios

gRPC มันเหมือน รถสปอร์ตคันแรง - powerful มาก แต่ต้องรู้วิธีขับ 🏎️

ถ้าใช้เป็น มันจะทำให้ระบบเร็วขึ้นมาก แต่ถ้าใช้ผิด อาจจะชนกำแพงได้ 😅