Protobuf چیست؟  | چگونه یک struct را به سیستمی دیگر ارسال کنیم؟

Protobuf چیست؟ 

فرض کنید می‌خواهیم بین دو سیستم -مثلا 2 میکروکنترلر متفاوت یا میکروکنترلر و کامپیوتردیتای یک struct را به اشتراک بگذاریم. می‌دانیم برای اینکار نهایتاً باید یک رشته بایت بین این دو سیستم تبادل شود. اما سوال اینجاست که برای تبدیل struct به یک رشته بایت قابل انتقال چه راه‌هایی داریم؟ 

ابتدا فرض می‌کنیم که نام struct  ما در فرستنده test_struct_t است و متغیری که در برنامه از آن ایجاد کرده‌ و می‌خواهیم انتقال دهیم test_struct_var نام دارد.  

یکی از راه‌ها این است که در گیرنده هم test_struct_t را مثل فرستنده تعریف کنیم. سپس از آدرس test_struct_var به اندازه‌ی سایز آن -sizeof(test_struct_t)- شروع به فرستادن بایت به بایت این متغیر struct ‌کنیم. در گیرنده‌ هم پس از ذخیره اطلاعات دریافتی در حافظه، آدرس شروع این اطلاعات را برابر با اشاره‌گری به struct تعریف شده -* my_struct_t- ‌کنیم.  راه حل ساده  و قشنگی به نظر می‌رسد و همه چیز خوب است، جز اینکه ممکن است مشاهده کنید مقادیر struct فرستنده و گیرنده یکی نیستند! 

این اتفاق می‌تواند ناشی از تفاوت در endianness، padding و یا سایز متفاوت در data typeهای پایه مثل int و double در سیستم مبدا و مقصد باشد. 

به جز این مشکل نه چندان کوچک، این را هم باید در نظر داشت که شاید زبان برنامه‌نویسی متفاوتی در مبدا و مقصد داشته باشیم و نتوان structای مشابه مبدا در مقصد ایجاد کرد.  

اما راه جایگزین چیست؟ 

با این حساب دیدیم که، اگرچه راه‌حل قبلی ساده و بسیاری از اوقات کار راه انداز است، اما همیشه به کار ما نمیاید و ممکن است گاهی به مشکل بخوریم. خوشبختانه data serialization به عنوان جایگزینی اصولی‌تر مدت‌ها پیش ابداع شده. در دنیای کامپیوتر به پروسه‌ی تبدیل یک data structure یا object به یک رشته بایت به جهت ذخیره یا ارسال serialization می‌گویند. برای اینکار هم راه‌های مختلفی هست. در اینجا پیشنهاد من استفاده از google protobuf هست که علاوه بر پوشش نیازهای بالا به ما امکانات بیشتری مثل فرستادن داده به صورت optional هم می‌دهد. 

متاسفانه google protobuf به صورت پیش فرض از زبان C پشتیبانی نمی‌کند. اما خبر خوب این هست که ما می‌توانیم از کتابخانه Nanopb استفاده کنیم که بر اساس google protobuf و برای زبان C توسعه داده شده.  

چگونه از کتابخانه Nanopb استفاده کنیم؟

توضیحاتی که در ادامه قراره بدم یک مثال جامع نیست و اگر می‌خواهید کاری متفاوت از مثال پیش رو انجام دهید، لازمه که به داکیومنت Nanopb مراجعه کنید.

ابتدا باید فایلی متنی با فرمت proto. تعریف کنیم. در این فایل مشخص می‌کنیم که می‌خواهیم message ما به چه شکلی باشد. در ادامه خواهید دید که با کامپایل این فایل به کمک پروژه Nanopb به فایل‌های لازم برای پروژه زبان C خود خواهیم رسید. در صورت لزوم می‌توانیم همین فایل را برای زبان‌های دیگر مثل سی‌ شارپ  یا پایتون هم کامپایل کنیم تا خروجی مربوط به این نوع زبان‌ها را دریافت کنیم.

