gRPC 是一种现代的高性能远程过程调用(RPC)框架,可在任何环境中运行。gRPC 基于 protobufs 构建,可扩展且高效,并在多种语言和运行时中提供广泛支持。

在本教程中,我们将介绍如何将一个基于 Rust 的 gRPC API 支持的 React 应用程序部署到 Koyeb。演示应用程序是一个电影数据库网站,其功能是展示一系列电影及其关联的元数据。您可以在此处找到两个应用程序组件的源代码:

准备条件

为了按照本教程进行操作,您需要以下内容:

  • 一个用于部署 Rust 和 React 服务的 Koyeb 帐户。Koyeb的免费套餐允许您每月免费运行两个服务。
  • 安装在本地计算机上的 protobuf 编译器。我们将使用它从我们的数据格式生成特定于语言的 subs。
  • Rust 和 cargo 安装在本地计算机上以创建 gRPC API 服务。
  • Node.js 和 npm 安装在本地计算机上以创建基于 React 的前端。

满足要求后,请继续开始。

创建 Rust API

我们将首先为后端创建 Rust API 和 protobuf 文件,该文件定义了我们两个服务将用于通信的数据格式。

创建一个新的 Rust 项目并安装依赖项

首先定义一个新的 Rust 项目。

使用该 cargo 命令生成使用预期的包文件初始化的新项目目录:

cargo new movies-back

接下来,cd 到新的项目目录并安装项目的依赖项:

cd movies-back
cargo add tonic@0.9.2 tonic-web@0.9.2
cargo add prost@0.11 prost-types@0.11
cargo add --build tonic-build@0.8
cargo add tonic-health@0.9.2
cargo add tower-http@0.2.3
cargo add --features tokio@1.0/macros,tokio@1.0/rt-multi-thread tokio@1.0

默认情况下,Web 浏览器不支持 gRPC,但我们将使用 gRPC-web 来实现它。

定义数据格式

接下来,通过创建 protobuf 定义文件来创建数据格式。

创建一个名为 proto 的新目录:

mkdir proto

在里面,创建一个以以下内容命名的新 proto/movie.proto 文件:

syntax = "proto3";
package movie;

message MovieItem {
  int32 id = 1;
  string title = 2;
  int32 year = 3;
  string genre = 4;
  string rating = 5;
  string starRating = 6;
  string runtime = 7;
  string cast = 8;
  string image = 9;
}

message MovieRequest {
}

message MovieResponse {
  repeated MovieItem movies = 1;
}

service Movie {
  rpc GetMovies (MovieRequest) returns (MovieResponse);
}

该文件 proto/movie.proto 使用 protobuf 格式定义我们的数据格式。它指定了一个数据结构来保存有关电影的所有数据,并概述了对该数据的请求和响应的外观。此定义将用于定义我们服务之间的 API。

创建后端服务

现在我们有了数据格式,我们可以创建后端服务。

首先配置 Rust 构建过程来编译 protobuf 文件。创建一个包含以下内容的调用 build.rs 文件:

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

现在,我们可以使用 GetMovies 端点实现实际的 API 服务器。打开 src/main.rs 文件并将内容替换为以下内容:

use std::env;
use tonic::{transport::Server, Request, Response, Status};
pub mod grpc_movie {
    tonic::include_proto!("movie");
}
use grpc_movie::movie_server::{Movie, MovieServer};
use grpc_movie::{MovieRequest, MovieResponse};

#[derive(Debug, Default)]
pub struct MovieService {}

