بدء استخدام gRPC-Rust

1. مقدمة

في هذا الدرس التطبيقي حول الترميز، ستستخدم gRPC-Rust لإنشاء عميل وخادم يشكّلان الأساس لتطبيق يحدّد المسارات مكتوب بلغة Rust.

في نهاية هذا الدليل التعليمي، سيكون لديك عميل يتصل بخادم بعيد باستخدام gRPC للحصول على اسم أو عنوان بريدي للمكان الذي يقع عند إحداثيات معيّنة على الخريطة. قد يستخدم تطبيق متكامل تصميم العميل والخادم هذا لتعداد نقاط الاهتمام أو تلخيصها على طول مسار معيّن.

يتم تحديد الخدمة في ملف بتنسيق Protocol Buffers، وسيتم استخدام هذا الملف لإنشاء رمز نص نموذجي للعميل والخادم حتى يتمكّنا من التواصل مع بعضهما البعض، ما يوفّر عليك الوقت والجهد في تنفيذ هذه الوظيفة.

لا يهتم هذا الرمز الذي تم إنشاؤه بتعقيدات الاتصال بين الخادم والعميل فحسب، بل أيضًا بتسلسل البيانات وإلغاء تسلسلها.

ماذا ستتعلّم؟

  • كيفية استخدام "مخازن البروتوكولات المؤقتة" (Protocol Buffers) لتحديد واجهة برمجة تطبيقات الخدمة
  • كيفية إنشاء عميل وخادم يستندان إلى gRPC استنادًا إلى "مخازن البروتوكولات المؤقتة" من خلال إنشاء الرموز البرمجية آليًا
  • فهم عملية التواصل بين العميل والخادم باستخدام gRPC

هذا الدرس التطبيقي حول الترميز موجّه لمطوّري Rust المبتدئين في gRPC أو الذين يريدون تجديد معلوماتهم في المجال، أو أي شخص آخر مهتم بتطوير أنظمة موزّعة. لا يُشترط توفّر خبرة سابقة في gRPC.

2. قبل البدء

المتطلبات الأساسية

تأكَّد من تثبيت ما يلي:

  • GCC. اتّبِع التعليمات هنا.
  • Rust، الإصدار 1.89.0 اتّبِع تعليمات التثبيت هنا.

الحصول على الشفرة‏

كي لا تضطر إلى البدء من الصفر تمامًا، يوفّر لك هذا الدرس التطبيقي حول الترميز بنية أساسية للرمز المصدر الخاص بالتطبيق لتتمكّن من إكماله. ستوضّح لك الخطوات التالية كيفية إكمال التطبيق، بما في ذلك استخدام مكوّنات برنامج تجميع مخازن البروتوكولات المؤقتة لإنشاء رمز gRPC النموذجي.

أولاً، أنشئ دليل عمل الدرس التطبيقي وادخله:

mkdir grpc-rust-getting-started && cd grpc-rust-getting-started

نزِّل الدرس التطبيقي حول الترميز واستخرِجه:

curl -sL https://github.com/grpc-ecosystem/grpc-codelabs/archive/refs/heads/v1.tar.gz \
  | tar xvz --strip-components=4 \
  grpc-codelabs-1/codelabs/grpc-rust-getting-started/start_here

بدلاً من ذلك، يمكنك تنزيل ملف ‎ .zip الذي يحتوي على دليل الدرس العملي فقط وفك ضغطه يدويًا.

يتوفّر الرمز المصدر المكتمل على GitHub إذا كنت تريد تخطّي كتابة عملية التنفيذ.

3. تحديد الخدمة

تتمثّل خطوتك الأولى في تحديد خدمة gRPC للتطبيق وطريقة استدعاء إجراء عن بُعد (RPC) وأنواع رسائل الطلبات والردود باستخدام مخازن البروتوكولات المؤقتة. ستوفّر خدمتك ما يلي:

  • طريقة استدعاء إجراء عن بُعد تُسمّى GetFeature ينفّذها الخادم ويستدعيها العميل.
  • نوعا الرسائل Point وFeature اللذان يمثّلان بنى البيانات المتبادلة بين العميل والخادم عند استخدام طريقة GetFeature يقدّم العميل إحداثيات الخريطة كـ Point في طلب GetFeature إلى الخادم، ويردّ الخادم بـ Feature مطابق يصف أي شيء يقع في تلك الإحداثيات.

سيتم تحديد طريقة "استدعاء الإجراء عن بُعد" هذه وأنواع الرسائل الخاصة بها في ملف proto/route_guide.proto الخاص بالرمز المصدر المقدَّم.

يُشار إلى "مخازن البروتوكولات المؤقتة" عادةً باسم protobufs. لمزيد من المعلومات عن مصطلحات gRPC، يُرجى الاطّلاع على المفاهيم الأساسية والبنية ودورة الحياة في gRPC.

طريقة الخدمة