syntax = “proto2”;
message test_struct_t {
    optional string greeting_message = 1;
    required uint32 id = 2;
    repeated float temperature = 3;
}
در خط اول ما مشخص کردیم که دستورات این فایل از کدام ورژن زبان protobuf استفاده کند. اگر فکر می‌کنید که در آینده احتمالاً در messageهای تعریف شده تغییر صورت می‌گیرد و تطابق با ورژن‌های قبلی messageها برای شما اهمیت دارد، بهتر است سراغ proto2 بروید. در غیر این صورت از proto3 استفاده کنید. چراکه این ورژن با هدف سادگی و بهینه‌تر بودن توسعه داده شده.
نام struct مدنظر من test_struct_t است. عضو اول آن قرار است یک رشته باشد که معادلش در زبان C می‌شود یک آرایه از جنس char. شاید در اینجا سوال پیش بیاید که سایز این آرایه چرا تعریف نشده است. برای مشخص کردن سایز ما دو راه داریم، راه اول اینه که همینجا با اضافه کردن کلید واژه‌های مخصوص Nanopb در جلوی هر عضو، ویژگی‌های بیشتری مثل سایز را برایش تعریف کرد. اما از آنجا که اینکار باعث می‌شود به یک فایل غیراستاندارد برسیم که تنها برای کامپایل با Nanopb و پروژه‌ی زبان C مناسب است، ترجیح من این است که فایلی جدا و هم نام با همین فایل با پسوند options. تعریف کنم و ویژگی‌های اضافی مورد نیاز Nanopb را در آنجا مشخص کنم. برای مثالمون در اینجا من خط زیر را به این فایل اضافه کردم تا یک رشته 20 کارکتری، تعریف شده به صورت char greeting_message[21] در فایل test.pb.h نهایی داشته باشیم.
test_struct.greeting_message max_length :20
برگردیم به تعریف message. می‌بینید که قبل از نام هر فیلد type و label آن فیلد و در جلوی‌ آن شماره اختصاصی‌اش را تعریف کردیم. با مفهوم type نباید بیگانه باشید. ما در اینجا از typeهای string، uint32 و float استفاده کردیم. اما به جز این موارد ما انواعی چون sfixed64 برای وقتی که می‌خواهیم یک عدد صحیح 8 بایتی با سایز ثابت را داشته باشیم یا message برای وقتی که می‌خواهیم از نوع message دیگری به عنوان فیلد داشته باشیم، هم داریم.
اما label فیلد چیست؟
در خط اول label فیلد را optional قرار دادیم. در نتیجه این امکان را به کاربر فرستنده message دادیم که به صورت اختیاری این فیلد را در message ارسالی خود بگنجاند. مشخص کردن اینکه این فیلد در message ارسال شده یا خیر، توسط عضو boolian دیگری تعریف می‌شود که نامش با has_ شروع و با نام همین فیلد optional ادامه پیدا می‌کند:
bool has_greeting_message;
اما داستان برای فیلد شماره 2 که id  هست اینگونه نیست و این فیلد باید همیشه در messageها باشد. به همین دلیل هم در struct نهایی ما متغیری به اسم has_id نخواهیم داشت.
میرسیم به فیلد شماره 3، اگرچه اینجا عنوان required آورده نشده اما وجودش همانند فیلد 2 الزامی است. کلمه‌ی repeated به این اشاره دارد که این فیلد قرار است آرایه باشد. سایز این آرایه هم به کمک خط زیر که در فایل test.options قرار دادیم به صورت ثابت و به اندازه 2 خواهد بود:
test_struct.temperature max_count:2 fixed_count:true
حالا می‌خواهیم این فایل را با nanopb کامپایل کنیم. در صورتی که بر روی سیستمتون پایتون رو نصب ندارید اول اون رو نصب کنید. سپس از صفحه‌ی گیت‌هاب Nanopb پروژه‌اش را در محل مدنظرتون clone کنید. اگر کتابخونه‌های لازم پایتون برای کامپایل را نداشته باشید، بعد از زدن دستور زیر خودش شروع به دانلود و نصب خواهد کرد.
python .\nanopb\generator\nanopb_generator.py .\test.proto
در صورتی که مراحل را به درستی جلو رفته باشید، باید در ترمینال عبارت زیر را مشاهده کنید:
Writing to test.pb.h and test.pb.c
در فولدر جاری هم باید دو فایل test.pb.c و test.pb.h ایجاد شده باشند.

علاوه بر این دو فایل ایجاد  شده، شما لازم دارید کتابخانه Nanopb را هم به پروژه خودتان اضافه کنید. برای این کار در همان فولدر کلون شده به دنبال فایل‌های .c و .h با نام‌های pb_common، pb_decode، pb_encode و همچنین فایل pb.h باشید. بعد از کپی این فایل‌ها در پروژه باید در فایلی که مدنظر دارید تا تابع موردنیازتان را فراخوانی کنید، هدرفایل‌های pb_encode یا pb_decode را بسته به اینکه قصد encode یا decode را دارید، اضافه کنید.

#include “pb_encode.h”
#include “pb_decode.h”
در مثال زیر می‌خواهیم test_struct_var را encode کنیم:
    test_struct_t test_struct_var;
    pb_byte_t buf[test_struct_t_size + 1U]
    pb_ostream_t encode_stream = pb_ostream_from_buffer(&buf[1], test_struct_t_size );
    bool encode_result = pb_encode(&encode_stream, test_struct_t_fields, &test_struct_var);
    eeprom_buffer[0] = encode_stream.bytes_written;
برای decode پیام در مقصد ما لازم داریم از تعداد بایت‌های encode شده اطلاع داشته باشیم. به همین علت اولین بایت بافر را برای اینکار در نظر گرفتیم. سایز رشته‌های بایت‌های encode ثابت نیست و بسته به عوامل مختلف مثل اندازه‌ی داده‌ها، تعداد فیلدها و … می‌تواند تغییر کند. اما در فایل test.pb.h ماکرویی تعریف شده -test_struct_t_size- که به کمک آن می‌توان به حداکثر تعداد ممکن رسید. در انتها هم بعد از encode داده‌ها باید به تعداد encode_stream.bytes_written + 1 بایت از ابتدای آرایه buf شروع به ارسال کنیم.
حالا برویم سراغ مقصد، جایی که encode_stream.bytes_written + 1 بایت را مثلا از طریق uart دریافت کرده و در rx_buffer ریخته‌ایم. همانطور که در کد زیر مشاهده می‌کنید این بایت‌های دریافتی را دیکود کرده و در متغیر test_struct_var ریختیم.
uint8_t bytes_written = rx_buffer[0];
if (bytes_written <= test_struct_t_size ) {
    pb_istream_t stream = pb_istream_from_buffer(rx_buffer, bytes_written);
    pb_decode(&stream, test_struct_t_fields, &test_struct_var);
}

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *