Protobuf
الموقع الرسمي: Protocol Buffers | Google Developers
مقدمة
البرنامج التعليمي الرسمي: Protocol Buffer Basics: Go | Protocol Buffers | Google Developers
Protocol Buffers هي آلية تسلسل بيانات هيكلية مستقلة عن اللغة والبروتوكول وقابلة للتوسيع،开源تها جوجل في عام 2008، وهي أسرع في عمليات فك الحزم وتغليفها، وتُستخدم غالبًا في مجال الاتصالات RPC. يمكن تعريف الطريقة الهيكلية للبيانات، ثم استخدام الكود المصدري المولد خصيصًا لكتابة البيانات الهيكلية وقراءتها بسهولة من مختلف تدفقات البيانات، وتستخدم في لغات متعددة. فيما يلي سنشير إلى Protocol Buffers بـ protobuf.
protobuf شائع نسبيًا، خاصة في مجال Go، حيث يستخدمه gRPC كآلية تسلسل لنقل البروتوكول.
الصيغة
أولًا، لنلقِ نظرة على مثال لفهم الشكل العام لملف protobuf، بشكل عام صياغته بسيطة جدًا ويمكن إتقانها في دقائق. فيما يلي مثال لملف باسم search.proto، ولاحقة ملفات protobuf هي .proto.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
}
message SearchResult {
string data = 1;
}
service SearchService {
rpc Search(SearchRequest) returns(SearchResult);
}- السطر الأول
syntax = "proto3";يشير إلى استخدام صياغةproto3، والافتراضي هو استخدام صياغةproto3. - طريقة الإعلان عن
messageمشابهة للهياكل، وهي الهيكل الأساسي فيproto SearchRequestيحدد ثلاثة حقول، كل حقل له اسم ونوعserviceيحدد خدمة، والخدمة تحتوي على واجهة rpc واحدة أو أكثر- واجهة rpc يجب أن يكون لها معامل واحد وقيمة إرجاع واحدة فقط، وأنواعها يجب أن تكون
message، ولا يمكن أن تكون أنواعًا أساسية.
يجب أيضًا ملاحظة أن كل سطر في ملف proto يجب أن ينتهي بفاصلة منقوطة.
التعليقات
نمط التعليقات متطابق تمامًا مع Go.
syntax = "proto3";
/* تعليق
* تعليق */
message SearchRequest {
string query = 1; //تعليق
string number = 2;
}الأنواع
يمكن لمعدّلات الأنواع أن تظهر فقط في message، ولا يمكن أن تظهر بشكل منفصل.
الأنواع الأساسية
| proto Type | Go Type |
|---|---|
| double | float64 |
| float | float32 |
| int32 | int32 |
| int64 | int64 |
| uint32 | uint32 |
| uint64 | uint64 |
| sint32 | int32 |
| sint64 | int64 |
| fixed32 | uint32 |
| fixed64 | uint64 |
| sfixed32 | int32 |
| sfixed64 | int64 |
| bool | bool |
| string | string |
| bytes | []byte |
المصفوفات
إضافة معدّل repeated قبل النوع الأساسي تشير إلى أن هذا نوع مصفوفة، ويقابل الشرائح في Go.
message Company {
repeated string employee = 1;
}map
تنسيق تعريف نوع map في protobuf كالتالي
map<key_type, value_type> map_field = N;key_type يجب أن يكون رقمًا أو سلسلة نصية، value_type ليس له قيود على النوع، لنرَ مثالًا
message Person {
map<string, int64> cards = 1;
}الحقول
في الواقع، proto ليس نوع قيمة-مفتاح تقليدي، في ملفات proto المُعلنة لن تظهر بيانات ملموسة، ما يتبع = في كل حقل يجب أن يكون رقمًا فريدًا في message الحالي، هذه الأرقام تُستخدم لتحديد وتعريف هذه الحقول في نص الرسالة الثنائية. الأرقام تبدأ من 1، الأرقام 1-15 ستشغل بايت واحد، 16-2047 ستشغل بايتين، لذا حاول إعطاء الأرقام 1-15 للحقول التي تظهر بشكل متكرر لتوفير المساحة، ويجب ترك بعض المساحة للحقول التي قد تظهر بشكل متكرر لاحقًا.
يجب أن تتبع الحقول في message واحد القواعد التالية
singular: الافتراضي هو هذا النوع من الحقول، فيmessageجيد البنية، يمكن أن يكون هناك 0 أو 1 فقط من هذا الحقل، أي لا يمكن تكرار نفس الحقل. الإعلان التالي سيؤدي لخطأ.protobufsyntax = "proto3"; message SearchRequest { string query = 1; string number = 2; string number = 3;//حقل مكرر }optional: مشابه لـsingular، لكن يمكن التحقق بشكل صريح مما إذا كانت قيمة الحقل قد تم تعيينها، وقد يكون هناك الحالتان التاليتانset: سيتم تسلسلهunset: لن يتم تسلسله
repeated: يمكن أن يظهر هذا النوع من الحقول 0 مرات أو أكثر، وسيتم الاحتفاظ بالقيم المكررة بالترتيب (بصراحة هو مصفوفة، يمكن السماح لنفس نوع القيمة بالظهور عدة مرات، والاحتفاظ بها بترتيب الظهور، أي الفهرس)map: حقل من نوع قيمة-مفتاح، طريقة الإعلان كالتاليprotobufmap<string,int32> config = 3;
الحقول المحجوزة
الكلمة المفتاحية reserve يمكنها الإعلان عن حقول محجوزة، بعد الإعلان عن رقم حقل محجوز، لن يتمكن من استخدامه كرقم أو اسم لحقل آخر، وسيحدث خطأ عند الترجمة. الجواب الرسمي من جوجل هو: إذا حذف ملف proto بعض الأرقام في نسخة جديدة، فقد يعيد مستخدمون آخرون استخدام هذه الأرقام المحذوفة في المستقبل، لكن إذا تم التبديل لأرقام النسخة القديمة فسيؤدي ذلك لعدم تطابق الأرقام مع الحقول وبالتالي حدوث أخطاء، الحقول المحجوزة يمكنها تذكيرك في وقت الترجمة بعدم استخدام هذا الحقل المحجوز، وإلا لن تنجح الترجمة.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
reserved "a"; //الإعلان عن حقل محجوز باسم محدد
reserved 1 to 2; //الإعلان عن تسلسل أرقام كحقول محجوزة
reserved 3,4; //الإعلان
}وبهذا، لن ينجح ترجمة هذا الملف.
الحقول المهملة
إذا كان حقل ما مهملًا، يمكن كتابته كالتالي.
message Body {
string name = 1 [deprecated = true];
}التعدادات
يمكن الإعلان عن ثوابت تعداد واستخدامها كنوع للحقل، يجب ملاحظة أن العنصر الأول في التعداد يجب أن يكون صفرًا، لأن القيمة الافتراضية لعناصر التعداد هي العنصر الأول.
syntax = "proto3";
enum Type {
GET = 0;
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}عندما يكون هناك عناصر تعداد بنفس القيمة داخل التعداد، يمكن استخدام أسماء مستعارة للتعداد
syntax = "proto3";
enum Type {
option allow_alias = true; //يجب تفعيل خيار السماح باستخدام الأسماء المستعارة
GET = 0;
GET_ALIAS = 0; //اسم مستعار لعنصر تعداد GET
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}الرسائل المتداخلة
message Outer { // Level 0
message MiddleAA { // Level 1
message Inner { // Level 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleBB { // Level 1
message Inner { // Level 2
int32 ival = 1;
bool booly = 2;
}
}
}يمكن تداخل الإعلان عن message داخل message، تمامًا مثل الهياكل المتداخلة.
Package
يمكنك إضافة معدّل حزمة اختياري لملف protobuf لمنع تعارض الأسماء بين أنواع رسائل البروتوكول.
package foo.bar;
message Open { ... }ثم يمكنك استخدام اسم الحزمة عند تعريف حقول نوع الرسالة:
message Foo {
...
foo.bar.Open open = 1;
...
}Import
الاستيراد يسمح لملفات protobuf متعددة بمشاركة التعريفات، صياغته كالتالي، ولا يمكن حذف امتداد الملف عند الاستيراد.
import "a/b/c.proto";عند الاستيراد تُستخدم المسارات النسبية، هذا المسار النسبي لا يشير إلى المسار النسبي بين ملف الاستيراد والملف المستورد، بل يعتمد على مسار المسح المحدد عند إنشاء الكود بواسطة مترجم protoc. لنفترض وجود هيكل ملفات كالتالي
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.protoإذا كنا نريد إنشاء كود دليل player فقط، وحددنا دليل player فقط عند مسح المسار، فإن الاستيراد المتبادل بين health.proto و player.proto يمكن كتابة اسم الملف مباشرة، مثل استيراد player.proto لـ health.proto.
import "health.proto";إذا قام player.proto باستيراد common.proto أو ملفات من دليل monster، فستفشل الترجمة، لذا الكتابة التالية خاطئة تمامًا، لأن المترجم لا يستطيع العثور على هذه الملفات.
import "../common.proto"; // كتابة خاطئةTIP
بالمناسبة، الرموز ..، . غير مسموح بها في مسارات الاستيراد.
لنفترض أننا حددنا pb_learn كمسار مسح عند الترجمة، فيمكن حينها استيراد ملفات من أدلة أخرى عبر المسار النسبي، والمسار الفعلي المستورد هو العنوان المطلق للملف بالنسبة للعنوان النسبي لـ pb_learn. لنرَ مثال استيراد player.proto لملفات أخرى.
import "common.proto";
imrpot "monster/monster.proto";
import "player/health.proto";حتى health.proto في نفس الدليل يجب استخدام المسار النسبي. لذا في مشروع واحد، عادةً ننشئ مجلدًا منفصلًا لتخزين جميع ملفات protobuf، ونحدده كمسار مسح عند الترجمة، وجميع سلوكيات الاستيراد في هذا الدليل تعتمد على مساره النسبي.
TIP
إذا كنت تستخدم محرر goland، للدليل protobuf الذي أنشأته بنفسك، لن يتمكن من التحليل افتراضيًا، وسيظهر باللون الأحمر، لجعل goland يتعرف عليه يجب تعيين مسار المسح يدويًا، ومبدأه نفس ما شرحناه أعلاه، طريقة الإعداد كالتالي، افتح الإعدادات التالية
File | Settings | Languages & Frameworks | Protocol Buffersفي Import Paths أضف مسار المسح يدويًا، هذا المسار يجب أن يكون نفس المسار المحدد عند الترجمة.

Any
نوع Any يسمح لك باستخدام الرسائل كنوع مضمن دون الحاجة لتعريف proto الخاص بها، يمكننا استيراد نوع معرف من جوجل مباشرة، وهو مدمج ولا يحتاج لكتابة يدوية.
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}جوجل أيضًا عرفت مسبقًا أنواعًا كثيرة أخرى، انتقل إلى protobuf/ptypes at master · golang/protobuf (github.com) لمعرفة المزيد، تشمل بشكل رئيسي
- تغليف الأنواع الأساسية
- نوع الوقت
- نوع Duration
تعريفات protobuf الخاصة بها يجب أن تكون في دليل include لمترجم protoc.
OneOf
شرح الوثائق الرسمية هنا معقد جدًا، ببساطة يعني أن حقلًا قد يكون له عدة أنواع محتملة عند النقل، لكن في النهاية نوع واحد فقط قد يُستخدم، ولا يُسمح بظهور حقول بمعدّل repeated داخله، وهذا يشبه union في لغة C.
message Stock {
// Stock-specific data
}
message Currency {
// Currency-specific data
}
message ChangeNotification {
int32 id = 1;
oneof instrument {
Stock stock = 2;
Currency currency = 3;
}
}Service
الكلمة المفتاحية service يمكنها تعريف خدمة RPC، خدمة RPC تحتوي على عدة واجهات rpc، وتنقسم الواجهات إلى واجهات أحادية وواجهات انسيابية.
message Body {
string name = 1;
}
service ExampleService {
rpc DoSomething(Body) returns(Body);
}الواجهات الانسيابية تنقسم إلى انسيابية أحادية وثنائية الاتجاه، وعادةً تُعدّل بالكلمة المفتاحية stream، لنرَ المثال التالي.
message Body {
string name = 1;
}
service ExampleService {
// انسيابية من العميل
rpc DoSomething(stream Body) returns(Body);
// انسيابية من الخادم
rpc DoSomething1(Body) returns(stream Body);
// انسيابية ثنائية الاتجاه
rpc DoSomething2(stream Body) returns(stream Body);
}ما يسمى بالانسيابية هو إرسال البيانات بشكل متبادل على مدى طويل في اتصال واحد، بدلًا من نمط السؤال والجواب البسيط مثل الواجهات الأحادية.
Empty
empty في الواقع هو message فارغ، يقابل الهيكل الفارغ في Go، نادرًا ما يُستخدم لتعديل الحقول، يُستخدم بشكل أساسي للإشارة إلى أن واجهة rpc معينة لا تحتاج لمعاملات أو ليس لها قيمة إرجاع.
syntax = "proto3";
import "google/protobuf/empty.proto";
service EmptyService {
rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}Option
option عادةً يُستخدم للتحكم في بعض سلوكيات protobuf. مثل التحكم في حزمة الكود المصدري Go المُولّد، يمكن الإعلان عنه كالتالي.
option go_package = "github/jack/sample/pb_learn;pb_learn"ما قبل الفاصلة المنقوطة هو مسار استيراد الملفات المصدرية الأخرى بعد توليد الكود، وما بعد الفاصلة المنقوطة هو اسم الحزمة للملف المُولّد.
يمكنه عمل بعض التحسينات، هناك عدة قيم متاحة، لا يمكن تكرار الإعلان.
SPEED، أعلى درجة تحسين، أكبر حجم كود مُولّد، هذا هو الافتراضي.CODE_SIZE، سيقلل حجم الكود المُولّد، لكنه سيعتمد على الانعكاس للتسلسلLIFE_RUNEIMTE، أصغر حجم كود، لكنه سينقص بعض الميزات.
فيما يلي حالة استخدام
option optimize_for = SPEED;بالإضافة إلى ذلك، يمكن لـ option إضافة بعض المعلومات الوصفية لـ message و enum، ويمكن الحصول على هذه المعلومات عبر الانعكاس، وهذا مفيد جدًا عند التحقق من المعاملات.
الترجمة
الترجمة أي توليد الكود، ما سبق كان مجرد تعريف لملفات protobuf، للاستخدام الفعلي يجب تحويلها لكود مصدري بلغة معينة، نستخدم مترجم protoc لذلك، وهو يدعم لغات متعددة.