#[tonic::async_trait]
impl Movie for MovieService {
    async fn get_movies(
        &self,
        request: Request<MovieRequest>,
    ) -> Result<Response<MovieResponse>, Status> {
        println!("Got a request: {:?}", request);

        let mut movies = Vec::new();
        movies.push(grpc_movie::MovieItem {
            id: 1,
            title: "Matrix".to_string(),
            year: 1999,
            genre: "Sci-Fi".to_string(),
            rating: "8.7".to_string(),
            star_rating: "4.8".to_string(),
            runtime: "136".to_string(),
            cast: "Keanu Reeves, Laurence Fishburne".to_string(),
            image: "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg".to_string(),
        });
        movies.push(grpc_movie::MovieItem {
            id: 2,
            title: "Spider-Man: Across the Spider-Verse".to_string(),
            year: 2023,
            genre: "Animation".to_string(),
            rating: "9.7".to_string(),
            star_rating: "4.9".to_string(),
            runtime: "136".to_string(),
            cast: "Donald Glover".to_string(),
            image: "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg".to_string(),
        });
        movies.push(grpc_movie::MovieItem {
            id: 3,
            title: "Her".to_string(),
            year: 2013,
            genre: "Drama".to_string(),
            rating: "8.7".to_string(),
            star_rating: "4.1".to_string(),
            runtime: "136".to_string(),
            cast: "Joaquin Phoenix".to_string(),
            image: "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg".to_string(),
        });

        let reply = grpc_movie::MovieResponse { movies: movies };

        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let port = env::var("PORT").unwrap_or("50051".to_string());
    let addr = format!("0.0.0.0:{}", port).parse()?;
    let movie = MovieService::default();
    let movie = MovieServer::new(movie);
    let movie = tonic_web::enable(movie);

    let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
    health_reporter
        .set_serving::<MovieServer<MovieService>>()
        .await;

    println!("Running on port {}...", port);
    Server::builder()
        .accept_http1(true)
        .add_service(health_service)
        .add_service(movie)
        .serve(addr)
        .await?;

    Ok(())
}

该应用程序使用 tonic gRPC 实现,根据 protobuf 文件定义的结构和接口为我们的后端构建和提供 API。在实际场景中,这通常由包含电影数据的数据库支持,但为了简化实现,我们只包含一些内联电影的数据。

默认情况下,该服务将在端口 50051 上运行(可使用 PORT 环境变量进行修改),并将响应使用 protobuf 文件定义的响应对象的电影请求。

测试 API 后端

API 后端现已完成,因此我们可以通过键入以下内容在本地启动服务器以测试其功能:

cargo run

API 服务器将构建并开始在端口 50051 上运行。可以使用所选的 gRPC 客户端(如 grpcurl gRPC 客户端)或 postman 来测试功能。

例如,您可以通过在运行 Rust 服务的项目根目录中键入以下内容 grpcurl 来请求电影列表:

grpcurl -plaintext -import-path proto -proto movie.proto 127.0.0.1:50051 movie.Movie/GetMovies

该服务应按预期返回电影列表:

{
  "movies": [
    {
      "id": 1,
      "title": "Matrix",
      "year": 1999,
      "genre": "Sci-Fi",
      "rating": "8.7",
      "starRating": "4.8",
      "runtime": "136",
      "cast": "Keanu Reeves, Laurence Fishburne",
      "image": "http://image.tmdb.org/t/p/w500//aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg"
    },
    {
      "id": 2,
      "title": "Spider-Man: Across the Spider-Verse",
      "year": 2023,
      "genre": "Animation",
      "rating": "9.7",
      "starRating": "4.9",
      "runtime": "136",
      "cast": "Donald Glover",
      "image": "http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg"
    },
    {
      "id": 3,
      "title": "Her",
      "year": 2013,
      "genre": "Drama",
      "rating": "8.7",
      "starRating": "4.1",
      "runtime": "136",
      "cast": "Joaquin Phoenix",
      "image": "http://image.tmdb.org/t/p/w500//eCOtqtfvn7mxGl6nfmq4b1exJRc.jpg"
    }
  ]
}

创建 Dockerfile

当我们将后端部署到 Koyeb 时,应用程序将在容器中运行。我们可以为我们的项目定义一个 Dockerfile 来描述项目的代码应该如何打包和运行。

在项目的根目录中,创建一个包含 Dockerfile 以下内容的新文件:

FROM rust:1.64.0-buster as builder

# install protobuf
RUN apt-get update && apt-get install -y protobuf-compiler libprotobuf-dev

COPY Cargo.toml build.rs /usr/src/app/
COPY src /usr/src/app/src/
COPY proto /usr/src/app/proto/
WORKDIR /usr/src/app
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release --bin movies-back

FROM gcr.io/distroless/static-debian11 as runner

# get binary
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/movies-back /

# set run env
EXPOSE 50051

# run it
CMD ["/movies-back"]

完成后,将 API 的所有文件添加到新的 GitHub 存储库,以便我们稍后可以将其部署到生产环境。

现在,我们已准备好创建将使用 API 的 React 应用程序。

创建 React 应用程序

现在后端已经完成,我们可以开始为我们的应用程序开发 React 前端服务了。

生成一个新的 React 项目

创建基本 react 应用程序的最快方法是使用 create-react-app .检查以确保您不在 Rust 服务的目录中,然后键入:

npx create-react-app movies-front

这将为您的前端创建一个新的项目目录,并生成一些基本文件来帮助您入门。

移动到新目录并启动服务以验证是否正确安装了所有内容:

cd movies-front
npm run start

开发服务器将在端口 3000 上打开,React 将尝试打开一个新的浏览器窗口来查看它。如果您没有被自动定向到那里,您可以访问 localhost:3000 。默认的 React 开发页面应该出现:

The default landing page for a new React project

完成后按 Ctrl - c 停止开发服务器。

配置 Tailwind CSS 框架

我们的 React 应用程序将在页面上显示电影列表。为了加快样式设置过程,我们将使用 Tailwind CSS。安装必要的软件包并通过键入以下内容初始化 Tailwind:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

现在 Tailwind 已经安装完毕,我们需要配置 React 应用程序来支持它。

首先,打开 tailwind.config.js 文件。修改 content 属性以选取所有预期的 CSS 内容:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

我们将直接使用 Sass 而不是 CSS,因此接下来我们需要安装 sass 软件包:

npm install sass

删除 src/App.css 该文件并将其替换为 src/App.scss .在里面,我们导入了我们需要的所有顺风代码:

@tailwind base;
@tailwind components;
@tailwind utilities;

我们可以通过更改主 React 应用程序文件来测试 Tailwind。更改 src/App.js 文件的内容,如下所示:

import './App.scss';
import Movie from './Movie';

function App() {
  // for now we will
  let movies = [
    {
      id: 1,
      title: 'The spiderman across the spider verse',
      year: 2023,
      genre: 'animation',
      rating: 'L',
      starRating: '4.9',
      runtime: '2h 22min',
      cast: 'Donald Glover',
      image: 'http://image.tmdb.org/t/p/w500//8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg'
    }
  ]

  return (
    <div className="App">
      {movies.map((movie) => (
          <Movie key={movie.id} details={movie} />
      ))}

    </div>
  );
}

export default App;

在这里,我们创建一个应用程序,该应用程序将在请求页面时为我们的电影列表提供服务。此时,我们只需模拟单个电影,以便测试我们的 CSS 样式。

创建一个 src/Movie.js 文件来定义影片的样式和显示方式:

export default function Movie(props) {
  let movie = props.details

  return (
    <article className="flex items-start space-x-6 p-6">
      <img src={movie.image} alt="" width="60" height="88" className="flex-none rounded-md bg-slate-100" />
      <div className="min-w-0 relative flex-auto">
        <h2 className="font-semibold text-slate-900 truncate pr-20">{movie.title}</h2>
        <dl className="mt-2 flex flex-wrap text-sm leading-6 font-medium">
          <div className="absolute top-0 right-0 flex items-center space-x-1">
            <dt className="text-sky-500">
              <span className="sr-only">Star rating</span>
              <svg width="16" height="20" fill="currentColor">
                <path d="M7.05 3.691c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.372 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.539 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.783.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.363-1.118L.98 9.483c-.784-.57-.381-1.81.587-1.81H5.03a1 1 0 00.95-.69L7.05 3.69z" />
              </svg>
            </dt>
            <dd>{movie.starRating}</dd>
          </div>
          <div>
            <dt className="sr-only">Rating</dt>
            <dd className="px-1.5 ring-1 ring-slate-200 rounded">{movie.rating}</dd>
          </div>
          <div className="ml-2">
            <dt className="sr-only">Year</dt>
            <dd>{movie.year}</dd>
          </div>
          <div>
            <dt className="sr-only">Genre</dt>
            <dd className="flex items-center">
              <svg width="2" height="2" fill="currentColor" className="mx-2 text-slate-300" aria-hidden="true">
                <circle cx="1" cy="1" r="1" />
              </svg>
              {movie.genre}
            </dd>
          </div>
          <div>
            <dt className="sr-only">Runtime</dt>
            <dd className="flex items-center">
              <svg width="2" height="2" fill="currentColor" className="mx-2 text-slate-300" aria-hidden="true">
                <circle cx="1" cy="1" r="1" />
              </svg>
              {movie.runtime}
            </dd>
          </div>
          <div className="flex-none w-full mt-2 font-normal">
            <dt className="sr-only">Cast</dt>
            <dd className="text-slate-400">{movie.cast}</dd>
          </div>
        </dl>
      </div>
    </article>
  )
}

如果再次启动开发服务器,您应该能够看到模拟的电影:

npm run start

Single movie mock display

从 API 获取电影列表

接下来,我们将修改应用程序以从 gRPC API 提取数据,而不是显示硬编码的电影。

首先,为 React 安装 protobuf 和 gRPC Web 支持:

npm install google-protobuf@~3.21.2 grpc-web@~1.4.1

接下来,我们需要基于我们为后端 Rust 应用程序定义的相同 protobuf 文件在 JavaScript 中生成电影实体。为此,我们将使用先决条件中的 protobuf 安装中包含的 protoc 命令。

假设 和 movies-back movies-front 彼此相邻,您可以通过在 movies-front 目录中运行以下命令来生成具有 protobuf 定义的相应 JavaScript 文件:

# change the proto_path to the correct place where movie.proto is.
protoc --proto_path=../movies-back/proto/ movie.proto --grpc-web_out=import_style=commonjs,mode=grpcweb:src --js_out=import_style=commonjs,binary:src

这将从您的 protobuf 文件生成适当的 JavaScript 存根,以便 React 应用程序了解如何与 API 通信。

现在我们可以将 src/App.js 文件更改为使用 gRPC 而不是提供硬编码电影:

import './App.scss';
import Movie from './Movie';
import { useState, useEffect} from 'react';

const proto = {};
proto.movie = require('./movie_grpc_web_pb.js');

function App() {
  let url  = process.env.REACT_APP_BACKEND_URL;
  if(url == null){
    url = "http://localhost:50051"
  }

  const client = new proto.movie.MovieClient(url, null, null);
  let [movies, setMovies] = useState([])
  useEffect(() => {
    const req = new proto.movie.MovieRequest();
    client.getMovies(req, {}, (err, response) => {
      if(response == null){
        return
      }

      if (response.getMoviesList().length === 0) {
        return
      }

      let m = []
      response.getMoviesList().forEach((movie) => {
        console.log(movie.toObject())
        m.push(movie.toObject())
      })
      setMovies(m)
    })
  }, [])

  return (
    <div className="App">
      {movies.map((movie) => (
          <Movie key={movie.id} details={movie} />
      ))}
    </div>
  );
}

export default App;

再次启动开发服务器以测试新更改:

npm run start

这一次,您应该会看到从 API 后端提供的完整电影列表:

Full movie display from backend API

确认应用程序按预期工作后,将 React 应用程序的文件添加到新的 GitHub 存储库并推送它们。我们将在下一阶段将应用程序部署到生产环境。

将应用程序部署到科耶布

现在我们的前端和后端按预期工作,我们可以将这两个服务部署到 Koyeb。

部署 API 服务

首先,我们将 Rust 应用程序部署到 Koyeb。

在 Koyeb 控制面板中,单击“创建应用程序”按钮以开始使用。选择 GitHub 作为部署方法,然后从存储库列表中选择后端 API 的存储库。或者,可以使用此服务的示例存储库,其中包含我们通过放入 https://github.com/filhodanuvem/movies-rust-grpc 公共 GitHub 存储库字段来讨论的相同代码。

在下一页上,选择 Dockerfile 作为构建器:

Koyeb API deployment configuration

单击高级以展开其他设置。将 PORT 环境变量的值更改为 50051。在“公开服务”部分中,将端口也修改为 50051。从协议下拉列表中选择 HTTP/2 ,并将路径设置为 /api

Koyeb API port and health check configuration

完成后,单击“部署”按钮以部署 API 后端。

在 API 的服务页面上,复制“公共 URL”的值。在配置 React 应用程序时,我们将需要此值。

部署 React 应用程序

现在 API 正在运行,我们可以部署 React 应用程序。与 API 不同,我们将为这个项目使用 buildpack 构建器,而不是从 Dockerfile 构建。

在 Koyeb 控制面板中,单击包含您刚刚部署的 API 服务的应用程序。在服务的索引页面中,单击“创建服务”按钮以在同一 Koyeb 应用程序的上下文中部署其他服务。

在以下屏幕上,选择 GitHub 作为部署方法,然后从存储库列表中单击前端 React 应用程序的存储库。或者,可以使用此服务的示例存储库,其中包含我们通过放入 https://github.com/filhodanuvem/movies-react 公共 GitHub 存储库字段来讨论的相同代码。

在下一页上,选择“构建包”作为构建器:

Koyeb frontend deployment configuration

单击高级以展开其他设置。单击添加变量按钮并创建一个名为 REACT_APP_BACKEND_URL 的新变量。使用您复制的 API 服务的公有 URL 作为值。它应如下所示:

REACT_APP_BACKEND_URL=https://<YOUR_APP_NAME>-<KOYEB_ORG_NAME>.koyeb.app/api

Koyeb frontend environment variable configuration

完成后,单击“部署”按钮开始生成和部署应用程序。部署完成后,使用 React 应用程序的公共 URL 访问电影数据库站点。

结论

在本指南中,我们创建并部署了一个由 Rust 后端和 React 前端组成的端到端应用程序。这两个服务使用 gRPC 进行通信。我们将这两项服务部署到 Koyeb 以利用其本机 gRPC 支持。这些服务能够安全地通信,以满足用户对电影数据库站点的请求。