لنحدّد أولاً طرق الخدمة، ثم نحدّد أنواع الرسائل Point وFeature. يحتوي الملف proto/routeguide.proto على بنية service باسم RouteGuide تحدّد طريقة واحدة أو أكثر توفّرها خدمة التطبيق.

أضِف GetFeature (وهي طريقة rpc) داخل تعريف RouteGuide. كما هو موضّح سابقًا، ستبحث هذه الطريقة عن اسم أو عنوان مكان من مجموعة إحداثيات معيّنة، لذا اطلب من GetFeature عرض Feature لـ Point معيّن:

service RouteGuide {
  // Definition of the service goes here

  // Obtains the feature at a given position.
  rpc GetFeature(Point) returns (Feature) {}
}

هذه طريقة أحادية لاستدعاء الإجراء عن بُعد: استدعاء إجراء بسيط عن بُعد يرسل فيه العميل طلبًا إلى الخادم وينتظر تلقّي رد، تمامًا مثل استدعاء دالة محلية.

أنواع الرسائل

في ملف proto/route_guide.proto الخاص بالرمز المصدر، حدِّد أولاً نوع رسالة Point. يمثّل Point زوج إحداثيات خط العرض وخط الطول على الخريطة. في هذا الدرس التطبيقي، استخدِم أعدادًا صحيحة للإحداثيات:

message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

الرقمان 1 و2 هما رقما تعريف فريدان لكل حقل من الحقول في بنية message.

بعد ذلك، حدِّد نوع رسالة Feature. يستخدم Feature الحقل string للاسم أو العنوان البريدي الخاص بمكان محدّد من خلال Point:

message Feature {
  // The name or address of the feature.
  string name = 1;

  // The point where the feature is located.
  Point location = 2;
}

4. إنشاء رمز العميل والخادم

لقد قدّمنا لك الرمز الذي تم إنشاؤه من ملف .proto في الدليل الذي تم إنشاؤه.

كما هو الحال مع أي مشروع، علينا التفكير في التبعيات الضرورية للرمز البرمجي. بالنسبة إلى مشاريع Rust، ستكون التبعيات في Cargo.toml. لقد أدرجنا بالفعل الاعتمادات اللازمة في ملف Cargo.toml.

إذا أردت معرفة كيفية إنشاء الرمز من ملف .proto بنفسك، يمكنك الرجوع إلى هذه التعليمات.

يتضمّن الرمز البرمجي الذي تم إنشاؤه ما يلي:

  • تعريفات البنية لأنواع الرسائل Point وFeature
  • سمة الخدمة التي يجب تنفيذها: route_guide_server::RouteGuide.
  • نوع العميل الذي سنستخدمه لاستدعاء الخادم: route_guide_client::RouteGuideClient<T>.

بعد ذلك، سننفّذ طريقة GetFeature من جهة الخادم، حتى يتمكّن الخادم من الردّ على الطلب الذي يرسله العميل.

5. تنفيذ الخدمة

في src/server/server.rs، يمكننا إدراج الرمز الذي تم إنشاؤه في النطاق من خلال ماكرو include_generated_proto! الخاص بـ gRPC واستيراد السمة RouteGuide وPoint.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

pub use grpc_pb::{
    route_guide_server::{RouteGuideServer, RouteGuide},
    Point, Feature,
};

يمكننا البدء بتحديد بنية لتمثيل خدمتنا، ويمكننا إجراء ذلك على src/server/server.rs في الوقت الحالي:

#[derive(Debug)]
pub struct RouteGuideService {
    features: Vec<Feature>,
}

الآن، علينا تنفيذ السمة route_guide_server::RouteGuide من الرمز الذي تم إنشاؤه.

استدعاء إجراء أحادي عن بُعد

تنفّذ الفئة RouteGuideService جميع طرق الخدمة. يتم تنفيذ العمل الرئيسي من خلال الدالة get_feature من جهة الخادم: تأخذ هذه الدالة رسالة Point من العميل وتعرض في رسالة Feature معلومات المكان المطابقة من قائمة بالأماكن المعروفة. في ما يلي تنفيذ الدالة في src/server/server.rs:

#[tonic::async_trait]
impl RouteGuide for RouteGuideService {
    async fn get_feature(&self, request: Request<Point>) -> Result<Response<Feature>, Status> {
        println!("GetFeature = {:?}", request);
        let requested_point = request.get_ref();
        for feature in self.features.iter() {
            if feature.location().latitude() == requested_point.latitude() {
                if feature.location().longitude() == requested_point.longitude(){
                    return Ok(Response::new(feature.clone()))
                };
            };    
        }
        Ok(Response::new(Feature::default()))
    }
}

في إطار هذه الطريقة، املأ كائن Feature بالمعلومات المناسبة لـ Point المحدّدة، ثم أرجِعه.

بعد تنفيذ هذه الطريقة، علينا أيضًا بدء تشغيل خادم gRPC حتى يتمكّن العملاء من استخدام خدمتنا. استبدِل main() بهذا النص.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:10000".parse().unwrap();
    println!("RouteGuideServer listening on: {addr}");
    let route_guide = RouteGuideService {
        features: load(),
    };
    let svc = RouteGuideServer::new(route_guide);
    Server::builder().add_service(svc).serve(addr).await?;
    Ok(())
}

