Skip to main content

公司場景 Pattern

Pattern 1: 設備資料串流

場景:Server Streaming 即時推送 PLC 感測器數據到監控端。比 REST polling 效率高一個數量級 — 不需要每次都建立連線、傳 HTTP Header、做 JSON 序列化。

伺服端實作

public class DeviceServiceImpl : DeviceService.DeviceServiceBase
{
private readonly IDeviceManager _deviceManager;
private readonly ILogger<DeviceServiceImpl> _logger;

public override async Task StreamSensorData(
SensorSubscription request,
IServerStreamWriter<SensorData> responseStream,
ServerCallContext context)
{
_logger.LogInformation(
"Sensor streaming started for {DeviceId}, interval={IntervalMs}ms",
request.DeviceId, request.IntervalMs);

var device = await _deviceManager.GetDeviceAsync(request.DeviceId);
var interval = TimeSpan.FromMilliseconds(request.IntervalMs);

try
{
while (!context.CancellationToken.IsCancellationRequested)
{
var reading = await device.ReadSensorsAsync();

await responseStream.WriteAsync(new SensorData
{
DeviceId = request.DeviceId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Temperature = reading.Temperature,
Pressure = reading.Pressure,
FlowRate = reading.FlowRate,
StatusCode = (int)reading.Status
});

await Task.Delay(interval, context.CancellationToken);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation(
"Sensor streaming stopped for {DeviceId} (client disconnected)",
request.DeviceId);
}
}
}

客戶端訂閱

using var channel = GrpcChannel.ForAddress("https://equipment-server:5001");
var client = new DeviceService.DeviceServiceClient(channel);

// 訂閱感測器數據(每 500ms 推送一次)
using var stream = client.StreamSensorData(new SensorSubscription
{
DeviceId = "CMP-01",
IntervalMs = 500
});

// 持續讀取推送資料
await foreach (var data in stream.ResponseStream.ReadAllAsync())
{
Console.WriteLine(
$"[{data.Timestamp}] {data.DeviceId}: " +
$"T={data.Temperature}°C, P={data.Pressure}kPa, " +
$"Flow={data.FlowRate}");

// 更新 UI 或寫入資料庫
await UpdateDashboardAsync(data);
}

Pattern 2: 設備控制命令

場景:Unary RPC 發送控制指令。強型別保證參數正確 — 不會因為 JSON 欄位名打錯而導致命令失敗。

public override async Task<CommandResult> SendCommand(
DeviceCommand request, ServerCallContext context)
{
_logger.LogInformation(
"Command received: {Command} for {DeviceId} with params {@Parameters}",
request.Command, request.DeviceId, request.Parameters);

try
{
var device = await _deviceManager.GetDeviceAsync(request.DeviceId);

switch (request.Command)
{
case "Start":
await device.StartAsync();
break;

case "Stop":
await device.StopAsync();
break;

case "SetTemperature":
var target = double.Parse(request.Parameters["target"]);
await device.SetTargetTemperatureAsync(target);
break;

case "LoadRecipe":
var recipeId = request.Parameters["recipeId"];
await device.LoadRecipeAsync(recipeId);
break;

default:
return new CommandResult
{
Success = false,
Message = $"Unknown command: {request.Command}"
};
}

return new CommandResult
{
Success = true,
Message = $"{request.Command} executed successfully"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Command {Command} failed for {DeviceId}",
request.Command, request.DeviceId);

return new CommandResult
{
Success = false,
Message = ex.Message
};
}
}

客戶端呼叫

var result = await client.SendCommandAsync(new DeviceCommand
{
DeviceId = "CMP-01",
Command = "SetTemperature",
Parameters = { { "target", "80.5" } }
});

if (!result.Success)
{
_logger.LogError("Command failed: {Message}", result.Message);
}

Pattern 3: 多設備雙向通訊

場景:Bidirectional Streaming 同時收發多台設備的命令和事件回報。

// 伺服端
public override async Task ControlChannel(
IAsyncStreamReader<DeviceCommand> requestStream,
IServerStreamWriter<DeviceEvent> responseStream,
ServerCallContext context)
{
// 背景任務:推送設備事件
var eventPushTask = Task.Run(async () =>
{
await foreach (var deviceEvent in _eventBus.ReadAllAsync(context.CancellationToken))
{
await responseStream.WriteAsync(new DeviceEvent
{
DeviceId = deviceEvent.DeviceId,
EventType = deviceEvent.Type.ToString(),
Payload = deviceEvent.Payload,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
});

// 主迴圈:處理客戶端命令
await foreach (var command in requestStream.ReadAllAsync())
{
_logger.LogInformation(
"Received command: {Command} for {DeviceId}",
command.Command, command.DeviceId);

await _commandProcessor.ProcessAsync(command);

// 回送確認事件
await responseStream.WriteAsync(new DeviceEvent
{
DeviceId = command.DeviceId,
EventType = "CommandAck",
Payload = $"Command '{command.Command}' acknowledged",
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}

await eventPushTask;
}

Pattern 4: 微服務間通訊

場景:MES 整合層和設備控制層之間用 gRPC 取代 REST,獲得更好的效能和型別安全。

┌──────────────┐     gRPC      ┌──────────────────┐    Modbus    ┌──────┐
│ MES 整合層 │ ───────────→ │ 設備控制層 │ ──────────→ │ PLC │
│ (Web API) │ ←─────────── │ (gRPC Server) │ ←────────── │ │
└──────────────┘ └──────────────────┘ └──────┘
↑ ↑
REST API Proto 定義
(給外部 MES) (內部強型別通訊)
// MES 整合層:作為 gRPC 客戶端呼叫設備控制層
public class EquipmentProxy : IEquipmentService
{
private readonly DeviceService.DeviceServiceClient _grpcClient;

public EquipmentProxy(DeviceService.DeviceServiceClient grpcClient)
{
_grpcClient = grpcClient;
}

public async Task<EquipmentStatus> GetStatusAsync(string deviceId)
{
var grpcStatus = await _grpcClient.GetStatusAsync(
new DeviceRequest { DeviceId = deviceId });

return new EquipmentStatus
{
DeviceId = grpcStatus.DeviceId,
IsConnected = grpcStatus.IsConnected,
Temperature = grpcStatus.Temperature,
Pressure = grpcStatus.Pressure
};
}
}

// DI 註冊 gRPC 客戶端
services.AddGrpcClient<DeviceService.DeviceServiceClient>(options =>
{
options.Address = new Uri("https://equipment-controller:5001");
});

Pattern 5: 錯誤處理(Status Code 決策)

gRPC 的錯誤以 RpcException + StatusCode 表達。不同 Status Code 有不同的處理策略——盲目重試 InvalidArgument 會浪費資源,沒重試 Unavailable 則錯失短暫故障的恢復機會。下圖是常見 Status Code 的處理決策樹:

決策原則:

  • 可重試類UnavailableDeadlineExceededInternal):交給 Polly 重試 + 指數退避 + Circuit Breaker;參考 framework/polly/overview
  • 不可重試類InvalidArgumentPermissionDeniedUnauthenticatedNotFound):重試只會浪費 quota;應 fail-fast 並由呼叫端處理
  • 特殊處理Unauthenticated 通常代表 Token 過期,先 refresh token 再重發一次(不算進 Polly retry 預算)
  • Cancelled 不是錯誤:通常是上游 CancellationToken 觸發;當作正常結束、不要 log 為 Error

對應的 client 程式碼骨架:

try
{
var status = await client.GetStatusAsync(request, deadline: DateTime.UtcNow.AddSeconds(5));
return status;
}
catch (RpcException ex) when (ex.StatusCode is StatusCode.Unavailable
or StatusCode.DeadlineExceeded
or StatusCode.Internal)
{
// 交給 Polly 重試 / Circuit Breaker
throw;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unauthenticated)
{
await RefreshTokenAsync();
return await client.GetStatusAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
_logger.LogDebug("gRPC call cancelled by caller");
return null;
}

效能比較

以下是傳輸 1000 筆感測器數據的概略比較(實際數字依環境不同):

指標gRPC (Protobuf)REST (JSON)差距
序列化大小~12 KB~45 KB3.7x 小
序列化時間~2 ms~15 ms7.5x 快
網路延遲~5 ms~20 ms4x 快
總吞吐量~50,000 msg/s~8,000 msg/s6x 高
CPU 使用率顯著差異
什麼時候 gRPC 的優勢才明顯

如果你的服務間只是偶爾互相呼叫(每秒幾十次),REST 和 gRPC 的差異可以忽略。gRPC 的優勢在高頻率、大量資料的場景才會顯現 — 例如每秒上千筆感測器數據的串流推送。


安全性

TLS 加密

// 伺服端
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
https.ServerCertificate = X509Certificate2.CreateFromPemFile(
"certs/server.crt", "certs/server.key");
});
});

Token Authentication

// 客戶端:附加認證 Token
var headers = new Metadata
{
{ "Authorization", $"Bearer {accessToken}" }
};

var status = await client.GetStatusAsync(
new DeviceRequest { DeviceId = "CMP-01" },
headers: headers);
// 伺服端:驗證 Token
public override async Task<DeviceStatus> GetStatus(
DeviceRequest request, ServerCallContext context)
{
var token = context.RequestHeaders
.FirstOrDefault(h => h.Key == "authorization")?.Value;

if (!await _authService.ValidateTokenAsync(token))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid token"));
}

// ... 處理請求
}

限制與注意事項

限制影響對策
瀏覽器不原生支援Web 前端無法直接呼叫 gRPC使用 gRPC-Web 或提供 REST 轉發層
需要 HTTP/2部分 Proxy/Load Balancer 不支援確認基礎設施支援 HTTP/2
不適合人類可讀Protobuf 是二進制,除錯不如 JSON 直覺搭配 gRPC reflection + grpcurl 工具
.proto 管理多服務共用 proto 需要版本管理建立共用 proto NuGet 套件

延伸閱讀