본문 바로가기

Languages/Rust

gRPC를 이용해서 Rust와 Flutter를 연동해보자 (2) - 예제

이전 소개 포스트에 이어서 예제를 직접 만들어 보겠습니다.

예제는 gRPC 홈에 있는 quick start의 예제의 proto 파일을 그대로 사용했으며, Rust와 Flutter code는 직접 생성해봤습니다.

다음과 같은 절차로 진행합니다.

  1. 준비물
  2. proto file 작성
  3. Rust로 gRPC 공통 library 작성
  4. Rust로 gRPC server 작성
  5. Rust로 gRPC client 작성
  6. Rust Server + Rust Client 연동 테스트
  7. Flutter로 gRPC client 작성
  8. Rust Server + Flutter Client 연동 테스트
  9. 마무리하며

1. 준비물

protoc 설치

protoc는 gRPC에서 사용하는 protocol buffer의 compiler다.

Windows 환경

chocolatey를 이용해서 protoc 를 설치한다.

choco install protoc

macOS 환경

brew install protobuf

2. proto file 작성 (protos/helloworld.proto)

syntax = "proto3";

package helloworld;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloResponse);
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

3. Rust로 gRPC 공통 library 작성

Rust library 프로젝트 생성

cargo new --lib helloworld_grpc_common_rs

Cargo.toml 수정 (helloworld_grpc_common_rs/Cargo.toml)

...
[dependencies]
prost = "0.12.1"
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros"] }
tonic = "0.10.1"

[build-dependencies]
tonic-build = { version = "0.10.1", features = ["prost"] }

build.rs 작성 (helloworld_grpc_common_rs/build.rs)

gRPC를 지원하는 다른 언어들과 다르게 Rust의 경우 코드 생성과정이 필요없이 빌드 아래와 같이 build.rs를 작성하면 앱 빌드시 proto 파일을 함께 compile하면서 빌드가 됩니다.
생성된 helloworld module은 gRPC server와 client에서도 모두 사용되므로 공용 library화하였습니다.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::compile_protos("../protos/helloworld.proto")?;
    Ok(())
}

lib.rs 작성 (helloworld_grpc_common_rs/src/lib.rs)

pub mod helloworld {
    tonic::include_proto!("helloworld");
}

4. gRPC server in Rust

cargo new helloworld_grpc_server_rs

Cargo.toml 수정 (helloworld_grpc_server_rs/Cargo.toml)

...
[dependencies]
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros"] }
tonic = "0.10.1"
helloworld_grpc_common_rs = { path = "../helloworld_grpc_common_rs" }

main.rs 작성 (helloworld_grpc_server_rs/src/main.rs)

use helloworld_grpc_common_rs::helloworld::greeter_server::GreeterServer;
use helloworld_grpc_common_rs::helloworld::{greeter_server::Greeter, HelloRequest, HelloResponse};
use tonic::{transport::Server, Request, Response, Status};

#[derive(Default)]
pub struct MyGreeter {}

#[tonic::async_trait]
impl Greeter for MyGreeter {
    async fn say_hello(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<HelloResponse>, Status> {
        let name = request.get_ref().name.clone();
        let message = format!("Hello, {}!", name);

        let response = HelloResponse { message };

        Ok(Response::new(response))
    }
}

#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "127.0.0.1:8080".parse()?;
    let greeter = MyGreeter::default();

    println!("Server listening on {}", addr);

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

5. Rust로 gRPC client 작성

client를 위한 rust project 생성

cargo new helloworld_grpc_client_rs

Cargo.toml 수정 (helloworld_grpc_client_rs/Cargo.toml)

...
[dependencies]
tonic = "0.10.0"
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros"] }
helloworld_grpc_common_rs = { path = "../helloworld_grpc_common_rs" }

main.rs 수정 (helloworld_grpc_client_rs/src/main.rs)

use helloworld_grpc_common_rs::helloworld::greeter_client::GreeterClient;
use helloworld_grpc_common_rs::helloworld::HelloRequest;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = GreeterClient::connect("http://127.0.0.1:8080").await?;

    let request = tonic::Request::new(HelloRequest {
        name: "Tonic".into(),
    });

    let response = client.say_hello(request).await?;

    println!("RESPONSE={:?}", response);

    Ok(())
}

6. Rust Server + Client 연동 테스트

Rust 서버를 실행합니다.

cargo run

Server listening on 127.0.0.1:8080

Rust client를 실행하면 아래와 같이 출력되면 정상작동하는 것입니다.

