研究 3:gRPC サービスの実装¶
はじめに¶
本パートでは、API サーバー構成とは異なるアプローチとして、gRPC による財務会計システムを実装します。Protocol Buffers による高効率なバイナリシリアライゼーションと、HTTP/2 によるストリーミング通信を活用し、高性能なマイクロサービス間通信を実現します。
ヘキサゴナルアーキテクチャ(ドメイン層・アプリケーション層)はそのまま共有し、Input Adapter として gRPC サービス層のみを追加します。
第 22 章:gRPC サーバーの基礎¶
22.1 gRPC とは¶
gRPC は Google が開発したオープンソースの高性能 RPC(Remote Procedure Call)フレームワークです。HTTP/2 プロトコルと Protocol Buffers を基盤とし、マイクロサービス間通信に最適化されています。
gRPC の主な特徴:
| 特徴 | 説明 |
|---|---|
| HTTP/2 | 多重化、ヘッダー圧縮、バイナリフレーミング |
| Protocol Buffers | 高速なバイナリシリアライゼーション |
| 多言語サポート | Java, Go, Python, C#, Node.js 等 |
| ストリーミング | 4 つの RPC パターンをサポート |
| デッドライン | タイムアウト管理の組み込みサポート |
| 認証 | TLS/SSL、トークンベース認証のサポート |
22.2 REST API / GraphQL との比較¶
詳細比較表:
| 特徴 | REST API | gRPC | GraphQL |
|---|---|---|---|
| プロトコル | HTTP/1.1 | HTTP/2 | HTTP/1.1 or HTTP/2 |
| データ形式 | JSON | Protocol Buffers | JSON |
| スキーマ | OpenAPI (任意) | .proto (必須) | .graphqls (必須) |
| シリアライゼーション | テキスト | バイナリ | テキスト |
| パフォーマンス | 中 | 高 | 中 |
| ストリーミング | WebSocket 別実装 | ネイティブサポート | Subscription |
| ブラウザ対応 | ◎ | △ (gRPC-Web) | ◎ |
| 学習コスト | 低 | 中 | 中 |
| 主な用途 | 汎用 API | マイクロサービス | フロントエンド向け |
財務会計システムで gRPC を選択する場面:
- 基幹システム間連携: 販売管理・購買管理との高性能な内部通信
- リアルタイム残高更新: ストリーミングによる即時反映通知
- 一括仕訳処理: クライアントストリーミングによる大量データ転送
- 月次締め処理: 長時間処理の進捗通知
22.3 4 つの RPC パターン¶
gRPC は 4 つの RPC パターンをサポートします。
各パターンの用途(財務会計システム):
| パターン | 用途例 |
|---|---|
| 単項 RPC | 勘定科目取得、仕訳登録 |
| サーバーストリーミング | 仕訳一覧取得、残高照会 |
| クライアントストリーミング | 仕訳明細の一括登録 |
| 双方向ストリーミング | リアルタイム残高同期 |
22.4 gRPC におけるヘキサゴナルアーキテクチャ¶
gRPC を導入しても、ヘキサゴナルアーキテクチャ(ドメイン層・アプリケーション層)はそのまま共有し、Input Adapter として gRPC サービス層のみを追加します。
22.5 ディレクトリ構成¶
既存の構成に infrastructure/in/grpc/ を追加するだけです。
src/main/java/com/example/accounting/
├── domain/ # ドメイン層(API版と共通)
│ ├── model/
│ │ ├── account/ # 勘定科目ドメイン
│ │ ├── journal/ # 仕訳ドメイン
│ │ ├── balance/ # 残高ドメイン
│ │ └── tax/ # 課税取引ドメイン
│ └── exception/
│
├── application/ # アプリケーション層(API版と共通)
│ ├── port/
│ │ ├── in/ # Input Port(ユースケース)
│ │ └── out/ # Output Port(リポジトリ)
│ └── service/
│
├── infrastructure/
│ ├── in/
│ │ ├── api/ # Input Adapter(REST実装)- 既存
│ │ └── grpc/ # Input Adapter(gRPC実装)- 新規追加
│ │ ├── service/ # gRPC サービス実装
│ │ ├── interceptor/ # インターセプター
│ │ └── converter/ # ドメイン ↔ Proto 変換
│ └── out/
│ └── persistence/ # Output Adapter(DB実装)- 既存
│ ├── mapper/
│ └── repository/
│
├── config/
│
└── src/main/proto/ # Protocol Buffers 定義
├── common.proto
├── account.proto
├── journal.proto
├── balance.proto
└── report.proto
22.6 技術スタックの追加¶
既存の build.gradle.kts に gRPC 関連の依存関係を追加します。
build.gradle.kts(差分)
import com.google.protobuf.gradle.*
plugins {
// 既存のプラグイン
id("java")
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
// gRPC 関連を追加
id("com.google.protobuf") version "0.9.4"
}
dependencies {
// 既存の依存関係(Spring Boot, MyBatis, PostgreSQL等)はそのまま
// gRPC 関連を追加
implementation("net.devh:grpc-spring-boot-starter:3.1.0.RELEASE")
implementation("io.grpc:grpc-protobuf:1.62.2")
implementation("io.grpc:grpc-stub:1.62.2")
implementation("io.grpc:grpc-services:1.62.2") // ヘルスチェック、リフレクション
// Protocol Buffers
implementation("com.google.protobuf:protobuf-java:3.25.3")
implementation("com.google.protobuf:protobuf-java-util:3.25.3") // JSON 変換
// Jakarta Annotation (gRPC generated code で必要)
compileOnly("jakarta.annotation:jakarta.annotation-api:2.1.1")
// Test
testImplementation("io.grpc:grpc-testing:1.62.2")
testImplementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.3"
}
plugins {
create("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.62.2"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
create("grpc")
}
}
}
}
// 生成コードのソースパス追加
sourceSets {
main {
java {
srcDirs(
"build/generated/source/proto/main/java",
"build/generated/source/proto/main/grpc"
)
}
}
}
application.yml(差分)
# 既存の設定はそのまま
# gRPC サーバー設定
grpc:
server:
port: 9090
reflection-service-enabled: true # gRPC リフレクション有効化
health-service-enabled: true # ヘルスチェック有効化
max-inbound-message-size: 4194304 # 4MB
max-inbound-metadata-size: 8192 # 8KB
# ログ設定
logging:
level:
net.devh.boot.grpc: DEBUG
io.grpc: INFO
第 23 章:Protocol Buffers 定義¶
23.1 共通メッセージ定義¶
src/main/proto/common.proto
syntax = "proto3";
package com.example.accounting;
option java_package = "com.example.accounting.infrastructure.in.grpc.proto";
option java_outer_classname = "CommonProto";
option java_multiple_files = true;
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
// 共通のページネーション
message PageRequest {
int32 page = 1;
int32 size = 2;
}
message PageInfo {
int32 page = 1;
int32 size = 2;
int32 total_elements = 3;
int32 total_pages = 4;
bool has_next = 5;
bool has_previous = 6;
}
// 共通のエラー応答
message ErrorDetail {
string field = 1;
string message = 2;
}
message ErrorResponse {
string code = 1;
string message = 2;
repeated ErrorDetail details = 3;
}
// 金額型(Decimal 相当)
message Money {
int64 units = 1; // 整数部
int32 nanos = 2; // 小数部(10億分の1単位)
string currency = 3; // 通貨コード(JPY等)
}
// 日付型(Date 相当)
message Date {
int32 year = 1;
int32 month = 2;
int32 day = 3;
}
// 年月型
message YearMonth {
int32 year = 1;
int32 month = 2;
}
// 決算期
message FiscalPeriod {
int32 fiscal_year = 1; // 決算年度
int32 fiscal_month = 2; // 決算月度(1-12)
}
// 監査情報
message AuditInfo {
google.protobuf.Timestamp created_at = 1;
string created_by = 2;
google.protobuf.Timestamp updated_at = 3;
string updated_by = 4;
}
23.2 勘定科目 Protocol Buffers¶
src/main/proto/account.proto
syntax = "proto3";
package com.example.accounting;
option java_package = "com.example.accounting.infrastructure.in.grpc.proto";
option java_outer_classname = "AccountProto";
option java_multiple_files = true;
import "common.proto";
import "google/protobuf/empty.proto";
// BSPL区分
enum BsplType {
BSPL_TYPE_UNSPECIFIED = 0;
BSPL_TYPE_BS = 1; // 貸借対照表
BSPL_TYPE_PL = 2; // 損益計算書
}
// 貸借区分
enum DebitCreditType {
DEBIT_CREDIT_TYPE_UNSPECIFIED = 0;
DEBIT_CREDIT_TYPE_DEBIT = 1; // 借方
DEBIT_CREDIT_TYPE_CREDIT = 2; // 貸方
}
// 取引要素区分
enum ElementType {
ELEMENT_TYPE_UNSPECIFIED = 0;
ELEMENT_TYPE_ASSET = 1; // 資産
ELEMENT_TYPE_LIABILITY = 2; // 負債
ELEMENT_TYPE_EQUITY = 3; // 資本
ELEMENT_TYPE_REVENUE = 4; // 収益
ELEMENT_TYPE_EXPENSE = 5; // 費用
}
// 集計区分
enum AggregationType {
AGGREGATION_TYPE_UNSPECIFIED = 0;
AGGREGATION_TYPE_HEADING = 1; // 見出科目
AGGREGATION_TYPE_SUMMARY = 2; // 集計科目
AGGREGATION_TYPE_POSTING = 3; // 計上科目
}
// 勘定科目メッセージ
message Account {
string account_code = 1; // 勘定科目コード
string account_name = 2; // 勘定科目名
BsplType bspl_type = 3; // BSPL区分
DebitCreditType debit_credit_type = 4; // 貸借区分
ElementType element_type = 5; // 取引要素区分
AggregationType aggregation_type = 6; // 集計区分
string parent_account_code = 7; // 親勘定科目コード
string account_path = 8; // 勘定科目パス
int32 display_order = 9; // 表示順
bool is_active = 10; // 有効フラグ
AuditInfo audit = 11; // 監査情報
}
// 勘定科目構成メッセージ
message AccountStructure {
string account_code = 1; // 勘定科目コード
string account_path = 2; // 勘定科目パス(~区切り)
int32 level = 3; // 階層レベル
}
// 課税取引マスタ
message TaxTransaction {
string tax_code = 1; // 課税取引コード
string tax_name = 2; // 課税取引名
int32 tax_rate = 3; // 税率(パーセント * 100)
Date effective_from = 4; // 適用開始日
Date effective_to = 5; // 適用終了日
bool is_active = 6; // 有効フラグ
}
// === リクエスト/レスポンス ===
// 勘定科目取得
message GetAccountRequest {
string account_code = 1;
}
message GetAccountResponse {
Account account = 1;
}
// 勘定科目一覧取得
message ListAccountsRequest {
PageRequest page = 1;
BsplType bspl_type = 2; // フィルタ(オプション)
ElementType element_type = 3; // フィルタ(オプション)
AggregationType aggregation_type = 4; // フィルタ(オプション)
string keyword = 5; // 検索キーワード
bool active_only = 6; // 有効のみ
}
message ListAccountsResponse {
repeated Account accounts = 1;
PageInfo page_info = 2;
}
// 勘定科目登録
message CreateAccountRequest {
string account_code = 1;
string account_name = 2;
BsplType bspl_type = 3;
DebitCreditType debit_credit_type = 4;
ElementType element_type = 5;
AggregationType aggregation_type = 6;
string parent_account_code = 7;
int32 display_order = 8;
}
message CreateAccountResponse {
Account account = 1;
}
// 勘定科目更新
message UpdateAccountRequest {
string account_code = 1;
string account_name = 2;
BsplType bspl_type = 3;
DebitCreditType debit_credit_type = 4;
ElementType element_type = 5;
AggregationType aggregation_type = 6;
string parent_account_code = 7;
int32 display_order = 8;
bool is_active = 9;
}
message UpdateAccountResponse {
Account account = 1;
}
// 勘定科目削除
message DeleteAccountRequest {
string account_code = 1;
}
message DeleteAccountResponse {
bool success = 1;
}
// 勘定科目一括登録(クライアントストリーミング)
message BulkCreateAccountRequest {
CreateAccountRequest account = 1;
}
message BulkCreateAccountResponse {
int32 success_count = 1;
int32 failure_count = 2;
repeated ErrorDetail errors = 3;
}
// 勘定科目ツリー取得
message GetAccountTreeRequest {
BsplType bspl_type = 1; // BS または PL
}
message GetAccountTreeResponse {
repeated AccountNode nodes = 1;
}
message AccountNode {
Account account = 1;
repeated AccountNode children = 2;
}
// 課税取引マスタ取得
message GetTaxTransactionRequest {
string tax_code = 1;
}
message GetTaxTransactionResponse {
TaxTransaction tax_transaction = 1;
}
// 課税取引マスタ一覧
message ListTaxTransactionsRequest {
Date as_of_date = 1; // 適用日(オプション)
bool active_only = 2;
}
message ListTaxTransactionsResponse {
repeated TaxTransaction tax_transactions = 1;
}
// === サービス定義 ===
service AccountService {
// 単項 RPC
rpc GetAccount(GetAccountRequest) returns (GetAccountResponse);
rpc CreateAccount(CreateAccountRequest) returns (CreateAccountResponse);
rpc UpdateAccount(UpdateAccountRequest) returns (UpdateAccountResponse);
rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse);
// サーバーストリーミング RPC(大量データ取得)
rpc ListAccounts(ListAccountsRequest) returns (stream Account);
// クライアントストリーミング RPC(一括登録)
rpc BulkCreateAccounts(stream BulkCreateAccountRequest) returns (BulkCreateAccountResponse);
// 勘定科目構成
rpc GetAccountTree(GetAccountTreeRequest) returns (GetAccountTreeResponse);
// 課税取引マスタ
rpc GetTaxTransaction(GetTaxTransactionRequest) returns (GetTaxTransactionResponse);
rpc ListTaxTransactions(ListTaxTransactionsRequest) returns (ListTaxTransactionsResponse);
}
23.3 仕訳 Protocol Buffers¶
src/main/proto/journal.proto
syntax = "proto3";
package com.example.accounting;
option java_package = "com.example.accounting.infrastructure.in.grpc.proto";
option java_outer_classname = "JournalProto";
option java_multiple_files = true;
import "common.proto";
import "account.proto";
import "google/protobuf/empty.proto";
// 仕訳伝票区分
enum JournalType {
JOURNAL_TYPE_UNSPECIFIED = 0;
JOURNAL_TYPE_NORMAL = 1; // 通常仕訳
JOURNAL_TYPE_CLOSING = 2; // 決算仕訳
JOURNAL_TYPE_ADJUSTMENT = 3; // 調整仕訳
JOURNAL_TYPE_REVERSAL = 4; // 取消仕訳
}
// 仕訳ステータス
enum JournalStatus {
JOURNAL_STATUS_UNSPECIFIED = 0;
JOURNAL_STATUS_DRAFT = 1; // 下書き
JOURNAL_STATUS_PENDING = 2; // 承認待ち
JOURNAL_STATUS_APPROVED = 3; // 承認済み
JOURNAL_STATUS_POSTED = 4; // 転記済み
JOURNAL_STATUS_CANCELED = 5; // 取消済み
}
// 仕訳伝票メッセージ
message Journal {
string slip_number = 1; // 伝票番号
Date journal_date = 2; // 起票日
Date input_date = 3; // 入力日
string summary = 4; // 伝票摘要
JournalType journal_type = 5; // 仕訳伝票区分
JournalStatus status = 6; // ステータス
bool is_closing_journal = 7; // 決算仕訳フラグ
FiscalPeriod fiscal_period = 8; // 決算期
Money total_debit = 9; // 借方合計
Money total_credit = 10; // 貸方合計
repeated JournalDetail details = 11; // 仕訳明細
string reversal_slip_number = 12; // 取消元伝票番号
AuditInfo audit = 13; // 監査情報
}
// 仕訳明細メッセージ
message JournalDetail {
int32 line_number = 1; // 行番号
string line_summary = 2; // 行摘要
repeated JournalDebitCreditDetail debit_credit_details = 3; // 貸借明細
}
// 仕訳貸借明細メッセージ
message JournalDebitCreditDetail {
DebitCreditType debit_credit_type = 1; // 借方/貸方
string account_code = 2; // 勘定科目コード
string account_name = 3; // 勘定科目名(参照用)
string sub_account_code = 4; // 補助科目コード
string department_code = 5; // 部門コード
string project_code = 6; // プロジェクトコード
Money amount = 7; // 金額
Money base_currency_amount = 8; // 基軸通貨金額
string tax_code = 9; // 課税取引コード
int32 tax_rate = 10; // 税率
Money tax_amount = 11; // 消費税額
}
// === リクエスト/レスポンス ===
// 仕訳取得
message GetJournalRequest {
string slip_number = 1;
}
message GetJournalResponse {
Journal journal = 1;
}
// 仕訳一覧取得
message ListJournalsRequest {
PageRequest page = 1;
Date date_from = 2; // 起票日From
Date date_to = 3; // 起票日To
FiscalPeriod fiscal_period = 4; // 決算期
JournalType journal_type = 5; // 仕訳伝票区分
JournalStatus status = 6; // ステータス
string account_code = 7; // 勘定科目コード
string keyword = 8; // 検索キーワード
}
message ListJournalsResponse {
repeated Journal journals = 1;
PageInfo page_info = 2;
}
// 仕訳登録
message CreateJournalRequest {
Date journal_date = 1;
string summary = 2;
JournalType journal_type = 3;
bool is_closing_journal = 4;
repeated CreateJournalDetailRequest details = 5;
}
message CreateJournalDetailRequest {
int32 line_number = 1;
string line_summary = 2;
repeated CreateJournalDebitCreditRequest debit_credit_details = 3;
}
message CreateJournalDebitCreditRequest {
DebitCreditType debit_credit_type = 1;
string account_code = 2;
string sub_account_code = 3;
string department_code = 4;
string project_code = 5;
Money amount = 6;
string tax_code = 7;
}
message CreateJournalResponse {
Journal journal = 1;
}
// 仕訳更新
message UpdateJournalRequest {
string slip_number = 1;
Date journal_date = 2;
string summary = 3;
JournalType journal_type = 4;
bool is_closing_journal = 5;
repeated CreateJournalDetailRequest details = 6;
}
message UpdateJournalResponse {
Journal journal = 1;
}
// 仕訳取消(赤黒処理)
message CancelJournalRequest {
string slip_number = 1;
string cancel_reason = 2;
}
message CancelJournalResponse {
Journal original_journal = 1; // 元の仕訳
Journal reversal_journal = 2; // 取消仕訳
}
// 仕訳承認
message ApproveJournalRequest {
string slip_number = 1;
string approver_comment = 2;
}
message ApproveJournalResponse {
Journal journal = 1;
}
// 仕訳一括登録(クライアントストリーミング)
message BulkCreateJournalRequest {
CreateJournalRequest journal = 1;
}
message BulkCreateJournalResponse {
int32 success_count = 1;
int32 failure_count = 2;
repeated ErrorDetail errors = 3;
repeated string created_slip_numbers = 4;
}
// 貸借バランスチェック
message ValidateBalanceRequest {
repeated CreateJournalDetailRequest details = 1;
}
message ValidateBalanceResponse {
bool is_balanced = 1;
Money total_debit = 2;
Money total_credit = 3;
Money difference = 4;
}
// === サービス定義 ===
service JournalService {
// 単項 RPC
rpc GetJournal(GetJournalRequest) returns (GetJournalResponse);
rpc CreateJournal(CreateJournalRequest) returns (CreateJournalResponse);
rpc UpdateJournal(UpdateJournalRequest) returns (UpdateJournalResponse);
rpc CancelJournal(CancelJournalRequest) returns (CancelJournalResponse);
rpc ApproveJournal(ApproveJournalRequest) returns (ApproveJournalResponse);
rpc ValidateBalance(ValidateBalanceRequest) returns (ValidateBalanceResponse);
// サーバーストリーミング RPC
rpc ListJournals(ListJournalsRequest) returns (stream Journal);
// クライアントストリーミング RPC
rpc BulkCreateJournals(stream BulkCreateJournalRequest) returns (BulkCreateJournalResponse);
// 双方向ストリーミング RPC(リアルタイム仕訳入力)
rpc StreamJournalEntry(stream CreateJournalRequest) returns (stream CreateJournalResponse);
}
23.4 残高 Protocol Buffers¶
src/main/proto/balance.proto
syntax = "proto3";
package com.example.accounting;
option java_package = "com.example.accounting.infrastructure.in.grpc.proto";
option java_outer_classname = "BalanceProto";
option java_multiple_files = true;
import "common.proto";
import "account.proto";
// 日次勘定科目残高
message DailyBalance {
Date balance_date = 1; // 残高日
string account_code = 2; // 勘定科目コード
string account_name = 3; // 勘定科目名
string sub_account_code = 4; // 補助科目コード
string department_code = 5; // 部門コード
Money previous_balance = 6; // 前日残高
Money debit_amount = 7; // 借方金額
Money credit_amount = 8; // 貸方金額
Money current_balance = 9; // 当日残高
}
// 月次勘定科目残高
message MonthlyBalance {
FiscalPeriod fiscal_period = 1; // 決算期
string account_code = 2; // 勘定科目コード
string account_name = 3; // 勘定科目名
string sub_account_code = 4; // 補助科目コード
string department_code = 5; // 部門コード
Money opening_balance = 6; // 月初残高
Money debit_amount = 7; // 借方金額
Money credit_amount = 8; // 貸方金額
Money closing_balance = 9; // 月末残高
Money closing_debit_balance = 10; // 決算借方金額
Money closing_credit_balance = 11; // 決算貸方金額
}
// 残高サマリー
message BalanceSummary {
string account_code = 1;
string account_name = 2;
BsplType bspl_type = 3;
ElementType element_type = 4;
Money balance = 5;
int32 level = 6;
bool is_leaf = 7;
}
// === リクエスト/レスポンス ===
// 日次残高取得
message GetDailyBalanceRequest {
Date balance_date = 1;
string account_code = 2;
string sub_account_code = 3;
string department_code = 4;
}
message GetDailyBalanceResponse {
DailyBalance balance = 1;
}
// 日次残高一覧取得
message ListDailyBalancesRequest {
Date balance_date = 1;
BsplType bspl_type = 2;
ElementType element_type = 3;
string department_code = 4;
bool posting_accounts_only = 5; // 計上科目のみ
}
message ListDailyBalancesResponse {
repeated DailyBalance balances = 1;
Money total_debit = 2;
Money total_credit = 3;
}
// 月次残高取得
message GetMonthlyBalanceRequest {
FiscalPeriod fiscal_period = 1;
string account_code = 2;
string sub_account_code = 3;
string department_code = 4;
}
message GetMonthlyBalanceResponse {
MonthlyBalance balance = 1;
}
// 月次残高一覧取得
message ListMonthlyBalancesRequest {
FiscalPeriod fiscal_period = 1;
BsplType bspl_type = 2;
ElementType element_type = 3;
string department_code = 4;
bool include_closing = 5; // 決算仕訳含む
}
message ListMonthlyBalancesResponse {
repeated MonthlyBalance balances = 1;
Money total_debit = 2;
Money total_credit = 3;
}
// 勘定科目別残高照会
message GetAccountBalanceRequest {
string account_code = 1;
Date date_from = 2;
Date date_to = 3;
string sub_account_code = 4;
string department_code = 5;
}
message GetAccountBalanceResponse {
string account_code = 1;
string account_name = 2;
Money opening_balance = 3; // 期首残高
Money total_debit = 4; // 期間借方合計
Money total_credit = 5; // 期間貸方合計
Money closing_balance = 6; // 期末残高
repeated DailyBalance daily_movements = 7; // 日別推移
}
// 残高サマリー取得(試算表用)
message GetBalanceSummaryRequest {
Date as_of_date = 1;
BsplType bspl_type = 2;
bool include_sub_accounts = 3;
}
message GetBalanceSummaryResponse {
repeated BalanceSummary summaries = 1;
Money bs_total_debit = 2; // BS借方合計
Money bs_total_credit = 3; // BS貸方合計
Money pl_total_debit = 4; // PL借方合計
Money pl_total_credit = 5; // PL貸方合計
}
// 残高更新(仕訳転記時)
message UpdateBalanceRequest {
string slip_number = 1;
}
message UpdateBalanceResponse {
bool success = 1;
int32 updated_daily_records = 2;
int32 updated_monthly_records = 3;
}
// === サービス定義 ===
service BalanceService {
// 単項 RPC
rpc GetDailyBalance(GetDailyBalanceRequest) returns (GetDailyBalanceResponse);
rpc GetMonthlyBalance(GetMonthlyBalanceRequest) returns (GetMonthlyBalanceResponse);
rpc GetAccountBalance(GetAccountBalanceRequest) returns (GetAccountBalanceResponse);
rpc GetBalanceSummary(GetBalanceSummaryRequest) returns (GetBalanceSummaryResponse);
rpc UpdateBalance(UpdateBalanceRequest) returns (UpdateBalanceResponse);
// サーバーストリーミング RPC
rpc ListDailyBalances(ListDailyBalancesRequest) returns (stream DailyBalance);
rpc ListMonthlyBalances(ListMonthlyBalancesRequest) returns (stream MonthlyBalance);
// 双方向ストリーミング(リアルタイム残高監視)
rpc WatchBalance(stream GetAccountBalanceRequest) returns (stream GetAccountBalanceResponse);
}
第 24 章:gRPC サービス実装¶
24.1 勘定科目サービス実装¶
GrpcAccountService.java
package com.example.accounting.infrastructure.in.grpc.service;
import com.example.accounting.application.port.in.AccountUseCase;
import com.example.accounting.domain.model.account.Account;
import com.example.accounting.domain.model.account.AccountCode;
import com.example.accounting.domain.exception.ResourceNotFoundException;
import com.example.accounting.infrastructure.in.grpc.converter.AccountConverter;
import com.example.accounting.infrastructure.in.grpc.proto.*;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.server.service.GrpcService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 勘定科目 gRPC サービス実装
*/
@GrpcService
public class GrpcAccountService extends AccountServiceGrpc.AccountServiceImplBase {
private static final Logger log = LoggerFactory.getLogger(GrpcAccountService.class);
private final AccountUseCase accountUseCase;
private final AccountConverter converter;
public GrpcAccountService(AccountUseCase accountUseCase, AccountConverter converter) {
this.accountUseCase = accountUseCase;
this.converter = converter;
}
/**
* 単項 RPC: 勘定科目取得
*/
@Override
public void getAccount(GetAccountRequest request,
StreamObserver<GetAccountResponse> responseObserver) {
log.info("getAccount: {}", request.getAccountCode());
try {
AccountCode code = new AccountCode(request.getAccountCode());
Account account = accountUseCase.findByCode(code)
.orElseThrow(() -> new ResourceNotFoundException(
"勘定科目", request.getAccountCode()));
GetAccountResponse response = GetAccountResponse.newBuilder()
.setAccount(converter.toProto(account))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (ResourceNotFoundException e) {
log.warn("Account not found: {}", request.getAccountCode());
responseObserver.onError(
Status.NOT_FOUND
.withDescription("勘定科目が見つかりません: " + request.getAccountCode())
.asRuntimeException()
);
} catch (Exception e) {
log.error("Error getting account", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.withCause(e)
.asRuntimeException()
);
}
}
/**
* 単項 RPC: 勘定科目登録
*/
@Override
public void createAccount(CreateAccountRequest request,
StreamObserver<CreateAccountResponse> responseObserver) {
log.info("createAccount: {}", request.getAccountCode());
try {
Account account = converter.toDomain(request);
Account created = accountUseCase.create(account);
CreateAccountResponse response = CreateAccountResponse.newBuilder()
.setAccount(converter.toProto(created))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (IllegalArgumentException e) {
log.warn("Invalid request: {}", e.getMessage());
responseObserver.onError(
Status.INVALID_ARGUMENT
.withDescription(e.getMessage())
.asRuntimeException()
);
} catch (Exception e) {
log.error("Error creating account", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* サーバーストリーミング RPC: 勘定科目一覧取得
*/
@Override
public void listAccounts(ListAccountsRequest request,
StreamObserver<com.example.accounting.infrastructure.in.grpc.proto.Account> responseObserver) {
log.info("listAccounts: bsplType={}, keyword={}",
request.getBsplType(), request.getKeyword());
try {
List<Account> accounts = accountUseCase.findAll(
converter.toCriteria(request)
);
// ストリーミングで勘定科目を1件ずつ送信
for (Account account : accounts) {
responseObserver.onNext(converter.toProto(account));
}
responseObserver.onCompleted();
log.info("listAccounts completed: {} items", accounts.size());
} catch (Exception e) {
log.error("Error listing accounts", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* クライアントストリーミング RPC: 勘定科目一括登録
*/
@Override
public StreamObserver<BulkCreateAccountRequest> bulkCreateAccounts(
StreamObserver<BulkCreateAccountResponse> responseObserver) {
log.info("bulkCreateAccounts: started");
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
List<ErrorDetail> errors = new java.util.ArrayList<>();
return new StreamObserver<BulkCreateAccountRequest>() {
@Override
public void onNext(BulkCreateAccountRequest request) {
try {
CreateAccountRequest accountRequest = request.getAccount();
Account account = converter.toDomain(accountRequest);
accountUseCase.create(account);
successCount.incrementAndGet();
log.debug("Created account: {}", accountRequest.getAccountCode());
} catch (Exception e) {
failureCount.incrementAndGet();
errors.add(ErrorDetail.newBuilder()
.setField(request.getAccount().getAccountCode())
.setMessage(e.getMessage())
.build());
log.warn("Failed to create account: {}",
request.getAccount().getAccountCode(), e);
}
}
@Override
public void onError(Throwable t) {
log.error("bulkCreateAccounts error", t);
}
@Override
public void onCompleted() {
BulkCreateAccountResponse response = BulkCreateAccountResponse.newBuilder()
.setSuccessCount(successCount.get())
.setFailureCount(failureCount.get())
.addAllErrors(errors)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
log.info("bulkCreateAccounts completed: success={}, failure={}",
successCount.get(), failureCount.get());
}
};
}
/**
* 勘定科目ツリー取得
*/
@Override
public void getAccountTree(GetAccountTreeRequest request,
StreamObserver<GetAccountTreeResponse> responseObserver) {
log.info("getAccountTree: bsplType={}", request.getBsplType());
try {
var tree = accountUseCase.getAccountTree(
converter.toDomainBsplType(request.getBsplType())
);
GetAccountTreeResponse response = GetAccountTreeResponse.newBuilder()
.addAllNodes(converter.toProtoTree(tree))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
log.error("Error getting account tree", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
}
24.2 仕訳サービス実装¶
GrpcJournalService.java
package com.example.accounting.infrastructure.in.grpc.service;
import com.example.accounting.application.port.in.JournalUseCase;
import com.example.accounting.domain.model.journal.Journal;
import com.example.accounting.domain.model.journal.SlipNumber;
import com.example.accounting.domain.exception.ResourceNotFoundException;
import com.example.accounting.domain.exception.BalanceNotMatchException;
import com.example.accounting.infrastructure.in.grpc.converter.JournalConverter;
import com.example.accounting.infrastructure.in.grpc.proto.*;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.server.service.GrpcService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 仕訳 gRPC サービス実装
*/
@GrpcService
public class GrpcJournalService extends JournalServiceGrpc.JournalServiceImplBase {
private static final Logger log = LoggerFactory.getLogger(GrpcJournalService.class);
private final JournalUseCase journalUseCase;
private final JournalConverter converter;
public GrpcJournalService(JournalUseCase journalUseCase, JournalConverter converter) {
this.journalUseCase = journalUseCase;
this.converter = converter;
}
/**
* 単項 RPC: 仕訳取得
*/
@Override
public void getJournal(GetJournalRequest request,
StreamObserver<GetJournalResponse> responseObserver) {
log.info("getJournal: {}", request.getSlipNumber());
try {
SlipNumber slipNumber = new SlipNumber(request.getSlipNumber());
Journal journal = journalUseCase.findBySlipNumber(slipNumber)
.orElseThrow(() -> new ResourceNotFoundException(
"仕訳", request.getSlipNumber()));
GetJournalResponse response = GetJournalResponse.newBuilder()
.setJournal(converter.toProto(journal))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (ResourceNotFoundException e) {
log.warn("Journal not found: {}", request.getSlipNumber());
responseObserver.onError(
Status.NOT_FOUND
.withDescription("仕訳が見つかりません: " + request.getSlipNumber())
.asRuntimeException()
);
} catch (Exception e) {
log.error("Error getting journal", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* 単項 RPC: 仕訳登録
*/
@Override
public void createJournal(CreateJournalRequest request,
StreamObserver<CreateJournalResponse> responseObserver) {
log.info("createJournal: date={}", request.getJournalDate());
try {
Journal journal = converter.toDomain(request);
Journal created = journalUseCase.create(journal);
CreateJournalResponse response = CreateJournalResponse.newBuilder()
.setJournal(converter.toProto(created))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (BalanceNotMatchException e) {
log.warn("Balance not match: {}", e.getMessage());
responseObserver.onError(
Status.FAILED_PRECONDITION
.withDescription("貸借が一致しません: " + e.getMessage())
.asRuntimeException()
);
} catch (IllegalArgumentException e) {
log.warn("Invalid request: {}", e.getMessage());
responseObserver.onError(
Status.INVALID_ARGUMENT
.withDescription(e.getMessage())
.asRuntimeException()
);
} catch (Exception e) {
log.error("Error creating journal", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* 単項 RPC: 仕訳取消(赤黒処理)
*/
@Override
public void cancelJournal(CancelJournalRequest request,
StreamObserver<CancelJournalResponse> responseObserver) {
log.info("cancelJournal: {}", request.getSlipNumber());
try {
SlipNumber slipNumber = new SlipNumber(request.getSlipNumber());
var result = journalUseCase.cancel(slipNumber, request.getCancelReason());
CancelJournalResponse response = CancelJournalResponse.newBuilder()
.setOriginalJournal(converter.toProto(result.getOriginal()))
.setReversalJournal(converter.toProto(result.getReversal()))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (ResourceNotFoundException e) {
log.warn("Journal not found for cancel: {}", request.getSlipNumber());
responseObserver.onError(
Status.NOT_FOUND
.withDescription("取消対象の仕訳が見つかりません: " + request.getSlipNumber())
.asRuntimeException()
);
} catch (Exception e) {
log.error("Error canceling journal", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* サーバーストリーミング RPC: 仕訳一覧取得
*/
@Override
public void listJournals(ListJournalsRequest request,
StreamObserver<com.example.accounting.infrastructure.in.grpc.proto.Journal> responseObserver) {
log.info("listJournals: dateFrom={}, dateTo={}",
request.getDateFrom(), request.getDateTo());
try {
List<Journal> journals = journalUseCase.findAll(
converter.toCriteria(request)
);
// ストリーミングで仕訳を1件ずつ送信
for (Journal journal : journals) {
responseObserver.onNext(converter.toProto(journal));
}
responseObserver.onCompleted();
log.info("listJournals completed: {} items", journals.size());
} catch (Exception e) {
log.error("Error listing journals", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
/**
* クライアントストリーミング RPC: 仕訳一括登録
*/
@Override
public StreamObserver<BulkCreateJournalRequest> bulkCreateJournals(
StreamObserver<BulkCreateJournalResponse> responseObserver) {
log.info("bulkCreateJournals: started");
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
List<ErrorDetail> errors = new java.util.ArrayList<>();
List<String> createdSlipNumbers = new java.util.ArrayList<>();
return new StreamObserver<BulkCreateJournalRequest>() {
@Override
public void onNext(BulkCreateJournalRequest request) {
try {
CreateJournalRequest journalRequest = request.getJournal();
Journal journal = converter.toDomain(journalRequest);
Journal created = journalUseCase.create(journal);
successCount.incrementAndGet();
createdSlipNumbers.add(created.getSlipNumber().getValue());
log.debug("Created journal: {}", created.getSlipNumber().getValue());
} catch (Exception e) {
failureCount.incrementAndGet();
errors.add(ErrorDetail.newBuilder()
.setField("journal")
.setMessage(e.getMessage())
.build());
log.warn("Failed to create journal", e);
}
}
@Override
public void onError(Throwable t) {
log.error("bulkCreateJournals error", t);
}
@Override
public void onCompleted() {
BulkCreateJournalResponse response = BulkCreateJournalResponse.newBuilder()
.setSuccessCount(successCount.get())
.setFailureCount(failureCount.get())
.addAllErrors(errors)
.addAllCreatedSlipNumbers(createdSlipNumbers)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
log.info("bulkCreateJournals completed: success={}, failure={}",
successCount.get(), failureCount.get());
}
};
}
/**
* 単項 RPC: 貸借バランスチェック
*/
@Override
public void validateBalance(ValidateBalanceRequest request,
StreamObserver<ValidateBalanceResponse> responseObserver) {
log.info("validateBalance");
try {
var result = journalUseCase.validateBalance(
converter.toDetailDomainList(request.getDetailsList())
);
ValidateBalanceResponse response = ValidateBalanceResponse.newBuilder()
.setIsBalanced(result.isBalanced())
.setTotalDebit(converter.toProtoMoney(result.getTotalDebit()))
.setTotalCredit(converter.toProtoMoney(result.getTotalCredit()))
.setDifference(converter.toProtoMoney(result.getDifference()))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
log.error("Error validating balance", e);
responseObserver.onError(
Status.INTERNAL
.withDescription("内部エラーが発生しました")
.asRuntimeException()
);
}
}
}
24.3 コンバーター実装¶
AccountConverter.java
package com.example.accounting.infrastructure.in.grpc.converter;
import com.example.accounting.domain.model.account.*;
import com.example.accounting.infrastructure.in.grpc.proto.*;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* 勘定科目ドメインモデル ⇔ Protocol Buffers 変換
*/
@Component
public class AccountConverter {
/**
* ドメインモデル → Proto
*/
public com.example.accounting.infrastructure.in.grpc.proto.Account toProto(Account domain) {
return com.example.accounting.infrastructure.in.grpc.proto.Account.newBuilder()
.setAccountCode(domain.getAccountCode().getValue())
.setAccountName(domain.getAccountName())
.setBsplType(toProtoBsplType(domain.getBsplType()))
.setDebitCreditType(toProtoDebitCreditType(domain.getDebitCreditType()))
.setElementType(toProtoElementType(domain.getElementType()))
.setAggregationType(toProtoAggregationType(domain.getAggregationType()))
.setParentAccountCode(nullToEmpty(domain.getParentAccountCode()))
.setAccountPath(nullToEmpty(domain.getAccountPath()))
.setDisplayOrder(domain.getDisplayOrder())
.setIsActive(domain.isActive())
.build();
}
/**
* CreateAccountRequest → ドメインモデル
*/
public Account toDomain(CreateAccountRequest request) {
return Account.builder()
.accountCode(new AccountCode(request.getAccountCode()))
.accountName(request.getAccountName())
.bsplType(toDomainBsplType(request.getBsplType()))
.debitCreditType(toDomainDebitCreditType(request.getDebitCreditType()))
.elementType(toDomainElementType(request.getElementType()))
.aggregationType(toDomainAggregationType(request.getAggregationType()))
.parentAccountCode(emptyToNull(request.getParentAccountCode()))
.displayOrder(request.getDisplayOrder())
.isActive(true)
.build();
}
/**
* ListAccountsRequest → 検索条件
*/
public AccountSearchCriteria toCriteria(ListAccountsRequest request) {
return AccountSearchCriteria.builder()
.bsplType(request.hasBsplType()
? toDomainBsplType(request.getBsplType()) : null)
.elementType(request.hasElementType()
? toDomainElementType(request.getElementType()) : null)
.aggregationType(request.hasAggregationType()
? toDomainAggregationType(request.getAggregationType()) : null)
.keyword(emptyToNull(request.getKeyword()))
.activeOnly(request.getActiveOnly())
.page(request.hasPage() ? request.getPage().getPage() : 0)
.size(request.hasPage() ? request.getPage().getSize() : 20)
.build();
}
/**
* ツリー構造を Proto に変換
*/
public List<AccountNode> toProtoTree(List<AccountTreeNode> tree) {
return tree.stream()
.map(this::toProtoNode)
.collect(Collectors.toList());
}
private AccountNode toProtoNode(AccountTreeNode node) {
AccountNode.Builder builder = AccountNode.newBuilder()
.setAccount(toProto(node.getAccount()));
if (node.getChildren() != null && !node.getChildren().isEmpty()) {
builder.addAllChildren(
node.getChildren().stream()
.map(this::toProtoNode)
.collect(Collectors.toList())
);
}
return builder.build();
}
// === 区分変換 ===
public BsplType toProtoBsplType(
com.example.accounting.domain.model.account.BsplType domain) {
return switch (domain) {
case BS -> BsplType.BSPL_TYPE_BS;
case PL -> BsplType.BSPL_TYPE_PL;
};
}
public com.example.accounting.domain.model.account.BsplType toDomainBsplType(
BsplType proto) {
return switch (proto) {
case BSPL_TYPE_BS -> com.example.accounting.domain.model.account.BsplType.BS;
case BSPL_TYPE_PL -> com.example.accounting.domain.model.account.BsplType.PL;
default -> throw new IllegalArgumentException("Unknown BSPL type: " + proto);
};
}
private DebitCreditType toProtoDebitCreditType(
com.example.accounting.domain.model.account.DebitCreditType domain) {
return switch (domain) {
case DEBIT -> DebitCreditType.DEBIT_CREDIT_TYPE_DEBIT;
case CREDIT -> DebitCreditType.DEBIT_CREDIT_TYPE_CREDIT;
};
}
private com.example.accounting.domain.model.account.DebitCreditType toDomainDebitCreditType(
DebitCreditType proto) {
return switch (proto) {
case DEBIT_CREDIT_TYPE_DEBIT ->
com.example.accounting.domain.model.account.DebitCreditType.DEBIT;
case DEBIT_CREDIT_TYPE_CREDIT ->
com.example.accounting.domain.model.account.DebitCreditType.CREDIT;
default -> throw new IllegalArgumentException("Unknown debit/credit type: " + proto);
};
}
private ElementType toProtoElementType(
com.example.accounting.domain.model.account.ElementType domain) {
return switch (domain) {
case ASSET -> ElementType.ELEMENT_TYPE_ASSET;
case LIABILITY -> ElementType.ELEMENT_TYPE_LIABILITY;
case EQUITY -> ElementType.ELEMENT_TYPE_EQUITY;
case REVENUE -> ElementType.ELEMENT_TYPE_REVENUE;
case EXPENSE -> ElementType.ELEMENT_TYPE_EXPENSE;
};
}
private com.example.accounting.domain.model.account.ElementType toDomainElementType(
ElementType proto) {
return switch (proto) {
case ELEMENT_TYPE_ASSET ->
com.example.accounting.domain.model.account.ElementType.ASSET;
case ELEMENT_TYPE_LIABILITY ->
com.example.accounting.domain.model.account.ElementType.LIABILITY;
case ELEMENT_TYPE_EQUITY ->
com.example.accounting.domain.model.account.ElementType.EQUITY;
case ELEMENT_TYPE_REVENUE ->
com.example.accounting.domain.model.account.ElementType.REVENUE;
case ELEMENT_TYPE_EXPENSE ->
com.example.accounting.domain.model.account.ElementType.EXPENSE;
default -> throw new IllegalArgumentException("Unknown element type: " + proto);
};
}
private AggregationType toProtoAggregationType(
com.example.accounting.domain.model.account.AggregationType domain) {
return switch (domain) {
case HEADING -> AggregationType.AGGREGATION_TYPE_HEADING;
case SUMMARY -> AggregationType.AGGREGATION_TYPE_SUMMARY;
case POSTING -> AggregationType.AGGREGATION_TYPE_POSTING;
};
}
private com.example.accounting.domain.model.account.AggregationType toDomainAggregationType(
AggregationType proto) {
return switch (proto) {
case AGGREGATION_TYPE_HEADING ->
com.example.accounting.domain.model.account.AggregationType.HEADING;
case AGGREGATION_TYPE_SUMMARY ->
com.example.accounting.domain.model.account.AggregationType.SUMMARY;
case AGGREGATION_TYPE_POSTING ->
com.example.accounting.domain.model.account.AggregationType.POSTING;
default -> throw new IllegalArgumentException("Unknown aggregation type: " + proto);
};
}
// === ユーティリティ ===
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private String emptyToNull(String value) {
return value == null || value.isEmpty() ? null : value;
}
}
第 25 章:gRPC インターセプター¶
25.1 ログインターセプター¶
LoggingInterceptor.java
package com.example.accounting.infrastructure.in.grpc.interceptor;
import io.grpc.*;
import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.UUID;
/**
* gRPC ログインターセプター
*/
@GrpcGlobalServerInterceptor
public class LoggingInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String requestId = UUID.randomUUID().toString();
String methodName = call.getMethodDescriptor().getFullMethodName();
long startTime = System.currentTimeMillis();
MDC.put("requestId", requestId);
log.info("gRPC Request: method={}", methodName);
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
next.startCall(
new ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
@Override
public void close(Status status, Metadata trailers) {
long duration = System.currentTimeMillis() - startTime;
log.info("gRPC Response: method={}, status={}, duration={}ms",
methodName, status.getCode(), duration);
MDC.clear();
super.close(status, trailers);
}
},
headers
)
) {
@Override
public void onMessage(ReqT message) {
log.debug("gRPC Request message: {}", message);
super.onMessage(message);
}
};
}
}
25.2 例外インターセプター¶
ExceptionInterceptor.java
package com.example.accounting.infrastructure.in.grpc.interceptor;
import com.example.accounting.domain.exception.BusinessException;
import com.example.accounting.domain.exception.ResourceNotFoundException;
import com.example.accounting.domain.exception.ValidationException;
import com.example.accounting.domain.exception.BalanceNotMatchException;
import io.grpc.*;
import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* gRPC 例外インターセプター
*/
@GrpcGlobalServerInterceptor
public class ExceptionInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ExceptionInterceptor.class);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
return new ExceptionHandlingListener<>(
next.startCall(call, headers),
call
);
}
private class ExceptionHandlingListener<ReqT, RespT>
extends ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT> {
private final ServerCall<ReqT, RespT> call;
protected ExceptionHandlingListener(
ServerCall.Listener<ReqT> delegate,
ServerCall<ReqT, RespT> call) {
super(delegate);
this.call = call;
}
@Override
public void onHalfClose() {
try {
super.onHalfClose();
} catch (Exception e) {
handleException(e);
}
}
private void handleException(Exception e) {
Status status;
String message;
if (e instanceof ResourceNotFoundException) {
status = Status.NOT_FOUND;
message = e.getMessage();
log.warn("Resource not found: {}", message);
} else if (e instanceof ValidationException) {
status = Status.INVALID_ARGUMENT;
message = e.getMessage();
log.warn("Validation error: {}", message);
} else if (e instanceof BalanceNotMatchException) {
status = Status.FAILED_PRECONDITION;
message = e.getMessage();
log.warn("Balance not match: {}", message);
} else if (e instanceof BusinessException) {
status = Status.FAILED_PRECONDITION;
message = e.getMessage();
log.warn("Business rule violation: {}", message);
} else if (e instanceof IllegalArgumentException) {
status = Status.INVALID_ARGUMENT;
message = e.getMessage();
log.warn("Invalid argument: {}", message);
} else {
status = Status.INTERNAL;
message = "内部エラーが発生しました";
log.error("Internal error", e);
}
call.close(status.withDescription(message), new Metadata());
}
}
}
第 26 章:動作確認とテスト¶
26.1 grpcurl による動作確認¶
# サーバー起動確認(リフレクションサービス経由)
grpcurl -plaintext localhost:9090 list
# 勘定科目サービスのメソッド一覧
grpcurl -plaintext localhost:9090 list com.example.accounting.AccountService
# 勘定科目取得
grpcurl -plaintext -d '{"account_code": "1110"}' \
localhost:9090 com.example.accounting.AccountService/GetAccount
# 勘定科目登録
grpcurl -plaintext -d '{
"account_code": "1111",
"account_name": "普通預金(みずほ銀行)",
"bspl_type": "BSPL_TYPE_BS",
"debit_credit_type": "DEBIT_CREDIT_TYPE_DEBIT",
"element_type": "ELEMENT_TYPE_ASSET",
"aggregation_type": "AGGREGATION_TYPE_POSTING",
"parent_account_code": "1110",
"display_order": 10
}' localhost:9090 com.example.accounting.AccountService/CreateAccount
# 勘定科目一覧取得(ストリーミング)
grpcurl -plaintext -d '{
"bspl_type": "BSPL_TYPE_BS",
"active_only": true
}' localhost:9090 com.example.accounting.AccountService/ListAccounts
# 仕訳登録
grpcurl -plaintext -d '{
"journal_date": {"year": 2025, "month": 1, "day": 15},
"summary": "売上計上",
"journal_type": "JOURNAL_TYPE_NORMAL",
"details": [{
"line_number": 1,
"line_summary": "A社への売上",
"debit_credit_details": [
{
"debit_credit_type": "DEBIT_CREDIT_TYPE_DEBIT",
"account_code": "1310",
"amount": {"units": 110000, "currency": "JPY"}
},
{
"debit_credit_type": "DEBIT_CREDIT_TYPE_CREDIT",
"account_code": "4100",
"amount": {"units": 100000, "currency": "JPY"}
},
{
"debit_credit_type": "DEBIT_CREDIT_TYPE_CREDIT",
"account_code": "2110",
"amount": {"units": 10000, "currency": "JPY"}
}
]
}]
}' localhost:9090 com.example.accounting.JournalService/CreateJournal
# ヘルスチェック
grpcurl -plaintext localhost:9090 grpc.health.v1.Health/Check
26.2 統合テスト¶
GrpcAccountServiceTest.java
package com.example.accounting.infrastructure.in.grpc.service;
import com.example.accounting.application.port.in.AccountUseCase;
import com.example.accounting.domain.model.account.*;
import com.example.accounting.infrastructure.in.grpc.converter.AccountConverter;
import com.example.accounting.infrastructure.in.grpc.proto.*;
import io.grpc.StatusRuntimeException;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SpringBootTest(properties = {
"grpc.server.port=9091",
"grpc.server.in-process-name=test"
})
@ActiveProfiles("test")
class GrpcAccountServiceTest {
@MockBean
private AccountUseCase accountUseCase;
@GrpcClient("inProcess")
private AccountServiceGrpc.AccountServiceBlockingStub blockingStub;
private Account sampleAccount;
@BeforeEach
void setUp() {
sampleAccount = Account.builder()
.accountCode(new AccountCode("1110"))
.accountName("現金")
.bsplType(com.example.accounting.domain.model.account.BsplType.BS)
.debitCreditType(
com.example.accounting.domain.model.account.DebitCreditType.DEBIT)
.elementType(
com.example.accounting.domain.model.account.ElementType.ASSET)
.aggregationType(
com.example.accounting.domain.model.account.AggregationType.POSTING)
.isActive(true)
.build();
}
@Test
@DisplayName("勘定科目取得 - 存在する科目を取得できること")
void getAccount_found() {
// Given
when(accountUseCase.findByCode(any(AccountCode.class)))
.thenReturn(Optional.of(sampleAccount));
GetAccountRequest request = GetAccountRequest.newBuilder()
.setAccountCode("1110")
.build();
// When
GetAccountResponse response = blockingStub.getAccount(request);
// Then
assertThat(response.getAccount().getAccountCode()).isEqualTo("1110");
assertThat(response.getAccount().getAccountName()).isEqualTo("現金");
assertThat(response.getAccount().getBsplType())
.isEqualTo(BsplType.BSPL_TYPE_BS);
}
@Test
@DisplayName("勘定科目取得 - 存在しない科目は NOT_FOUND")
void getAccount_notFound() {
// Given
when(accountUseCase.findByCode(any(AccountCode.class)))
.thenReturn(Optional.empty());
GetAccountRequest request = GetAccountRequest.newBuilder()
.setAccountCode("9999")
.build();
// When & Then
assertThatThrownBy(() -> blockingStub.getAccount(request))
.isInstanceOf(StatusRuntimeException.class)
.satisfies(e -> {
StatusRuntimeException sre = (StatusRuntimeException) e;
assertThat(sre.getStatus().getCode())
.isEqualTo(io.grpc.Status.NOT_FOUND.getCode());
});
}
@Test
@DisplayName("勘定科目一覧取得 - ストリーミングで取得できること")
void listAccounts_streaming() {
// Given
List<Account> accounts = List.of(
sampleAccount,
Account.builder()
.accountCode(new AccountCode("1120"))
.accountName("普通預金")
.bsplType(com.example.accounting.domain.model.account.BsplType.BS)
.debitCreditType(
com.example.accounting.domain.model.account.DebitCreditType.DEBIT)
.elementType(
com.example.accounting.domain.model.account.ElementType.ASSET)
.aggregationType(
com.example.accounting.domain.model.account.AggregationType.POSTING)
.isActive(true)
.build()
);
when(accountUseCase.findAll(any())).thenReturn(accounts);
ListAccountsRequest request = ListAccountsRequest.newBuilder()
.setBsplType(BsplType.BSPL_TYPE_BS)
.setActiveOnly(true)
.build();
// When
Iterator<com.example.accounting.infrastructure.in.grpc.proto.Account> iterator =
blockingStub.listAccounts(request);
// Then
int count = 0;
while (iterator.hasNext()) {
com.example.accounting.infrastructure.in.grpc.proto.Account account = iterator.next();
assertThat(account.getAccountCode()).matches("11\\d+");
count++;
}
assertThat(count).isEqualTo(2);
}
}
第 27 章:まとめ¶
27.1 学習した内容¶
- gRPC の基本概念: Protocol Buffers、HTTP/2、4 つの RPC パターン
- REST/GraphQL との比較: 用途に応じた適切な選択
- ヘキサゴナルアーキテクチャとの統合: Input Adapter として gRPC サービスを追加
- 技術スタック: grpc-spring-boot-starter、Protocol Buffers
- Protocol Buffers 定義: 共通型、勘定科目、仕訳、残高のメッセージとサービス定義
- gRPC サービス実装: 単項 RPC、ストリーミング RPC
- インターセプター: ログ、例外ハンドリング
- テスト: gRPC クライアントを使用した統合テスト
27.2 gRPC が適するユースケース¶
| ユースケース | 理由 |
|---|---|
| 基幹システム連携 | 高性能なバイナリ通信 |
| 大量仕訳一括登録 | クライアントストリーミング |
| リアルタイム残高監視 | 双方向ストリーミング |
| 月次締め進捗通知 | サーバーストリーミング |
| マイクロサービス間通信 | 型安全な API 定義 |
27.3 API アーキテクチャ選択ガイド¶
27.4 次のステップ¶
- gRPC-Web による Web フロントエンド対応
- 認証・認可(JWT トークン検証)
- ロードバランシングとサービスメッシュ
- Observability(メトリクス、トレーシング)