Libraries & Ecosystem · کتابخانهها و اکوسیستم سنیورSenior ~43 دقیقه مطالعه~37 min read
gRPC و Protocol BuffersgRPC & Protocol Buffers
gRPC یک چارچوب فراخوانی رویهی راهدور روی HTTP/2 است که با Protocol Buffers قرارداد را در قالب یک اسکیمای نوعدار تعریف میکند، کد کلاینت و سرور را میسازد و چهار الگوی فراخوانی، مهلتزمانی، لغو و تکاملِ امنِ اسکیما را برای ارتباط سریع و کمحجم بین سرویسها فراهم میکند.gRPC is a modern RPC framework over HTTP/2 that uses Protocol Buffers to define a strongly-typed contract, generates client and server code, and gives you four call patterns, deadlines, cancellation, and safe schema evolution for fast, compact service-to-service communication.
سلام. بیا از یک اعتراف شروع کنیم: وقتی دو سرویس در یک سیستم میخواهند با هم حرف بزنند، اکثر ما پیشفرض میرویم سراغ REST روی JSON. جواب میدهد — اما پشت آن راحتی، یک عالمه کارِ دستی، رشتهبازی و حدسوگمان پنهان است: هر دو طرف باید سرِ نامِ فیلدها توافق نانوشته داشته باشند، JSON را دستی تجزیه کنند، و امیدوار باشند که تغییر کوچکِ یکی، دیگری را نشکند. gRPC آمده تا این «توافق نانوشته» را به یک قرارداد رسمیِ نوعدار تبدیل کند که کامپایلر و ابزارها آن را میفهمند. در این فصل قرار نیست فقط یاد بگیری چطور یک سرویس gRPC بنویسی؛ قرار است بفهمی چرا این تکنولوژی وجود دارد، پشت پرده چه اتفاقی میافتد و کجا باید و کجا نباید سراغش بروی.
مسیری که با هم میرویم:
- RPC چیست — فراخوانی رویهی راهدور و اینکه چرا میخواهیم شبکه «نامرئی» شود.
- Protocol Buffers — زبان تعریف قرارداد (IDL) و فرمت سیمی (wire format) که داده را فشرده و سریع میکند.
- gRPC روی HTTP/2 — چرا HTTP/2 قلب ماجراست و چه چیزی را ممکن میکند.
- تولید کد — از یک فایل
.protoتا stub و کلاسهای جاوا. - چهار نوع فراخوانی — unary، server-streaming، client-streaming و bidirectional با کد واقعی.
- مهلتزمانی، لغو، interceptor — کنترل زمان و رهگیری میانبُری.
- gRPC در برابر REST/JSON — جدول مقایسه و تحلیل معاملهها (trade-off).
- تکامل اسکیما — چطور قرارداد را بدون شکستن مصرفکنندهها عوض کنیم.
- دامها، بهترین شیوهها و پرسشهای مصاحبه با پاسخ کامل.
بخش صفر — چند کلمه که باید پیش از شروع حسشان کنی
قبل از کد، چند اصطلاح در کل فصل برمیگردند. بگذار همین اول در ذهنت جا بیندازمشان.
- سریالسازی (serialization): تبدیل یک شیء در حافظه به یک رشتهی بایت که بتوان روی شبکه فرستاد یا در دیسک ذخیره کرد. عکسِ آن دیسریالسازی (deserialization) است. JSON یک فرمت سریالسازیِ متنی است؛ Protocol Buffers یک فرمت سریالسازیِ دودویی (binary).
- IDL: سرنام Interface Definition Language، یعنی «زبان تعریف رابط». یک فایل مستقل از زبانِ برنامهنویسی که میگوید «قرارداد چیست»؛ چه پیامهایی، با چه فیلدهایی، و چه متدهایی وجود دارد. فایل
.protoهمین است. - stub: کدِ تولیدشدهای که در سمت کلاینت مثل یک شیءِ محلی بهنظر میرسد اما پشتپرده فراخوانی را روی شبکه میفرستد. اسمش از این میآید که «تکهی جانشین» توابع واقعیِ آنطرفِ شبکه است.
- HTTP/2: نسخهی دومِ پروتکل HTTP که چند درخواست را همزمان روی یک اتصال TCP جابهجا میکند (multiplexing)، هدرها را فشرده میکند و از streaming دوطرفه پشتیبانی میکند. gRPC روی همین سوار است.
RPC چیست: وقتی میخواهیم شبکه ناپدید شود
تصور کن میخواهی جوابِ یک محاسبهی پیچیده را بدانی اما خودت بلد نیستی. یک راه این است که نامه بنویسی، در پاکت بگذاری، آدرس بزنی، پست کنی و منتظر جواب بمانی — این میشود مدلِ «پیاممحور». راهِ راحتتر این است که گوشی را برداری، شمارهی متخصص را بگیری، بگویی «این عدد بهعلاوهی آن چند میشود؟» و بیدرنگ جواب بشنوی — انگار خودش کنارت نشسته. RPC دقیقاً همین تماس تلفنی است: تو تابعی را «صدا میزنی» که انگار محلی است، اما در واقع روی ماشینِ دیگری اجرا میشود. تمامِ کارِ پاکت و تمبر و پست (سریالسازی، شبکه، تجزیه) پشتِ گوشی پنهان میشود.
RPC سرنامِ Remote Procedure Call است: «فراخوانی رویهی راهدور». ایدهی مرکزی این است که یک فراخوانیِ شبکهای را طوری در بیاوری که شبیهِ یک فراخوانیِ تابعِ معمولی بهنظر برسد. بهجای اینکه دستی یک درخواست HTTP بسازی، URL درست کنی، بدنه را به JSON تبدیل کنی، بفرستی و جواب را تجزیه کنی، فقط مینویسی:
BalanceResponse resp = accountService.getBalance(request);
و انگار همینجا اجرا شد. اما توهمِ «محلیبودن» یک تیغِ دولبه است. شبکه واقعاً ناپدید نمیشود؛ فقط پنهان میشود. پشتِ آن یک فراخوانی، هنوز تأخیر (latency) هست، بستهها ممکن است گم شوند، سرور ممکن است پایین باشد و اتصال ممکن است وسط کار قطع شود.
یک فهرستِ کلاسیک بهنامِ «مغالطههای محاسبات توزیعشده» (Fallacies of Distributed Computing) میگوید مهندسانِ تازهکار پیشفرض میگیرند: شبکه قابلاعتماد است، تأخیر صفر است، پهنای باند بینهایت است، شبکه امن است و... همهی اینها غلطاند. RPC کدِ شبکه را زیبا میکند، اما تو را از فکرکردن به شکست، مهلتزمانی و تلاشِ مجدد (retry) معاف نمیکند. هر جا یک . روی یک stub میبینی، در واقع یک سفرِ شبکهای پنهان است.
gRPC یکی از محبوبترین چارچوبهای RPCِ امروز است که گوگل آن را در سال ۲۰۱۵ متنباز کرد. حرف «g» رسماً در هر نسخه یک معنی دارد (شوخیِ خودِ تیم)، اما دو ستونش ثابت است: Protocol Buffers برای قرارداد و داده، و HTTP/2 برای انتقال. بیا اول ستونِ اول را بشکافیم.
Protocol Buffers: قرارداد را رسمی کن
JSON مثل یک یادداشتِ دستنویس است: هر بار نامِ فیلد را کامل مینویسی («"accountId": ...»)، هر عددی بهصورت متن ذخیره میشود، و طرفِ مقابل باید حدس بزند منظورت از هر کلید چیست. Protocol Buffers مثل یک فرمِ چاپیِ استاندارد است: خانهها از پیش شمارهگذاری شدهاند، هر دو طرف نسخهی یکسانی از فرم را دارند، و تو فقط مقدارها را در خانهها میگذاری. لازم نیست کنارِ هر مقدار بنویسی «این خانهی نامِ حساب است» — چون خانهی شمارهی ۱ همیشه نامِ حساب است. نتیجه: بسیار فشردهتر، سریعتر و بیابهامتر.
Protocol Buffers (که کوتاهش میکنیم «protobuf») دو چیز است که با هم میآیند:
- یک زبانِ تعریفِ قرارداد (IDL) که در فایلهای
.protoمینویسی و شکلِ داده و سرویس را توصیف میکند. - یک فرمتِ سریالسازیِ دودویی که آن داده را به فشردهترین شکلِ ممکن روی سیم میفرستد.
یک فایل .proto نمونه:
syntax = "proto3";
package bank.v1;
option java_package = "com.example.bank.v1";
option java_multiple_files = true;
// یک پیام: بستهای از فیلدهای شمارهدار
message BalanceRequest {
string account_id = 1;
}
message BalanceResponse {
string account_id = 1;
int64 cents = 2; // موجودی به سِنت، برای پرهیز از خطای اعشار
string currency = 3;
}
// یک سرویس: مجموعهای از متدهای راهدور
service AccountService {
rpc GetBalance(BalanceRequest) returns (BalanceResponse);
}
چند نکته را بشکافیم:
syntax = "proto3"میگوید از نسخهی سومِ زبان استفاده میکنیم (به «editions» در ادامه میرسیم).- هر فیلد یک شمارهی میدان (field number) دارد: آن
= 1،= 2... این شمارهها هستند که روی سیم میروند، نه نامِ فیلد. این کلیدِ فهمِ کلِ ماجراست. int64را برای پول انتخاب کردیم چون اعداد اعشاریِ شناور (double) در پول باگزا هستند؛ همیشه پول را به کوچکترین واحد (سِنت/ریال) بهصورت عددِ صحیح نگه دار.- بلوکِ
serviceمتدهای راهدور را تعریف میکند. protobuf فقط دربارهٔ داده نیست؛ سرویسها را هم توصیف میکند، و اینجاست که gRPC واردِ ماجرا میشود.
در JSON، اگر یک آرایهی هزارتایی از حساب بفرستی، رشتهی "accountId" هزار بار در بایتها تکرار میشود. در protobuf، فقط شمارهی 1 (یک بایت) میرود. این تفاوت، پیامها را معمولاً ۳ تا ۱۰ برابر کوچکتر و تجزیه را چند برابر سریعتر میکند — چون تجزیهگر لازم نیست رشتهها را بخواند و تطبیق دهد، فقط عددِ شماره را میخواند.
فرمتِ سیمی: پشتِ پردهی بایتها
حالا برویم سراغِ جادویِ فشردگی. وقتی protobuf یک فیلد را سریال میکند، برای هر فیلد یک برچسب (tag) مینویسد و بعد مقدار را. برچسب با این فرمول ساخته میشود:
tag = (field_number << 3) | wire_type
یعنی شمارهی میدان را ۳ بیت به چپ شیفت میدهد و ۳ بیتِ پایین را به نوعِ سیمی (wire type) اختصاص میدهد. نوعِ سیمی به تجزیهگر میگوید «مقدارِ بعدی چند بایت است و چطور خوانده میشود». فقط چند نوعِ سیمی داریم:
| نوعِ سیمی | شماره | برای چه نوعهایی |
|---|---|---|
| VARINT | 0 | int32, int64, uint32, uint64, bool, enum, sint32, sint64 |
| I64 | 1 | fixed64, sfixed64, double |
| LEN | 2 | string, bytes, پیامِ تودرتو، آرایههای packed |
| I32 | 5 | fixed32, sfixed32, float |
(نوعهای ۳ و ۴ مربوط به groupهای قدیمی و منسوخاند.)
قلبِ فشردگی، varint است: «عددِ صحیح با طولِ متغیر». بهجای اینکه هر عددِ صحیح همیشه ۴ یا ۸ بایت بگیرد، protobuf عددهای کوچک را در بایتهای کمتر جا میدهد. هر بایت ۷ بیت داده دارد و بیتِ هشتم (پرارزشترین) یک بیتِ ادامه (continuation bit) است: اگر ۱ باشد یعنی «بایتِ بعدی هم بخوان». پس عددِ ۱ فقط یک بایت است، اما عددِ یکمیلیارد چند بایت.
یک تلهی کلاسیک: در varint، عددِ منفیِ int32 همیشه ۱۰ بایتِ کامل میگیرد! چون منفیها با متممِ دو نمایش داده میشوند و بیتهای بالا همه ۱ میشوند. اگر میدانی معمولاً منفی است، بهجای int32 از sint32 استفاده کن؛ این نوع از کدگذاری زیگزاگ (ZigZag) استفاده میکند که منفیها را به مثبتهای کوچک نگاشت میکند (0→0، 1-→1، 1→2، 2-→3...) و دوباره فشرده میشوند. انتخابِ نوعِ درست، مستقیماً روی حجمِ سیم اثر میگذارد.
بیایید یک پیامِ واقعی را رمزگشایی کنیم تا حس کنی چقدر کمحجم است. فرض کن message T { int32 a = 1; } و مقدارِ a = 150. روی سیم میشود:
0x08 0x96 0x01
0x08= برچسب:(1 << 3) | 0→ میدانِ ۱، نوعِ VARINT.0x96 0x01= varintِ عددِ ۱۵۰.
فقط ۳ بایت. همین داده در JSON — {"a":150} — نُه بایت است، و آن هم بدونِ حسابِ فاصلهها.
چون تجزیهگر فقط شماره را میبیند نه نام، دو نتیجهی حیاتی داریم: (۱) میتوانی نامِ فیلد را در .proto عوض کنی و هیچچیز نمیشکند، چون نام فقط برای برنامهنویس است. (۲) اما اگر شمارهی میدان را عوض کنی یا دوباره استفاده کنی، فاجعه است: دادهی قدیمی با شمارهی جدید بهاشتباه تفسیر میشود. قانونِ طلایی: شمارهی میدان را هرگز عوض نکن و هرگز دوباره استفاده نکن. این تنها قانونی است که کلِ سازگاریِ رو به عقب روی آن بنا شده.
proto3 و «editions» جدید
سالها دو نسخهی زبان داشتیم: proto2 و proto3. از سال ۲۰۲۳ به بعد، تیمِ protobuf مفهومِ تازهای بهنامِ Protobuf Editions معرفی کرد که بهجای syntax = "proto3" مینویسی edition = "2024" (جدیدترین edition تا امروز). ایده این است که رفتارهایی که قبلاً بینِ proto2 و proto3 فرق داشتند (مثلِ «حضورِ میدان» یا نحوهی رمزگذاری) حالا بهصورت featureهای مستقل قابلِ تنظیماند.
توصیهی رسمیِ خودِ تیمِ protobuf این است که اکثرِ کاربران فعلاً روی proto3 بمانند و برای مهاجرت به editions عجله نکنند تا اکوسیستم پخته شود. پس در این فصل روی proto3 تمرکز میکنیم؛ فقط بدان که editions آیندهی زبان است و editions با proto2/proto3 سازگارِ دوطرفه است (فرمتِ سیمی عوض نمیشود). ابزارِ Prototiller برای مهاجرتِ خودکار وجود دارد.
gRPC روی HTTP/2: چرا انتقال مهم است
protobuf فقط داده و قرارداد را حل میکند. حالا باید آن بایتها را از A به B ببریم. gRPC این کار را روی HTTP/2 انجام میدهد و این انتخاب، اتفاقی نیست.
HTTP/1.1 مثل یک جادهی تکبانده است: هر ماشین (درخواست) باید منتظر بماند تا ماشینِ جلویی رد شود. اگر یک درخواستِ کند جلو بیفتد، همه پشتش گیر میکنند — به این میگویند «مسدودشدنِ سرِ صف» (head-of-line blocking). HTTP/2 مثل یک بزرگراهِ چندبانده روی یک اتصال است: دهها درخواست همزمان و بههمبافته (multiplexed) جابهجا میشوند، هر کدام در «جریانِ» (stream) خودش، بدونِ اینکه منتظرِ هم بمانند. همین یک ویژگی، streaming و کارایی بالای gRPC را ممکن میکند.
چهار چیزی که HTTP/2 به gRPC میدهد:
- Multiplexing: چند فراخوانیِ RPC همزمان روی یک اتصالِ TCP، بدونِ صفِ سریالی.
- Streaming دوطرفه: چون هر جریان میتواند مستقل باز بماند، سرور و کلاینت میتوانند همزمان پیام بفرستند و بگیرند.
- فشردهسازیِ هدر (HPACK): هدرهای HTTP فشرده میشوند، پس هزینهی متادیتا کم است.
- فریمِ دودویی: HTTP/2 خودش دودویی است و با بارِ دودوییِ protobuf هماهنگ است.
هر فراخوانیِ RPC یک درخواستِ POST به مسیرِ /<package>.<Service>/<Method> است. بدنهی درخواست، پیامهای protobufِ سریالشدهاند که هر کدام با یک پیشوندِ ۵ بایتی (۱ بایت پرچمِ فشردهسازی + ۴ بایت طول) بستهبندی شدهاند. وضعیتِ پایان (status) در trailerها برمیگردد — هدرهایی که بعد از بدنه میآیند. به همین دلیل gRPC به HTTP/2 نیاز دارد؛ HTTP/1.1 trailer را بهخوبی پشتیبانی نمیکند. برای مرورگرها که به فریمهای خام HTTP/2 دسترسی ندارند، نسخهای بهنامِ gRPC-Web با یک proxy وجود دارد.
تولید کد: از .proto تا جاوا
اینجا زیباییِ کار خودش را نشان میدهد. تو .proto را مینویسی، و یک کامپایلر بهنامِ protoc (بههمراهِ افزونهی protoc-gen-grpc-java) از آن کلاسهای جاوا میسازد: کلاسهای پیام (immutable با الگوی Builder) و stubهای سرویس. در دنیای Maven معمولاً این کار را با پلاگین خودکار میکنی. تنظیماتِ کلیدی:
<!-- وابستگیها (نسخهی نمونه: gRPC-Java 1.82.x) -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.82.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.82.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.82.1</version>
</dependency>
از هر service در .proto، برای جاوا سه نوع stub ساخته میشود. فهمِ تفاوتشان مهم است:
| نوعِ stub | امضای فراخوانی | چهوقت | streaming |
|---|---|---|---|
| BlockingStub | مقدار را مستقیم برمیگرداند و منتظر میماند | سادهترین؛ کدِ همگام | unary و server-stream |
| FutureStub | یک ListenableFuture برمیگرداند |
ناهمگامِ سبک با callback | فقط unary |
| (Async) Stub | با StreamObserver و callback کار میکند |
همهی حالتها، بهویژه streaming | هر چهار نوع |
یک اشتباهِ رایج: انتظار داری همهچیز را با blocking stub بنویسی. اما blocking stub نمیتواند client-streaming یا bidirectional را انجام دهد — چون آنجا باید تو پیامها را یکییکی بفرستی و این ذاتاً ناهمگام است. برای این دو حالت باید سراغِ async stub و StreamObserver بروی. blocking stub فقط برای unary و server-streaming (که یک Iterator برمیگرداند) مناسب است.
سمتِ سرور، protoc یک کلاسِ انتزاعیِ AccountServiceImplBase میسازد که تو آن را extend میکنی و متدها را پیاده میکنی. بیایید کاملِ یک سرویسِ ساده را ببینیم.
چهار نوعِ فراخوانی
gRPC چهار الگویِ ارتباطی دارد که همه از همان فایلِ .proto میآیند — فقط با کلمهی کلیدیِ stream مشخص میشوند.
service ChatService {
// ۱) unary: یک درخواست، یک پاسخ
rpc GetBalance(BalanceRequest) returns (BalanceResponse);
// ۲) server-streaming: یک درخواست، جریانی از پاسخها
rpc WatchPrices(WatchRequest) returns (stream PriceTick);
// ۳) client-streaming: جریانی از درخواستها، یک پاسخ
rpc UploadPoints(stream Point) returns (UploadSummary);
// ۴) bidirectional: جریان در هر دو جهت، مستقل
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
تصورشان کن مثلِ چهار سبکِ مکالمه: unary یک پرسشوپاسخِ ساده است («ساعت چند است؟» «سه.»). server-streaming مثلِ گوشدادن به رادیو است: یک بار روشنش میکنی و جریانی از اخبار میآید. client-streaming مثلِ آپلودِ یک فایلِ بزرگ تکهتکه است: تو مدام میفرستی و در پایان یک رسید میگیری. bidirectional مثلِ یک تماسِ تلفنیِ واقعی است: هر دو طرف هر وقت خواستند حرف میزنند و گوش میدهند، همزمان.
۱) Unary — ستون فقرات
سمتِ سرور:
import io.grpc.stub.StreamObserver;
import io.grpc.Status;
public class AccountServiceImpl extends AccountServiceGrpc.AccountServiceImplBase {
@Override
public void getBalance(BalanceRequest request,
StreamObserver<BalanceResponse> responseObserver) {
String id = request.getAccountId();
if (id.isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("account_id is required")
.asRuntimeException());
return;
}
BalanceResponse resp = BalanceResponse.newBuilder()
.setAccountId(id)
.setCents(1_250_00) // ۱۲۵۰.۰۰
.setCurrency("USD")
.build();
responseObserver.onNext(resp); // یک پاسخ بفرست
responseObserver.onCompleted(); // جریان را ببند
}
}
توجه کن که حتی در unary هم StreamObserver داریم: onNext مقدار را میفرستد و onCompleted میگوید «تمام». راهاندازیِ سرور:
import io.grpc.Server;
import io.grpc.ServerBuilder;
Server server = ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.build()
.start();
server.awaitTermination();
سمتِ کلاینت با blocking stub — تمیزترین حالت:
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext() // فقط برای توسعه؛ در تولید TLS
.build();
AccountServiceGrpc.AccountServiceBlockingStub stub =
AccountServiceGrpc.newBlockingStub(channel);
BalanceResponse resp = stub.getBalance(
BalanceRequest.newBuilder().setAccountId("A-42").build());
System.out.println(resp.getCents() + " " + resp.getCurrency());
channel.shutdown();
ManagedChannel یک شیءِ سنگین و بلندعمر است که یک استخرِ اتصالِ HTTP/2 را مدیریت میکند. آن را یک بار در طولِ عمرِ برنامه بساز و بازاستفاده کن — نه بهازای هر فراخوانی. برعکس، stub سبک است؛ ساختنش ارزان است و میتوانی بهازای هر فراخوانی با withDeadlineAfter تنظیمش کنی. اشتباهِ رایجِ تازهکارها ساختنِ channel در هر درخواست است که سریعاً اتصالها را تمام میکند.
۲) Server-streaming
سرور چند بار onNext میزند و در پایان onCompleted:
@Override
public void watchPrices(WatchRequest request,
StreamObserver<PriceTick> obs) {
for (int i = 0; i < 5; i++) {
obs.onNext(PriceTick.newBuilder()
.setSymbol(request.getSymbol())
.setPriceCents(10_000 + i * 25)
.build());
}
obs.onCompleted();
}
کلاینت با blocking stub یک Iterator میگیرد:
Iterator<PriceTick> ticks = blockingStub.watchPrices(
WatchRequest.newBuilder().setSymbol("ACME").build());
while (ticks.hasNext()) {
System.out.println(ticks.next().getPriceCents());
}
۳) Client-streaming
اینجا کلاینت چند پیام میفرستد. چون باید خودت StreamObserver را برگردانی، حتماً async stub لازم است:
// سمت سرور
@Override
public StreamObserver<Point> uploadPoints(
StreamObserver<UploadSummary> responseObs) {
return new StreamObserver<>() {
int count = 0;
long sum = 0;
@Override public void onNext(Point p) { count++; sum += p.getValue(); }
@Override public void onError(Throwable t) { /* لاگ */ }
@Override public void onCompleted() {
responseObs.onNext(UploadSummary.newBuilder()
.setCount(count).setTotal(sum).build());
responseObs.onCompleted(); // یک پاسخِ نهایی
}
};
}
دقت کن که متدِ سرور، قبل از دریافتِ هیچ پیامی یک StreamObserver برمیگرداند؛ این observer است که با هر onNext صدا زده میشود. onCompletedِ کلاینت به سرور میگوید «دیگر پیامی نیست»، و آنجا سرور پاسخِ خلاصه را میفرستد.
۴) Bidirectional streaming
قویترین حالت: هر دو طرف مستقل میفرستند و میگیرند.
@Override
public StreamObserver<ChatMessage> chat(
StreamObserver<ChatMessage> responseObs) {
return new StreamObserver<>() {
@Override public void onNext(ChatMessage msg) {
// بلافاصله پژواک بده — نیازی به منتظرماندنِ کلِ ورودی نیست
responseObs.onNext(ChatMessage.newBuilder()
.setText("echo: " + msg.getText()).build());
}
@Override public void onError(Throwable t) { }
@Override public void onCompleted() { responseObs.onCompleted(); }
};
}
در یکِ جریانِ خاص، پیامها بهترتیب تحویل میشوند — این تضمین شده است. اما در bidi، ترتیبِ نسبیِ پیامهای کلاینت و سرور تضمین نیست: سرور میتواند قبل از اینکه کلاینت حرفش تمام شود شروع به فرستادن کند. همچنین StreamObserver thread-safe نیست؛ اگر از چند نخ روی یک observer بنویسی باید خودت هماهنگسازی کنی، وگرنه پیامها در هم میریزند.
مهلتزمانی و لغو: کنترلِ زمان
تصور کن به کسی میگویی «تا ساعت ۵ منتظرت میمانم؛ بعد از آن میروم». Deadline در gRPC دقیقاً همین است: یک نقطهی زمانیِ مطلق که کلاینت میگوید «تا این لحظه صبر میکنم». نکتهی ظریف این است که deadline یک مدت (مثلِ «۵ ثانیه») نیست، بلکه یک زمانِ پایانِ مطلق است — و بهطور خودکار روی سیم به سرور و از سرور به سرویسهای پاییندستی منتقل میشود.
در جاوا معمولاً با withDeadlineAfter تنظیمش میکنی که داخلی آن را به یک deadlineِ مطلق تبدیل میکند:
BalanceResponse resp = blockingStub
.withDeadlineAfter(500, TimeUnit.MILLISECONDS)
.getBalance(request);
اگر سرور تا آن لحظه پاسخ ندهد، فراخوانی با وضعیتِ DEADLINE_EXCEEDED شکست میخورد.
فرض کن سرویسِ A سرویسِ B را صدا میزند و B هم C را. اگر هر کدام یک timeoutِ مستقل ۵ ثانیهای داشته باشند، در بدترین حالت کاربر ۱۵ ثانیه منتظر میماند. اما deadlineِ gRPC مطلق و منتشرشونده است: A یک deadlineِ ۵ ثانیهای میگذارد، و همان لحظهی پایانِ مطلق به B و بعد به C منتقل میشود. وقتی مهلت تمام شود، کلِ زنجیره همزمان قطع میشود و هیچ سرویسی روی کاری که دیگر کسی منتظرش نیست وقت هدر نمیدهد. این «بودجهی زمانی» یکی از مهمترین ابزارها برای جلوگیری از فروپاشیِ آبشاری است.
سمتِ سرور میتوانی چک کنی که آیا هنوز کسی منتظرِ جواب هست یا نه — یعنی آیا فراخوانی لغو شده یا مهلتش گذشته:
if (Context.current().isCancelled()) {
responseObserver.onError(Status.CANCELLED
.withDescription("client gone").asRuntimeException());
return; // کارِ سنگین را شروع نکن
}
لغو (cancellation) هم اتفاق میافتد وقتی کلاینت پیش از پایان، فراخوانی را رها میکند (مثلاً کاربر تب را میبندد). gRPC این لغو را به سرور منتقل میکند و سرور میتواند کارِ نیمهتمام را متوقف کند. Context مکانیزمی است که deadline و سیگنالِ لغو را در طولِ زنجیرهی فراخوانی حمل میکند.
وقتی deadline میگذرد، کلاینت نمیداند آیا سرور کار را انجام داد یا نه — شاید پاسخ در راه بود. برای همین، برای عملیاتِ غیرِ idempotent (مثلِ «پول را منتقل کن») نمیتوانی کورکورانه retry کنی، وگرنه ممکن است دو بار پول منتقل شود. راهکار: عملیات را idempotent طراحی کن (مثلاً با یک کلیدِ یکتا) یا فقط عملیاتِ امن را retry کن. deadline مشکل را حل نمیکند؛ فقط زمانِ انتظار را محدود میکند.
Interceptor: رهگیریِ میانبُری
Interceptor مثلِ گیتِ بازرسیِ فرودگاه است: هر مسافری (هر فراخوانیِ RPC) قبل از رسیدن به مقصد از آن رد میشود. آنجا میتوانی بلیت را چک کنی (احراز هویت)، زمان را ثبت کنی (سنجش)، یا حتی مسافر را برگردانی (رد کردن). قشنگیِ کار این است که منطقِ اصلیِ سرویس اصلاً از وجودِ این گیت خبر ندارد؛ رهگیری کاملاً جداست.
Interceptorها همان دغدغههای میانبُری (cross-cutting concerns) را حل میکنند: لاگ، متریک، احرازِ هویت، ردیابیِ توزیعشده (tracing) — چیزهایی که در همهی فراخوانیها تکرار میشوند و نباید در منطقِ کسبوکار قاطی شوند. دو طرف داریم: ServerInterceptor و ClientInterceptor.
یک server interceptor ساده که زمانِ هر فراخوانی را لاگ میکند:
public class TimingInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
long start = System.nanoTime();
String method = call.getMethodDescriptor().getFullMethodName();
ServerCall<ReqT, RespT> wrapped =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override public void close(Status status, Metadata trailers) {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf("%s -> %s in %dms%n",
method, status.getCode(), ms);
super.close(status, trailers);
}
};
return next.startCall(wrapped, headers);
}
}
و نصبش روی سرور:
ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.intercept(new TimingInterceptor())
.build();
Metadata همان هدرهای HTTP/2 است — جایی که توکنِ احراز هویت (مثلِ Authorization: Bearer ...) یا شناسهی ردیابی مینشیند. یک احرازِ هویتِ نمونه: interceptor توکن را از Metadata میخواند، اعتبارسنجی میکند، و اگر بیاعتبار بود با Status.UNAUTHENTICATED فراخوانی را رد میکند — همهی اینها بدونِ اینکه یک خط به کدِ سرویس اضافه شود.
interceptorها زنجیرهای اجرا میشوند. معمولاً میخواهی احرازِ هویت قبلِ لاگگیری و منطقِ کسبوکار اجرا شود تا فراخوانیِ غیرمجاز زودتر رد شود. در gRPC-Java ترتیبِ اجرا برعکسِ ترتیبِ افزودن است، پس مثلِ لایههای پیاز به آن فکر کن و ترتیب را تست کن. همچنین برای انتشارِ context (مثلِ tracing) از Context استفاده کن، نه فیلدهای استاتیک.
gRPC در برابر REST/JSON: کِی کدام؟
حالا که هر دو را دیدیم، بیایید صادقانه مقایسه کنیم. gRPC «بهتر» نیست؛ متفاوت است و برای مسائلِ متفاوت.
| ویژگی | gRPC + protobuf | REST + JSON |
|---|---|---|
| فرمتِ داده | دودوییِ فشرده | متنیِ خوانا |
| قرارداد | اسکیمای رسمیِ .proto (نوعدار) |
معمولاً غیررسمی؛ با OpenAPI اختیاری |
| انتقال | HTTP/2 (اجباری) | HTTP/1.1 یا 2 |
| Streaming | چهار نوع، شاملِ دوطرفه | محدود (SSE، WebSocket جدا) |
| تولیدِ کد | داخلی و استاندارد | با ابزارِ جانبی |
| خوانایی برای انسان | نه (نیاز به ابزار) | بله، با curl |
| پشتیبانیِ مرورگر | فقط با gRPC-Web + proxy | بومی و کامل |
| سرعت/حجم | بسیار سریعتر و کوچکتر | کندتر و حجیمتر |
| کششدنِ HTTP | ضعیف | قوی (GET قابلِ کش) |
| منحنیِ یادگیری | تندتر | ملایمتر |
اگر داری APIِ عمومی برای مرورگرها و توسعهدهندگانِ متفرقه میسازی، جایی که خوانایی، کششدن و سادگی مهماند → REST/JSON. اگر داری ارتباطِ داخلیِ بینِ میکروسرویسها (east-west traffic) را طراحی میکنی، جایی که کارایی، قراردادِ نوعدار و streaming حیاتیاند → gRPC. خیلی از شرکتها هر دو را دارند: gRPC در پشتِ صحنه بینِ سرویسها، و یک لایهی REST/JSON (یا GraphQL) رو به بیرون برای مرورگر. این «بهترینِ هر دو دنیا» الگویِ بسیار رایجی است.
یک واقعیتِ مهم: کدِ جاوااسکریپتِ داخلِ مرورگر نمیتواند مستقیماً gRPC صدا بزند، چون مرورگر به فریمهای خامِ HTTP/2 و trailerها دسترسی نمیدهد. باید از gRPC-Web استفاده کنی که یک proxy (مثلِ Envoy) را وسط میگذارد. این محدودیت، بهتنهایی دلیلِ اصلیِ استفاده از REST برای APIهایِ رو به مرورگر است. اگر کسی در مصاحبه بپرسد «چرا فرانتاند مستقیم gRPC نمیزند؟» جوابْ همین است.
تکاملِ اسکیما: قرارداد را بدون شکستن عوض کن
بزرگترین قوّتِ واقعیِ protobuf در دنیای واقعی، سازگاریِ رو به عقب و رو به جلو است. یعنی سرور و کلاینت میتوانند نسخههای متفاوتِ .proto داشته باشند و باز هم درست کار کنند. این معجزه از همان اصلِ «شمارهی میدان» میآید.
یادت هست protobuf را به یک فرمِ چاپی تشبیه کردیم؟ حالا تصور کن اداره یک خانهی جدید به فرم اضافه میکند (شمارهی ۷). کسانی که فرمِ قدیمی (بدونِ خانهی ۷) را پر کردهاند، مشکلی ندارند: خانهی ۷ خالی میماند و اداره مقدارِ پیشفرض را فرض میگیرد. و کسی که فرمِ جدید را پر کرده و به کارمندِ قدیمی میدهد؟ کارمند خانهی ۷ را نمیشناسد، پس نادیدهاش میگیرد و بقیهی فرم را عادی میخواند. هیچکدام نمیشکنند. این دقیقاً رفتارِ protobuf است.
قوانینِ طلاییِ تکاملِ امن:
- افزودنِ فیلدِ جدید امن است — به شرطِ اینکه شمارهی میدانِ تازهای بگیرد. تجزیهگرهای قدیمی آن را بهعنوانِ «فیلدِ ناشناخته» نادیده میگیرند (و در proto3 حتی نگهش میدارند و دوباره میفرستند).
- حذفِ فیلد امن است — اما شمارهاش را باید با
reservedرزرو کنی تا کسی در آینده اشتباهی از آن استفاده نکند. - هرگز شمارهی میدان را عوض نکن. نامش را عوض کن، اشکالی ندارد؛ اما شماره مقدس است.
- هرگز نوعِ ناسازگار نده. عوضکردنِ
int32بهstringداده را خراب میکند. بعضی تغییرات امناند (مثلِint32↔int64در محدوده) اما اکثراً نه. - فیلدها را
requiredنکن. proto3 اصلاًrequiredندارد و همین درست است: هر فیلدْ اختیاری و با مقدارِ پیشفرض است، که تکامل را ممکن میکند.
message BalanceResponse {
reserved 4; // شمارهی حذفشده رزرو شد
reserved "old_field_name"; // نامِ حذفشده هم رزرو شد
string account_id = 1;
int64 cents = 2;
string currency = 3;
string bank_name = 5; // فیلدِ جدید، شمارهی تازه — امن
}
در یک سیستمِ بزرگ نمیتوانی همهی سرویسها را همزمان دیپلوی کنی. برای چند دقیقه یا چند ساعت، نسخهی قدیمی و جدید کنارِ هم اجرا میشوند (rolling deployment). سازگاریِ دوطرفهی protobuf یعنی این همزیستی امن است: نسخهی جدید میتواند فیلد بفرستد که قدیمی نادیده بگیرد، و برعکس. بدونِ این، هر تغییرِ اسکیما یک دیپلویِ هماهنگِ کابوسوار میشد. ابزارهایی مثلِ Buf حتی در CI بررسی میکنند که تغییراتت سازگاریِ رو به عقب را نشکنند.
کِی از gRPC استفاده کنیم (و کِی نه)
سراغش برو وقتی:
- ارتباطِ داخلیِ بینِ میکروسرویسها داری و کارایی مهم است.
- به streaming (بهویژه دوطرفه) نیاز داری: پخشِ زنده، telemetry، چت.
- میخواهی قراردادِ نوعدار و کدِ تولیدشده در چند زبان داشته باشی (چندزبانه).
- تأخیرِ کم و پیامهای کوچک برایت حیاتی است (mobile، IoT، سیستمهای پرترافیک).
سراغش نرو وقتی:
- APIِ عمومیِ رو به مرورگر میسازی و نمیخواهی proxyِ gRPC-Web را تحمل کنی.
- خوانایی برای انسان و اشکالزداییِ ساده با
curlبرایت مهمتر از سرعت است. - کششدنِ HTTP یک نیازِ اصلی است (مثلِ محتوایِ عمومیِ قابلِ کش).
- تیمت با REST راحت است و مقیاسِ کار، پیچیدگیِ gRPC را توجیه نمیکند.
gRPC یک ابزارِ فوقالعاده است، اما هر پروژهای به آن نیاز ندارد. اگر یک استارتاپِ کوچک با یک monolith داری، REST/JSON کاملاً کافی است و سرعتِ توسعهات را بالا نگه میدارد. gRPC وقتی میدرخشد که مقیاس، تعدادِ سرویسها و نیازِ کارایی بالا برود. انتخابِ فناوری را بر اساسِ مسئله بکن، نه بر اساسِ اینکه چه چیزی جدیدتر یا باکلاستر است.
دامها و بهترین شیوهها
بیایید تلههای پرتکرار را یکجا جمع کنیم:
- channel را در هر فراخوانی نساز. یک channelِ بلندعمر بساز و بازاستفاده کن؛ stub سبک است.
- همیشه deadline بگذار. فراخوانیِ بدونِ deadline میتواند تا ابد آویزان بماند و نخها را قفل کند. deadline پیشفرض نیست؛ باید صریح بگذاری.
- retry فقط برای عملیاتِ idempotent. بعد از
DEADLINE_EXCEEDEDیاUNAVAILABLEکورکورانه retry نکن مگر اینکه عملیات امن باشد. - شمارهی میدان را دوباره استفاده نکن؛ حذفشدهها را
reservedکن. StreamObserverرا thread-safe فرض نکن؛ از چند نخ روی آن هماهنگ بنویس.- پیامِ خیلی بزرگ نفرست. gRPC یک سقفِ پیشفرض (حدودِ ۴ مگابایت) دارد؛ برای فایلِ بزرگ از client-streaming تکهتکه استفاده کن.
- در تولید TLS.
usePlaintext()فقط برای توسعه است. - خطاها را با
Statusِ درست برگردان (مثلِNOT_FOUND،INVALID_ARGUMENT،PERMISSION_DENIED) نه یک استثنای عمومی؛ کدِ وضعیتِ درست به کلاینت کمک میکند رفتارِ درست نشان دهد. option java_multiple_files = trueرا بگذار تا هر پیام کلاسِ جدای خودش را بگیرد و کدِ تمیزتری داشته باشی.
پرسشهای مصاحبه
حالا وقتِ تمرین است. اول خودت جواب بده، بعد پاسخ را باز کن.
RPC یعنی «فراخوانی رویهی راهدور»: یک فراخوانیِ شبکهای که مثلِ یک فراخوانیِ تابعِ محلی بهنظر میرسد. توهمِ آن این است که شبکه «ناپدید» شده. خطرناک است چون شبکه واقعاً ناپدید نشده — تأخیر، شکست، قطعی و مهلتزمانی هنوز هستند. برنامهنویسی که RPC را مثلِ فراخوانیِ محلی میبیند، به deadline، retry و مدیریتِ خطا فکر نمیکند و در تولید غافلگیر میشود. جملهی طلایی: «RPC کدِ شبکه را زیبا میکند، نه بیخطر.»
دو دلیلِ اصلی: (۱) نامِ فیلدها روی سیم نمیروند؛ فقط شمارهی میدان میرود، پس تکرارِ رشتهها حذف میشود. (۲) اعداد با varint (طولِ متغیر) کدگذاری میشوند، پس اعدادِ کوچک بایتِ کمتری میگیرند، در حالی که JSON همهچیز را بهصورتِ متن ذخیره میکند. بهعلاوه، چون اسکیما از پیش معلوم است، تجزیهگر لازم نیست رشتهها را تطبیق دهد و مستقیم بر اساسِ شماره و نوعِ سیمی داده را میخواند. نتیجه معمولاً ۳ تا ۱۰ برابر کوچکتر و بهمراتب سریعتر است.
برچسبِ هر فیلد اینطور ساخته میشود: (field_number << 3) | wire_type. یعنی شمارهی میدان ۳ بیت به چپ شیفت میشود و ۳ بیتِ پایین، نوعِ سیمی را نگه میدارد. چهار نوعِ سیمیِ فعال: VARINT با شمارهی ۰ (int/bool/enum)، I64 با شمارهی ۱ (fixed64/double)، LEN با شمارهی ۲ (string/bytes/پیامِ تودرتو)، و I32 با شمارهی ۵ (fixed32/float). نوعِ سیمی به تجزیهگر میگوید چند بایتِ بعدی چطور خوانده شود، و همین اجازه میدهد فیلدهای ناشناخته امن رد شوند.
gRPC به سه ویژگیِ HTTP/2 نیاز دارد: (۱) multiplexing — چند RPC همزمان روی یک اتصال بدونِ مسدودشدنِ سرِ صف، (۲) streamهای دوطرفه که برای client/bidi streaming لازماند، و (۳) trailerها — چون وضعیتِ پایانِ gRPC (status) بعد از بدنه بهصورتِ trailer برمیگردد و HTTP/1.1 این را بهخوبی پشتیبانی نمیکند. بههمین دلیل مرورگرها هم مستقیم نمیتوانند gRPC بزنند و به gRPC-Web + proxy نیاز است.
(۱) unary: یک درخواست، یک پاسخ — مثلِ گرفتنِ موجودیِ حساب. (۲) server-streaming: یک درخواست، جریانی از پاسخ — مثلِ اشتراکِ قیمتِ زنده یا نتایجِ صفحهبندیشده. (۳) client-streaming: جریانی از درخواست، یک پاسخ — مثلِ آپلودِ تکهتکهی یک فایلِ بزرگ یا ارسالِ دستهای متریکها. (۴) bidirectional: جریان در هر دو جهت بهطور مستقل — مثلِ چت یا یک بازیِ بلادرنگ. در .proto اینها فقط با کلمهی stream روی درخواست و/یا پاسخ مشخص میشوند.
blocking stub مقدار را مستقیم برمیگرداند و منتظر میماند؛ سادهترین است و برای unary و server-streaming (که Iterator میدهد) خوب است، اما client/bidi streaming را پشتیبانی نمیکند. future stub یک ListenableFuture برمیگرداند؛ ناهمگامِ سبک، فقط برای unary. async stub با StreamObserver و callback کار میکند و تنها گزینهای است که هر چهار نوع، بهویژه client/bidi streaming را پوشش میدهد. انتخاب بستگی به این دارد که همگام میخواهی یا ناهمگام، و آیا streaming داری یا نه.
deadline یک نقطهی زمانیِ مطلق است (نه یک مدت)، و مهمتر اینکه روی سیم منتشر میشود: وقتی A سرویسِ B و B سرویسِ C را صدا میزند، همان deadlineِ مطلق در کلِ زنجیره جاری میشود. پس بهجای اینکه هر لایه timeoutِ مستقل داشته باشد و مجموعِ انتظارها بالا برود، کلِ زنجیره یک «بودجهی زمانی» مشترک دارد و وقتی مهلت تمام شود همه با هم قطع میشوند. این جلوی هدررفتِ منابع روی کاری که دیگر کسی منتظرش نیست را میگیرد و به مهارِ فروپاشیِ آبشاری کمک میکند.
نه. وقتی deadline میگذرد، کلاینت نمیداند آیا سرور عملیات را انجام داد یا نه — شاید پاسخ در راه بوده. برای عملیاتِ idempotent (مثلِ خواندن) retry بیخطر است. اما برای عملیاتِ غیرِ idempotent (مثلِ «پول را منتقل کن») retryِ کورکورانه ممکن است باعثِ اجرای دوباره شود و مثلاً دو بار پول منتقل کند. راهکار: عملیات را idempotent طراحی کن (مثلاً با کلیدِ یکتا) یا فقط عملیاتِ امن را retry کن.
interceptor یک لایهی رهگیری است که هر فراخوانیِ RPC قبل از رسیدن به منطقِ سرویس (یا کلاینت) از آن رد میشود. مسئلهای که حل میکند «دغدغههای میانبُری» است: لاگ، متریک، احرازِ هویت، ردیابیِ توزیعشده — چیزهایی که در همهی فراخوانیها تکرار میشوند و نباید در منطقِ کسبوکار قاطی شوند. ServerInterceptor سمتِ سرور و ClientInterceptor سمتِ کلاینت داریم، و توکن/متادیتا از طریقِ Metadata (همان هدرهای HTTP/2) خوانده میشود. مزیت: منطقِ اصلی از این دغدغهها کاملاً جدا میماند.
هیچ چیزی نمیشکند — به شرطِ اینکه شمارهی میدانِ تازهای داده باشی. نسخهی قدیمی که این شماره را نمیشناسد، آن را بهعنوانِ «فیلدِ ناشناخته» نادیده میگیرد (در proto3 حتی نگهش میدارد). و اگر نسخهی قدیمی دادهای بدونِ آن فیلد بفرستد، نسخهی جدید مقدارِ پیشفرض را میبیند. این همان سازگاریِ دوطرفه است که rolling deployment را امن میکند. اما اگر شمارهی میدانِ قدیمی را دوباره استفاده میکردی، داده بهاشتباه تفسیر میشد — پس شمارههای حذفشده باید reserved شوند.
proto3 عمداً کلیدواژهی required را حذف کرد چون required در proto2 منبعِ باگ بود: اگر فیلدی را required میکردی، دیگر نمیتوانستی بعداً بدونِ شکستنِ سازگاری حذفش کنی، و اگر یک طرف آن را نمیفرستاد کلِ تجزیه شکست میخورد. در proto3 هر فیلد اختیاری است و اگر نبود مقدارِ پیشفرض (صفر، رشتهی خالی...) میگیرد. همین باعث میشود افزودن و حذفِ فیلدها امن باشد و اسکیما بتواند در طول زمان تکامل یابد بدونِ اینکه نسخههای مختلف همدیگر را بشکنند.
چون هر کدام برای ترافیکِ متفاوتی بهینهاند. رو به بیرون (north-south، سمتِ مرورگر و توسعهدهندگانِ متفرقه) REST/JSON استفاده میشود چون خوانا، قابلِ کش، سازگار با مرورگر و ساده برای اشکالزدایی است. درونِ سیستم (east-west، بینِ میکروسرویسها) gRPC استفاده میشود چون سریعتر، فشردهتر، نوعدار و دارای streaming است و کدِ چندزبانه تولید میکند. اغلب یک API gateway لبه است که REST را میپذیرد و در پشتِ صحنه به gRPC ترجمه میکند. این «بهترینِ هر دو دنیا» است و نشان میدهد که انتخابِ فناوری باید بر اساسِ نوعِ مسئله باشد نه سلیقه.
چون channel یک شیءِ سنگین است که یک استخرِ اتصالِ HTTP/2 و منابعِ نخ را مدیریت میکند. ساختنش گران است و اگر بهازای هر درخواست یکی بسازی، بهسرعت اتصالها و منابع را تمام میکنی و کارایی سقوط میکند. الگوی درست: channel را یک بار بلندعمر بساز و بازاستفاده کن؛ در مقابل، stub سبک است و میتوانی بهازای هر فراخوانی با تنظیماتی مثلِ withDeadlineAfter نمونهی تازه بسازی. فراموشنکردنِ shutdown() channel هنگامِ خاموشیِ برنامه هم مهم است.
- RPC یک فراخوانیِ شبکهای است که مثلِ فراخوانیِ تابعِ محلی بهنظر میرسد؛ اما شبکه ناپدید نمیشود — به deadline و مدیریتِ خطا فکر کن.
- Protocol Buffers دو چیز است: یک IDL (فایلِ
.proto) و یک فرمتِ سیمیِ دودویی. کوچک و سریع است چون فقط شمارهی میدان روی سیم میرود و اعداد با varint فشرده میشوند. برچسب =(field_number << 3) | wire_type. - gRPC روی HTTP/2 سوار است تا از multiplexing، streamهای دوطرفه و trailerها بهره ببرد؛ به همین دلیل مرورگرها به gRPC-Web نیاز دارند.
- تولیدِ کد از
.protoسه stub در جاوا میسازد: blocking، future و async — و async تنها گزینه برای client/bidi streaming است. - چهار نوعِ فراخوانی: unary، server-streaming، client-streaming، bidirectional.
- Deadlineها مطلق و منتشرشوندهاند و بودجهی زمانیِ کلِ زنجیره را کنترل میکنند؛ لغو به سرور منتقل میشود. Interceptorها دغدغههای میانبُری را جدا نگه میدارند.
- gRPC در برابر REST: gRPC برای ارتباطِ داخلیِ پرکارایی، REST برای APIِ رو به مرورگر و خوانا — و اغلب هر دو با هم.
- تکاملِ اسکیما امن است اگر شمارهی میدان را هرگز عوض/بازاستفاده نکنی و حذفشدهها را
reservedکنی. proto3 هنوز توصیه میشود؛ editions آینده است.
Let's start with a confession: when two services in a system need to talk, most of us reach for REST over JSON by reflex. It works — but hidden behind that convenience is a pile of manual labor, string-juggling, and guesswork. Both sides must silently agree on field names, hand-parse JSON, and hope that a small change on one end doesn't quietly break the other. gRPC exists to turn that unwritten agreement into a formal, strongly-typed contract that compilers and tooling actually understand. In this chapter you won't just learn how to write a gRPC service — you'll understand why the technology exists, what happens behind the curtain, and where you should and shouldn't reach for it.
The path we'll walk together:
- What RPC is — remote procedure calls and why we want the network to "disappear."
- Protocol Buffers — the contract language (IDL) and the wire format that makes data compact and fast.
- gRPC over HTTP/2 — why HTTP/2 is the heart of the story and what it unlocks.
- Code generation — from a
.protofile to Java stubs and classes. - The four call types — unary, server-streaming, client-streaming, and bidirectional with real code.
- Deadlines, cancellation, interceptors — controlling time and cross-cutting concerns.
- gRPC vs REST/JSON — a comparison table and honest trade-off analysis.
- Schema evolution — how to change the contract without breaking consumers.
- Pitfalls, best practices, and interview questions with full answers.
Part 0 — a few words you must feel before we start
Before code, a few terms recur throughout. Let me plant them in your mind now.
- Serialization: turning an in-memory object into a stream of bytes you can send over the network or store on disk. The reverse is deserialization. JSON is a text serialization format; Protocol Buffers is a binary one.
- IDL: Interface Definition Language — a language-independent file that says "here's the contract": which messages exist, with which fields, and which methods. A
.protofile is an IDL. - stub: generated code that, on the client, looks like a local object but actually dispatches the call over the network. The name means "the stand-in piece" for the real functions living on the other side.
- HTTP/2: the second version of HTTP that carries many requests concurrently over a single TCP connection (multiplexing), compresses headers, and supports bidirectional streaming. gRPC rides on top of it.
What RPC is: when we want the network to vanish
Imagine you need the answer to a hard calculation but can't do it yourself. One way: write a letter, seal it, address it, mail it, and wait for a reply — that's the message-passing model. The easier way: pick up the phone, dial the expert, ask "what's this number plus that one?" and hear the answer instantly — as if they were sitting next to you. RPC is exactly that phone call: you "call" a function that looks local, but it actually runs on another machine. All the envelope-and-stamp work (serialization, network, parsing) hides behind the receiver.
RPC stands for Remote Procedure Call. The central idea is to make a network call look like an ordinary function call. Instead of hand-building an HTTP request, constructing a URL, turning the body into JSON, sending it, and parsing the response, you just write:
BalanceResponse resp = accountService.getBalance(request);
as if it ran right here. But the illusion of "localness" is a double-edged sword. The network doesn't actually vanish; it just hides. Behind that one call there is still latency, packets can be lost, the server may be down, and the connection may drop mid-flight.
A classic list called the Fallacies of Distributed Computing says junior engineers assume: the network is reliable, latency is zero, bandwidth is infinite, the network is secure, and so on — every one of them false. RPC makes network code beautiful, but it does not excuse you from thinking about failure, deadlines, and retries. Every . you see on a stub is a hidden network round-trip.
gRPC is one of today's most popular RPC frameworks, open-sourced by Google in 2015. The "g" officially stands for a different word in every release (the team's running joke), but its two pillars are fixed: Protocol Buffers for the contract and data, and HTTP/2 for transport. Let's crack open the first pillar.
Protocol Buffers: make the contract formal
JSON is like a handwritten note: every time you spell out the field name in full ("accountId": ...), every number is stored as text, and the other side has to guess what each key means. Protocol Buffers is like a standardized printed form: the boxes are pre-numbered, both sides hold the identical form, and you just drop values into the boxes. You don't need to write "this is the account-name box" next to each value — because box number 1 is always the account name. The result: far more compact, faster, and unambiguous.
Protocol Buffers (we'll shorten it to "protobuf") is two things that come together:
- an Interface Definition Language (IDL) you write in
.protofiles that describes the shape of your data and services; - a binary serialization format that puts that data on the wire in the most compact form possible.
A sample .proto:
syntax = "proto3";
package bank.v1;
option java_package = "com.example.bank.v1";
option java_multiple_files = true;
// A message: a bundle of numbered fields
message BalanceRequest {
string account_id = 1;
}
message BalanceResponse {
string account_id = 1;
int64 cents = 2; // balance in cents, to avoid float errors
string currency = 3;
}
// A service: a set of remote methods
service AccountService {
rpc GetBalance(BalanceRequest) returns (BalanceResponse);
}
Let's unpack a few things:
syntax = "proto3"says we use version 3 of the language (we'll get to "editions" later).- Every field has a field number: the
= 1,= 2... It's these numbers that travel on the wire, not the field names. This is the key to understanding everything. - We chose
int64for money because floating-point (double) money is bug-prone; always keep money as an integer count of the smallest unit (cents). - The
serviceblock defines remote methods. protobuf isn't only about data; it describes services too — and this is where gRPC enters.
In JSON, if you send an array of a thousand accounts, the string "accountId" is repeated a thousand times in the bytes. In protobuf, only the number 1 (a single byte) travels. That difference typically makes messages 3–10× smaller and parsing several times faster — because the parser doesn't need to read and match strings, it just reads a number.
The wire format: behind the bytes
Now for the compaction magic. When protobuf serializes a field, it writes a tag for that field followed by the value. The tag is built with this formula:
tag = (field_number << 3) | wire_type
That is, the field number is shifted left by 3 bits and the low 3 bits hold the wire type. The wire type tells the parser "how many bytes the next value is and how to read it." There are only a handful of wire types:
| Wire type | Number | For which types |
|---|---|---|
| VARINT | 0 | int32, int64, uint32, uint64, bool, enum, sint32, sint64 |
| I64 | 1 | fixed64, sfixed64, double |
| LEN | 2 | string, bytes, embedded message, packed repeated |
| I32 | 5 | fixed32, sfixed32, float |
(Types 3 and 4 belonged to deprecated "groups.")
The heart of compaction is the varint — variable-width integer. Instead of every integer always taking 4 or 8 bytes, protobuf packs small numbers into fewer bytes. Each byte holds 7 bits of data, and the 8th (most-significant) bit is a continuation bit: if it's 1, "read the next byte too." So the number 1 is a single byte, but a billion takes several.
A classic gotcha: in varint encoding a negative int32 always takes the full 10 bytes! Because negatives are represented in two's complement, the high bits are all 1s. If a field is usually negative, use sint32 instead of int32; that type uses ZigZag encoding, which maps negatives to small positives (0→0, -1→1, 1→2, -2→3...) so they compress again. Picking the right type directly affects wire size.
Let's decode a real message to feel how tiny it is. Suppose message T { int32 a = 1; } with a = 150. On the wire it becomes:
0x08 0x96 0x01
0x08= tag:(1 << 3) | 0→ field 1, wire type VARINT.0x96 0x01= the varint for 150.
Just 3 bytes. The same data in JSON — {"a":150} — is nine bytes, and that's without counting whitespace.
Because the parser sees only the number, not the name, two crucial consequences follow: (1) you can rename a field in .proto and nothing breaks, because the name is only for the programmer. (2) But if you change or reuse a field number, disaster: old data gets misinterpreted under the new number. The golden rule: never change and never reuse a field number. This is the single rule the entire story of backward compatibility rests on.
proto3 and the new "editions"
For years we had two language versions: proto2 and proto3. Since 2023, the protobuf team introduced a new concept called Protobuf Editions, where instead of syntax = "proto3" you write edition = "2024" (the newest edition to date). The idea is that behaviors that used to differ between proto2 and proto3 (like "field presence" or how something is encoded) are now individually configurable features.
The protobuf team's own official advice is that most users should stick with proto3 for now and not rush to adopt editions until the ecosystem matures. So this chapter focuses on proto3; just know that editions is the language's future and is bidirectionally compatible with proto2/proto3 (the wire format doesn't change). A tool called Prototiller exists for automated migration.
gRPC over HTTP/2: why transport matters
protobuf only solves the data and the contract. Now we must carry those bytes from A to B. gRPC does this over HTTP/2, and that choice is no accident.
HTTP/1.1 is like a single-lane road: each car (request) must wait for the one ahead to pass. If a slow request gets in front, everyone stacks up behind it — this is called head-of-line blocking. HTTP/2 is like a multi-lane highway over one connection: dozens of requests travel concurrently, interleaved (multiplexed), each in its own stream, without waiting on each other. That single feature is what makes gRPC's streaming and high performance possible.
Four things HTTP/2 gives gRPC:
- Multiplexing: many concurrent RPC calls over one TCP connection, no serial queue.
- Bidirectional streaming: because each stream can stay open independently, server and client can send and receive simultaneously.
- Header compression (HPACK): HTTP headers are compressed, so metadata is cheap.
- Binary framing: HTTP/2 is itself binary, matching protobuf's binary payload.
Each RPC call is a POST to the path /<package>.<Service>/<Method>. The request body is serialized protobuf messages, each wrapped with a 5-byte prefix (1 byte compression flag + 4 bytes length). The final status returns in trailers — headers that arrive after the body. This is exactly why gRPC needs HTTP/2; HTTP/1.1 doesn't support trailers well. For browsers, which have no access to raw HTTP/2 frames, there's a variant called gRPC-Web that uses a proxy.
Code generation: from .proto to Java
Here's where the beauty shows. You write the .proto, and a compiler called protoc (with the protoc-gen-grpc-java plugin) generates Java classes from it: message classes (immutable, with a Builder pattern) and service stubs. In the Maven world you typically automate this with a plugin. The key dependencies:
<!-- dependencies (example version: gRPC-Java 1.82.x) -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.82.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.82.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.82.1</version>
</dependency>
From each service in .proto, three kinds of stub are generated for Java. Understanding their differences matters:
| Stub type | Call signature | When | streaming |
|---|---|---|---|
| BlockingStub | returns the value directly and waits | simplest; synchronous code | unary and server-stream |
| FutureStub | returns a ListenableFuture |
lightweight async with callback | unary only |
| (Async) Stub | works with StreamObserver and callbacks |
all cases, especially streaming | all four |
A common mistake: expecting to write everything with the blocking stub. But the blocking stub cannot do client-streaming or bidirectional — because there you must send messages one by one, which is inherently asynchronous. For those two cases you must reach for the async stub and StreamObserver. The blocking stub only fits unary and server-streaming (which returns an Iterator).
On the server side, protoc generates an abstract AccountServiceImplBase class that you extend and implement. Let's see a complete simple service.
The four call types
gRPC has four communication patterns, all coming from the same .proto file — distinguished only by the stream keyword.
service ChatService {
// 1) unary: one request, one response
rpc GetBalance(BalanceRequest) returns (BalanceResponse);
// 2) server-streaming: one request, a stream of responses
rpc WatchPrices(WatchRequest) returns (stream PriceTick);
// 3) client-streaming: a stream of requests, one response
rpc UploadPoints(stream Point) returns (UploadSummary);
// 4) bidirectional: streams in both directions, independent
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
Picture them as four styles of talk: unary is a simple Q&A ("What time is it?" "Three."). server-streaming is like tuning in to the radio: you switch it on once and a stream of news flows out. client-streaming is like uploading a big file in chunks: you keep sending and get one receipt at the end. bidirectional is like a real phone call: both parties talk and listen whenever they like, simultaneously.
1) Unary — the backbone
Server side:
import io.grpc.stub.StreamObserver;
import io.grpc.Status;
public class AccountServiceImpl extends AccountServiceGrpc.AccountServiceImplBase {
@Override
public void getBalance(BalanceRequest request,
StreamObserver<BalanceResponse> responseObserver) {
String id = request.getAccountId();
if (id.isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("account_id is required")
.asRuntimeException());
return;
}
BalanceResponse resp = BalanceResponse.newBuilder()
.setAccountId(id)
.setCents(1_250_00) // 1250.00
.setCurrency("USD")
.build();
responseObserver.onNext(resp); // send one response
responseObserver.onCompleted(); // close the stream
}
}
Notice that even in unary we use StreamObserver: onNext sends the value and onCompleted says "done." Starting the server:
import io.grpc.Server;
import io.grpc.ServerBuilder;
Server server = ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.build()
.start();
server.awaitTermination();
Client side with a blocking stub — the cleanest form:
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext() // dev only; use TLS in production
.build();
AccountServiceGrpc.AccountServiceBlockingStub stub =
AccountServiceGrpc.newBlockingStub(channel);
BalanceResponse resp = stub.getBalance(
BalanceRequest.newBuilder().setAccountId("A-42").build());
System.out.println(resp.getCents() + " " + resp.getCurrency());
channel.shutdown();
A ManagedChannel is a heavy, long-lived object managing a pool of HTTP/2 connections. Build it once for the lifetime of the app and reuse it — not per call. Stubs, by contrast, are lightweight; creating them is cheap and you can tune them per-call with withDeadlineAfter. A common beginner mistake is creating a channel per request, which quickly exhausts connections.
2) Server-streaming
The server calls onNext several times and onCompleted at the end:
@Override
public void watchPrices(WatchRequest request,
StreamObserver<PriceTick> obs) {
for (int i = 0; i < 5; i++) {
obs.onNext(PriceTick.newBuilder()
.setSymbol(request.getSymbol())
.setPriceCents(10_000 + i * 25)
.build());
}
obs.onCompleted();
}
The client with a blocking stub gets an Iterator:
Iterator<PriceTick> ticks = blockingStub.watchPrices(
WatchRequest.newBuilder().setSymbol("ACME").build());
while (ticks.hasNext()) {
System.out.println(ticks.next().getPriceCents());
}
3) Client-streaming
Here the client sends many messages. Because you must return a StreamObserver yourself, the async stub is required:
// server side
@Override
public StreamObserver<Point> uploadPoints(
StreamObserver<UploadSummary> responseObs) {
return new StreamObserver<>() {
int count = 0;
long sum = 0;
@Override public void onNext(Point p) { count++; sum += p.getValue(); }
@Override public void onError(Throwable t) { /* log */ }
@Override public void onCompleted() {
responseObs.onNext(UploadSummary.newBuilder()
.setCount(count).setTotal(sum).build());
responseObs.onCompleted(); // one final response
}
};
}
Note that the server method returns a StreamObserver before receiving any message; it's that observer whose onNext fires with each message. The client's onCompleted tells the server "no more messages," and that's when the server sends its summary response.
4) Bidirectional streaming
The most powerful form: both sides send and receive independently.
@Override
public StreamObserver<ChatMessage> chat(
StreamObserver<ChatMessage> responseObs) {
return new StreamObserver<>() {
@Override public void onNext(ChatMessage msg) {
// echo immediately — no need to wait for the whole input
responseObs.onNext(ChatMessage.newBuilder()
.setText("echo: " + msg.getText()).build());
}
@Override public void onError(Throwable t) { }
@Override public void onCompleted() { responseObs.onCompleted(); }
};
}
Within a single stream, messages are delivered in order — that's guaranteed. But in bidi, the relative order of client vs server messages is not guaranteed: the server can start sending before the client is done. Also, StreamObserver is not thread-safe; if you write to one observer from multiple threads you must synchronize yourself, or messages will interleave into garbage.
Deadlines and cancellation: controlling time
Imagine telling someone "I'll wait for you until 5 o'clock; after that I'm leaving." A deadline in gRPC is exactly that: an absolute point in time by which the client says "I'll wait this long." The subtle point is that a deadline is not a duration (like "5 seconds") but an absolute end time — and it automatically propagates over the wire to the server and from the server to downstream services.
In Java you usually set it with withDeadlineAfter, which internally converts it into an absolute deadline:
BalanceResponse resp = blockingStub
.withDeadlineAfter(500, TimeUnit.MILLISECONDS)
.getBalance(request);
If the server doesn't respond by then, the call fails with status DEADLINE_EXCEEDED.
Suppose service A calls B, and B calls C. If each has an independent 5-second timeout, in the worst case the user waits 15 seconds. But a gRPC deadline is absolute and propagating: A sets a 5-second deadline, and that same absolute end time flows to B and then to C. When the deadline expires, the entire chain is cut simultaneously, and no service wastes time on work nobody is waiting for anymore. This "time budget" is one of the most important tools for preventing cascading collapse.
On the server side you can check whether anyone is still waiting for the answer — that is, whether the call was cancelled or its deadline passed:
if (Context.current().isCancelled()) {
responseObserver.onError(Status.CANCELLED
.withDescription("client gone").asRuntimeException());
return; // don't start the heavy work
}
Cancellation also happens when the client abandons a call before it finishes (e.g., the user closes the tab). gRPC propagates that cancellation to the server, and the server can stop the half-finished work. Context is the mechanism that carries the deadline and cancellation signal along the call chain.
When a deadline passes, the client doesn't know whether the server did the work or not — the response may have been in flight. So for non-idempotent operations (like "transfer money") you can't blindly retry, or money might be transferred twice. The fix: design the operation to be idempotent (e.g., with a unique key) or only retry safe operations. The deadline doesn't solve the problem; it just bounds the wait.
Interceptors: cross-cutting interception
An interceptor is like an airport security gate: every passenger (every RPC call) passes through it before reaching the destination. There you can check the ticket (authentication), record the time (metrics), or even turn the passenger back (rejection). The beauty is that the core service logic knows nothing of this gate; the interception is fully separate.
Interceptors solve cross-cutting concerns: logging, metrics, authentication, distributed tracing — things that repeat across all calls and shouldn't be tangled into business logic. There are two sides: ServerInterceptor and ClientInterceptor.
A simple server interceptor that logs each call's duration:
public class TimingInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
long start = System.nanoTime();
String method = call.getMethodDescriptor().getFullMethodName();
ServerCall<ReqT, RespT> wrapped =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override public void close(Status status, Metadata trailers) {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf("%s -> %s in %dms%n",
method, status.getCode(), ms);
super.close(status, trailers);
}
};
return next.startCall(wrapped, headers);
}
}
And installing it on the server:
ServerBuilder.forPort(9090)
.addService(new AccountServiceImpl())
.intercept(new TimingInterceptor())
.build();
Metadata is the HTTP/2 headers — where an auth token (like Authorization: Bearer ...) or a trace id lives. A sample authentication: the interceptor reads the token from Metadata, validates it, and if invalid rejects the call with Status.UNAUTHENTICATED — all without adding a single line to the service code.
Interceptors run as a chain. You usually want authentication to run before logging and business logic, so an unauthorized call is rejected early. In gRPC-Java the execution order is the reverse of the addition order, so think of them like onion layers and test the ordering. Also, for propagating context (like tracing) use Context, not static fields.
gRPC vs REST/JSON: which and when?
Now that we've seen both, let's compare honestly. gRPC isn't "better"; it's different, for different problems.
| Feature | gRPC + protobuf | REST + JSON |
|---|---|---|
| Data format | compact binary | human-readable text |
| Contract | formal .proto schema (typed) |
usually informal; optional OpenAPI |
| Transport | HTTP/2 (required) | HTTP/1.1 or 2 |
| Streaming | four types, including bidi | limited (SSE, separate WebSocket) |
| Code generation | built-in and standard | via extra tooling |
| Human readability | no (needs tooling) | yes, with curl |
| Browser support | only via gRPC-Web + proxy | native and full |
| Speed/size | much faster and smaller | slower and larger |
| HTTP caching | weak | strong (GET is cacheable) |
| Learning curve | steeper | gentler |
If you're building a public API for browsers and third-party developers, where readability, caching, and simplicity matter → REST/JSON. If you're designing internal service-to-service communication (east-west traffic), where performance, a typed contract, and streaming are critical → gRPC. Many companies use both: gRPC behind the scenes between services, and a REST/JSON (or GraphQL) layer facing outward for the browser. This "best of both worlds" is a very common pattern.
An important fact: JavaScript inside a browser can't call gRPC directly, because the browser doesn't expose raw HTTP/2 frames and trailers. You must use gRPC-Web, which puts a proxy (like Envoy) in the middle. This limitation alone is the main reason REST is used for browser-facing APIs. If someone in an interview asks "why doesn't the front-end call gRPC directly?", this is the answer.
Schema evolution: change the contract without breaking
protobuf's biggest real-world strength is backward and forward compatibility. That means the server and client can run different versions of the .proto and still work correctly. This miracle comes from that same "field number" principle.
Remember we compared protobuf to a printed form? Now imagine the office adds a new box to the form (number 7). People who filled in the old form (without box 7) are fine: box 7 stays empty and the office assumes a default value. And someone who filled in the new form and hands it to an old clerk? The clerk doesn't recognize box 7, so they simply ignore it and read the rest as usual. Neither breaks. That's exactly protobuf's behavior.
The golden rules of safe evolution:
- Adding a new field is safe — as long as it gets a fresh field number. Old parsers ignore it as an "unknown field" (and proto3 even keeps and re-emits it).
- Removing a field is safe — but you must reserve its number with
reservedso nobody accidentally reuses it later. - Never change a field number. Rename it, fine; but the number is sacred.
- Never give an incompatible type. Changing
int32tostringcorrupts data. Some changes are safe (likeint32↔int64within range) but most aren't. - Don't make fields
required. proto3 has norequiredat all, and that's correct: every field is optional with a default value, which is what makes evolution possible.
message BalanceResponse {
reserved 4; // removed number is reserved
reserved "old_field_name"; // removed name reserved too
string account_id = 1;
int64 cents = 2;
string currency = 3;
string bank_name = 5; // new field, fresh number — safe
}
In a large system you can't deploy every service simultaneously. For a few minutes or hours, the old and new versions run side by side (rolling deployment). protobuf's bidirectional compatibility means that coexistence is safe: the new version can send a field the old ignores, and vice versa. Without this, every schema change would be a nightmare of coordinated deployment. Tools like Buf even check in CI that your changes don't break backward compatibility.
When to use gRPC (and when not)
Reach for it when:
- you have internal microservice-to-microservice communication and performance matters.
- you need streaming (especially bidirectional): live feeds, telemetry, chat.
- you want a typed contract and generated code across multiple languages (polyglot).
- low latency and small messages are critical (mobile, IoT, high-traffic systems).
Don't reach for it when:
- you're building a public browser-facing API and don't want to tolerate a gRPC-Web proxy.
- human readability and easy debugging with
curlmatter more than speed. - HTTP caching is a core need (like cacheable public content).
- your team is comfortable with REST and the scale doesn't justify gRPC's complexity.
gRPC is a fantastic tool, but not every project needs it. If you're a small startup with a monolith, REST/JSON is entirely enough and keeps your development speed high. gRPC shines when scale, service count, and performance needs rise. Choose technology based on the problem, not on what's newer or flashier.
Pitfalls and best practices
Let's gather the frequent traps in one place:
- Don't build a channel per call. Build one long-lived channel and reuse it; stubs are lightweight.
- Always set a deadline. A call with no deadline can hang forever and pin threads. There is no default deadline; you must set one explicitly.
- Retry only idempotent operations. After
DEADLINE_EXCEEDEDorUNAVAILABLE, don't blindly retry unless the operation is safe. - Don't reuse field numbers;
reservedthe removed ones. - Don't assume
StreamObserveris thread-safe; synchronize writes from multiple threads. - Don't send a giant message. gRPC has a default cap (~4 MB); for large files use chunked client-streaming.
- TLS in production.
usePlaintext()is dev-only. - Return errors with the right
Status(likeNOT_FOUND,INVALID_ARGUMENT,PERMISSION_DENIED) rather than a generic exception; the correct status code helps the client behave correctly. - Set
option java_multiple_files = trueso each message gets its own class and your code is cleaner.
Interview Questions
Now it's time to drill. Answer each yourself first, then open the answer.
RPC means "Remote Procedure Call": a network call that looks like a local function call. Its illusion is that the network has "vanished." It's dangerous because the network hasn't actually vanished — latency, failure, disconnection, and timeouts are still there. A programmer who treats RPC like a local call won't think about deadlines, retries, and error handling, and gets surprised in production. The golden line: "RPC makes network code beautiful, not safe."
Two main reasons: (1) field names don't travel on the wire; only the field number does, eliminating repeated strings. (2) numbers are encoded with varints (variable width), so small numbers take fewer bytes, whereas JSON stores everything as text. Additionally, because the schema is known in advance, the parser doesn't need to match strings and reads data directly by number and wire type. The result is typically 3–10× smaller and considerably faster.
Each field's tag is built as (field_number << 3) | wire_type. The field number is shifted left 3 bits, and the low 3 bits hold the wire type. The four active wire types: VARINT number 0 (int/bool/enum), I64 number 1 (fixed64/double), LEN number 2 (string/bytes/embedded message), and I32 number 5 (fixed32/float). The wire type tells the parser how to read the next bytes, which is exactly what lets unknown fields be skipped safely.
gRPC needs three HTTP/2 features: (1) multiplexing — many concurrent RPCs over one connection with no head-of-line blocking, (2) bidirectional streams, needed for client/bidi streaming, and (3) trailers — because gRPC's final status returns after the body as a trailer, which HTTP/1.1 doesn't support well. This is also why browsers can't call gRPC directly and need gRPC-Web plus a proxy.
(1) unary: one request, one response — like fetching an account balance. (2) server-streaming: one request, a stream of responses — like a live price subscription or paginated results. (3) client-streaming: a stream of requests, one response — like chunked upload of a big file or batch-sending metrics. (4) bidirectional: streams in both directions independently — like chat or a real-time game. In .proto these are marked simply with the stream keyword on the request and/or response.
The blocking stub returns the value directly and waits; it's simplest and good for unary and server-streaming (which yields an Iterator), but it doesn't support client/bidi streaming. The future stub returns a ListenableFuture; lightweight async, unary only. The async stub works with StreamObserver and callbacks and is the only option covering all four types, especially client/bidi streaming. The choice depends on whether you want synchronous or asynchronous, and whether you have streaming.
A deadline is an absolute point in time (not a duration), and crucially it propagates over the wire: when A calls B and B calls C, the same absolute deadline flows through the whole chain. So instead of each layer having an independent timeout and the total wait ballooning, the entire chain shares one "time budget," and when it expires everyone is cut together. This prevents wasting resources on work nobody is waiting for anymore and helps contain cascading failures.
No. When a deadline passes, the client doesn't know whether the server did the operation — the response may have been in flight. For idempotent operations (like a read), retry is safe. But for non-idempotent operations (like "transfer money"), a blind retry could cause a double execution and, say, transfer money twice. The fix: design the operation to be idempotent (e.g., with a unique key) or only retry safe operations.
An interceptor is an interception layer through which every RPC call passes before reaching the service logic (or, on the client, before being sent). The problem it solves is "cross-cutting concerns": logging, metrics, authentication, distributed tracing — things repeated across all calls that shouldn't be tangled into business logic. There's ServerInterceptor on the server side and ClientInterceptor on the client, and tokens/metadata are read via Metadata (the HTTP/2 headers). The benefit: the core logic stays fully decoupled from these concerns.
Nothing breaks — as long as you gave it a fresh field number. The old version, not recognizing the number, ignores it as an "unknown field" (in proto3 it even keeps it). And if the old version sends data without that field, the new version sees the default value. This is the bidirectional compatibility that makes rolling deployment safe. But if you had reused an old field number, data would be misinterpreted — which is why removed numbers must be reserved.
proto3 deliberately dropped the required keyword because required in proto2 was a source of bugs: once a field was required, you couldn't remove it later without breaking compatibility, and if one side failed to send it, the whole parse failed. In proto3 every field is optional and, if absent, takes a default value (zero, empty string...). This is exactly what makes adding and removing fields safe and lets the schema evolve over time without different versions breaking each other.
Because each is optimized for different traffic. Outward (north-south, toward the browser and third-party developers) REST/JSON is used because it's readable, cacheable, browser-compatible, and simple to debug. Inside the system (east-west, between microservices) gRPC is used because it's faster, more compact, typed, has streaming, and generates polyglot code. Often an edge API gateway accepts REST and translates to gRPC behind the scenes. This is "best of both worlds" and shows technology choice should follow the kind of problem, not taste.
Because a channel is a heavy object managing a pool of HTTP/2 connections and thread resources. It's expensive to build, and if you create one per request you rapidly exhaust connections and resources, and performance collapses. The correct pattern: build the channel once, long-lived, and reuse it; by contrast, a stub is lightweight and you can create a fresh one per call with settings like withDeadlineAfter. Also remember to shutdown() the channel on application exit.
- RPC is a network call that looks like a local function call; but the network doesn't vanish — think about deadlines and error handling.
- Protocol Buffers is two things: an IDL (the
.protofile) and a binary wire format. It's small and fast because only the field number travels and numbers are compressed with varints. Tag =(field_number << 3) | wire_type. - gRPC rides on HTTP/2 to benefit from multiplexing, bidirectional streams, and trailers; this is why browsers need gRPC-Web.
- Code generation from
.protoproduces three Java stubs: blocking, future, and async — and async is the only one for client/bidi streaming. - The four call types: unary, server-streaming, client-streaming, bidirectional.
- Deadlines are absolute and propagating and govern the whole chain's time budget; cancellation propagates to the server. Interceptors keep cross-cutting concerns separate.
- gRPC vs REST: gRPC for high-performance internal communication, REST for readable browser-facing APIs — and often both together.
- Schema evolution is safe if you never change/reuse a field number and
reservedthe removed ones. proto3 is still recommended; editions is the future.