cargo run  
Finished dev \[unoptimized + debuginfo\] target(s) in 0.06s  
Running `target/debug/helloworld_grpc_client_rs`  
RESPONSE=Response { metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Sun, 24 Sep 2023 07:44:53 GMT", "grpc-status": "0"} }, message: HelloResponse { message: "Hello, Tonic!" }, extensions: Extensions } 

7. Flutter로 gRPC client 작성

Flutter 프로젝트 생성

flutter create helloworld_grpc_client_flutter

관련 library 추가

cd helloworld_grpc_client_flutter
flutter pub add protobuf grpc

Flutter용 gRPC client code 생성

cd ..
mkdir -p helloworld_grpc_client_flutter/lib/generated
protoc -I protos/helloworld.proto --dart_out=grpc:helloworld_grpc_client_flutter/lib/generated

Greeter widget 추가 (helloworld_grpc_client_flutter/lib/greeter.dart)

이 위젯은 Greet, Reset 버튼과 Message 텍스트 박스가 있고, Greet을 누르면 Rust gRPC와 통신해서 결과를 받아오고 받아온 메시지를 Message 텍스트 박스에 counter와 함께 표시합니다. Reset 버튼은 counter와 message를 초기화합니다.

import 'package:flutter/material.dart';
import 'package:grpc/grpc.dart';

import '/generated/helloworld.pbgrpc.dart';

class GreeterWidget extends StatefulWidget {
  const GreeterWidget({super.key});

  @override
  State<GreeterWidget> createState() => _GreeterWidgetState();
}

class _GreeterWidgetState extends State<GreeterWidget> {
  var counter = 0;
  var message = "None";

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextButton(
          onPressed: _onPressed,
          child: const Text('Greet'),
        ),
        Text('$counter: response: $message'),
        TextButton(
          onPressed: _onResetPressed,
          child: const Text('Reset'),
        ),
      ],
    );
  }

  Future<void> _onPressed() async {
    final channel = ClientChannel(
      '127.0.0.1',
      port: 8080,
      options: const ChannelOptions(credentials: ChannelCredentials.insecure()),
    );

    final stub = GreeterClient(
      channel,
      options: CallOptions(timeout: const Duration(seconds: 30)),
    );

    try {
      final response = await stub.sayHello(
        HelloRequest()..name = 'Flutter',
      );

      debugPrint('Response from server: ${response.message}');
      setState(() {
        counter += 1;
        message = response.message;
      });
    } catch (e) {
      debugPrint('Error: $e');
    } finally {
      await channel.shutdown();
    }
  }

  void _onResetPressed() {
    setState(() {
      counter = 0;
      message = "None";
    });
  }
}

main.dart 수정 (helloworld_grpc_client_flutter/lib/main.dart)

_MyHomePageState 안의 Column 에 GreeterWidget()을 추가합니다.

...
import '/greeter.dart';
...
class _MyHomePageState extends State<MyHomePage> {
...
  @override
  Widget build(BuildContext context) {
...
    return Scaffold(
...
      body: Center(
...
        child: Column(
...
          children: <Widget>[
...
            const GreeterWidget(),
          ],
...

DebugProfie.entitlements 및 Release.entitlements 파일 수정 (helloworld_grpc_client_flutter/macos/Runner/DebugProfile.entitlements) (macOS)

아래는 macOS에서 실행시, network 연결을 허용하기 위해서 두개의 entitlements 파일에 아래의 내용을 추가합니다.

    <key>com.apple.security.network.client</key>
    <true/>

8. Rust Server + Flutter Client 연동 테스트

Rust 서버는 이미 실행되어 있으니, Flutter 클라이언트만 실행합니다. macOS 또는 Windows에서 실행하면 각 OS에 맞는 Flutter desktop application이 실행됩니다.

Greet버튼을 탭하면 버튼 밑에 메시지가 출력됩니다. 메시지는 Rust gRPC 서버가 생성해준 메시지입니다.

cd helloworld_grpc_client_flutter
flutter run

macOS에서 실행한 화면입니다. Greet 버튼을 누르면 1: response: Hello, Flutter!로 출력됩니다.

9. 마무리하며

지금까지 Rust 기반의 gRPC 서버, 클라이언트, 그리고 Flutter 기반의 gRPC 클라이언트를 작성해보았고, 상호 연동해서 동작하는 것을 해보았습니다.
FFI와의 장단점이 있으므로, 판단해보시고 프로젝트에 잘 활용바랍니다.