في ما يلي الخطوات المفصّلة لما يحدث في main():

  1. تحديد المنفذ الذي نريد استخدامه للاستماع إلى طلبات العميل
  2. إنشاء RouteGuideService مع تحميل الميزات من خلال استدعاء الدالة المساعدة load()
  3. أنشئ مثيلاً لخادم gRPC باستخدام RouteGuideServer::new() باستخدام الخدمة التي أنشأناها.
  4. تسجيل تنفيذ الخدمة في خادم gRPC
  5. قم باستدعاء serve() على الخادم مع تزويده بتفاصيل المنفذ لبدء وضع الانتظار المعطِّل إلى أن يتم إنهاء العملية.

6. إنشاء العميل

في هذا القسم، سنلقي نظرة على كيفية إنشاء برنامج عميل بلغة Rust لخدمة RouteGuide في src/client/client.rs.

كما فعلنا في src/server/server.rs، يمكننا إدراج الرمز الذي تم إنشاؤه في النطاق من خلال ماكرو include_generated_code! في gRPC واستيراد النوع RouteGuideClient.

mod grpc_pb {
    grpc::include_generated_proto!("generated", "routeguide");
}

use grpc_pb::{
    route_guide_client::RouteGuideClient,
    Point,
};

طُرق خدمات الاستدعاء

في gRPC-Rust، تعمل طلبات استدعاء الإجراء عن بُعد (RPC) في وضع الحظر/التزامن، ما يعني أنّ طلب RPC ينتظر رد الخادم، وسيعرض إما ردًا أو خطأ.

لاستدعاء طرق الخدمة، علينا أولاً إنشاء قناة للتواصل مع الخادم. ننشئ ذلك من خلال إنشاء نقطة نهاية أولاً، ثم الاتصال بنقطة النهاية هذه، وتمرير القناة التي تم إنشاؤها عند الاتصال بـ RouteGuideClient::new() على النحو التالي:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel);
    Ok(())
}

في هذه الدالة، عند إنشاء العميل، نغلّف القناة العامة التي تم إنشاؤها أعلاه باستخدام رمز العنصر النائب الذي تم إنشاؤه والذي ينفّذ الطرق المحدّدة في خدمة .proto.

Simple RPC

إنّ استدعاء إجراء بسيط GetFeature هو أمر مباشر تقريبًا مثل استدعاء طريقة محلية. أضِف هذا النص في main().

println!("*** SIMPLE RPC ***");
let point = proto!(Point{
    latitude: 409_146_138,
    longitude: -746_188_906
});
let response = client
    .get_feature(Request::new(point))
    .await?.into_inner();
Ok(())

كما ترى، نستدعي الطريقة على الرمز البديل الذي حصلنا عليه سابقًا. في مَعلمات الطريقة، ننشئ كائنًا لطلب Protocol Buffers المؤقت ونملأه (في حالتنا Point). إذا لم تعرض المكالمة خطأً، يمكننا قراءة معلومات الرد من الخادم من القيمة المرجَعة الأولى.

println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
    response.name(),
    response.location().latitude(),
    response.location().longitude());

في المجمل، يجب أن تبدو الدالة main() الخاصة بالعميل على النحو التالي:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    //Create endpoint to connect to
    let endpoint = Endpoint::new("http://[::1]:10000")?; 
    let channel = endpoint.connect().await?;             

    // Create a new client
    let mut client = RouteGuideClient::new(channel); 

    println!("*** SIMPLE RPC ***");
    let point = proto!(Point{
        latitude: 409_146_138,
        longitude: -746_188_906
    });
    let response = client
        .get_feature(Request::new(point))
        .await?.into_inner();

    println!("Response = Name = \"{}\", Latitude = {}, Longitude = {}",
        response.name(),
        response.location().latitude(),
        response.location().longitude());
    Ok(())
}

7. للتجربة:

أولاً، لتشغيل Client وServer، لنضِفها كأهداف ثنائية إلى الحزمة. علينا تعديل Cargo.toml وفقًا لذلك وإضافة ما يلي:

[[bin]]
name = "routeguide-server"
path = "src/server/server.rs"

[[bin]]
name = "routeguide-client"
path = "src/client/client.rs"

بعد ذلك، نفِّذ الأوامر التالية من دليل العمل:

  1. شغِّل الخادم في إحدى الوحدات الطرفية:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-server 
  1. شغِّل العميل من نافذة طرفية أخرى:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-client

ستظهر لك نتيجة مشابهة لما يلي، مع حذف الطوابع الزمنية لتوضيح الصورة:

*** SIMPLE RPC ***

FEATURE: Name = "Berkshire Valley Management Area Trail, Jefferson, NJ, USA", Lat = 409146138, Lon = -746188906

8. الخطوات التالية