التثبيت
لتنزيل المترجم انتقل إلى protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com) لتنزيل أحدث إصدار Release، عادةً يكون ملفًا مضغوطًا
protoc-25.1-win64
│ readme.txt
│
├─bin
│ protoc.exe
│
└─include
└─google
└─protobuf
│ any.proto
│ api.proto
│ descriptor.proto
│ duration.proto
│ empty.proto
│ field_mask.proto
│ source_context.proto
│ struct.proto
│ timestamp.proto
│ type.proto
│ wrappers.proto
│
└─compiler
plugin.protoبعد التنزيل، أضف دليل bin إلى متغيرات البيئة، لتتمكن من استخدام أمر protoc، بعد الانتهاء تحقق من الإصدار، المخرجات الطبيعية تعني نجاح التثبيت
$ protoc --version
libprotoc 25.1المترجم المنزّل لا يدعم لغة Go افتراضيًا، لأن توليد كود Go هو ملف تنفيذي منفصل، بينما اللغات الأخرى مدمجة معًا، لذا نثبّت إضافة لغة Go، تُستخدم لترجمة تعريفات protocbuf لكود مصدري Go.
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latestإذا كنت تحتاج أيضًا لتوليد كود خدمة gRPC، ثبّت الإضافة التالية
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latestبعد التثبيت تحقق من الإصدار
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0
$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0هذه الإضافات هي أيضًا ملفات تنفيذية منفصلة، لكن لا يمكن استدعاؤها إلا عبر protoc، ولا يمكن تنفيذها بشكل منفصل.
(this program should be run by protoc, not directly)بالإضافة إلى ذلك هناك العديد من الإضافات الأخرى، مثل إضافة توليد وثائق واجهة openapi وغيرها، يمكنك البحث عنها بنفسك إذا كنت مهتمًا.
التوليد
لنأخذ المثال السابق، الهيكل كالتالي
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.protoلتوليد الكود، يجب تحديد ثلاثة معاملات إجمالًا
- مسار المسح، يُرشد المترجم من أين البحث عن ملفات
protobufوكيف تحليل مسارات الاستيراد - مسار التوليد، أين توضع الملفات بعد الترجمة
- الملفات المستهدفة، تحديد الملفات المستهدفة المراد ترجمتها.
قبل البدء تأكد من إعداد go_package في ملفات protobuf بشكل صحيح، استخدم protoc -h لعرض المعاملات المدعومة، الأكثر استخدامًا هو -I أو --proto_path، يمكن استخدامه عدة مرات لتحديد مسارات مسح متعددة، مثل
$ protoc --proto_path=./pb_learn --proto_path=./third_partyتحديد مسار المسح فقط ليس كافيًا، يجب أيضًا تحديد مسار التوليد والملفات المستهدفة protobuf، هنا نولّد ملفات go لذا نستخدم معامل --go_out، مدعوم من إضافة protoc-gen-go التي نزّلناها سابقًا.
$ cd pb_learn
$ protoc --proto_path=. --go_out=. common.proto
$ ls
common.pb.go common.proto monster/ player/معامل --go_out هو مسار التوليد المحدد، . يشير للمسار الحالي، common.proto هو الملف المحدد للترجمة. إذا كنت تريد توليد كود grpc (بشرط تثبيت إضافة grpc)، يمكنك إضافة معامل --go-grpc_out (إذا لم يكن هناك تعريف service في ملف protobuf، لن يتم توليد الملف المقابل).
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto
$ ls
common.pb.go common.proto common_grpc.pb.go monster/ player/common.pb.go هو تعريف نوع protobuf المُولّد، common_grpc.pb.go هو كود gRPC المُولّد، وهو مبني على الأول، إذا لم يتم توليد تعريف للغة المقابلة، فلا يمكن توليد كود gRPC.
إذا كنت تريد ترجمة جميع ملفات protobuf في هذا الدليل، يمكنك استخدام الرمز *، مثل
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.protoإذا كنت تريد تضمين جميع الملفات، يمكنك استخدام الرمز **، مثل ./**/*.proto.
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.protoلكن، هذه الطريقة تنطبق فقط على الصدفة التي تدعم هذا النوع من الرموز، مثل في Windows، cmd أو powershell لا يدعمان هذه الكتابة
D> protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto
Invalid file name pattern or missing input file "./**/*.proto"لحسن الحظ gitbash يدعم العديد من أوامر linux، ويمكنه جعل windows يدعم هذه الصيغة. لتجنب كتابة أوامر متكررة في كل مرة، يمكن وضعها في makefile
.PHONY: all
proto_gen:
protoc --proto_path=. \
--go_out=paths=source_relative:. \
--go-grpc_out=paths=source_relative:. \
./**/*.proto ./*.protoيمكن ملاحظة وجود paths=source_relative:. إضافي، هذا إعداد لنمط مسار توليد الملفات، هناك عدة خيارات متاحة
paths=import، هذا هو الافتراضي، سيتم توليد الملفات في الدليل المحدد بواسطةimport، ويمكن أن يكون أيضًا مسار وحدة. مثل وجود ملفprotos/buzz.proto، تحديدpaths=example.com/project/protos/fizz، فسيتم في النهاية توليدexample.com/project/protos/fizz/buzz.pb.go.module=$PREFIX، عند التوليد، سيتم حذف بادئة المسار. في المثال أعلاه، إذا تم تحديد البادئةexample.com/project، فسيتم في النهاية توليدprotos/fizz/buzz.pb.go، هذا النمط يُستخدم بشكل أساسي لتوليده مباشرة في الوحدة (يبدو أنه لا يوجد فرق كبير).paths=source_relative، الملفات المُولّدة ستحتفظ بنفس الهيكل النسبي لملفاتprotobufفي الدليل المحدد.
النقطتان : تفصل بين نمط المسار ومسار التوليد المحدد.
| common.proto
| common.pb.go
│
├─monster
│ monster.pb.go
│ monster.proto
│
└─player
health.pb.go
health.proto
health_grpc.pb.go
player.pb.go
player.protoالانعكاس
من خلال options يمكن توسيع enum و message، أولًا استورد "google/protobuf/descriptor.proto"
import "google/protobuf/descriptor.proto";
extend google.protobuf.EnumValueOptions {
optional string string_name = 123456789;
}
enum Integer {
INT64 = 0[
(string_name) = "int_64"
];
}هذا يكافئ إضافة معلومات وصفية لقيمة التعداد هذه. ونفس الشيء ينطبق على message، كالتالي
import "google/protobuf/descriptor.proto";
extend google.protobuf.MessageOptions {
optional string my_option = 51234;
}
message MyMessage {
option (my_option) = "Hello world!";
}هذا يكافئ انعكاس protobuf، بعد توليد الكود يمكن الوصول إليه عبر Descriptor، كالتالي
func main() {
message := pb_learn.MyMessage{}
message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
fmt.Println(descriptor.FullName(), ":", value)
return true
})
}المخرجات
my_option:"Hello world!"هذه الطريقة يمكن تشبيهها بإضافة tag للهياكل في Go، وهي متشابهة إلى حد كبير، وفقًا لهذه الطريقة يمكن أيضًا تنفيذ وظيفة التحقق من المعاملات، فقط تحتاج لكتابة القواعد في options، والتحقق عبر Descriptor.
