总结了关于gRPC设计和开发的要点

首先

这是2019年Future Advent Calendar 2的第22天。顺便一提,Advent Calendar 1的文章在这里。

今年是我入职的第五个年头了,每年到了这个时候,我都会反思自己在过去一年里面面对了哪些工作和技术,这也是一个很好的机会。此外,由于接下来的项目都非常忙碌,完全不得不利用宝贵的休息日来撰写文章,非常艰难。因此,我首先要确保文章的质量。

简要概述

2019年我领导并参与了使用KVS等NoSQL数据库进行应用程序的设计和开发。
回头看,我之前只写了有关Cassandra的文章,所以这次我希望专注于写关于gRPC的文章。
之前我主要专注于基础设施和中间件的设计和构建,对API的设计和开发没有认真进行过,一直以来都很困难,但通过gRPC的设计和开发,我希望能将所得到的知识回馈给大家。

gRPC是一种通信协议。

gRPC是Google开发的开源项目,利用Protocol Buffers进行数据序列化,实现了比REST更快的通信速度。
gRPC还具有一个特点,就是通过定义一个被称为proto文件的接口定义语言(IDL)文件来定义API规范,可以自动根据proto文件生成适合不同语言(如Java、C++、Python、Go)的客户端/服务器源代码模板。proto文件按照proto3的语言规范进行定义。

grpc_concept_diagram_00.png

选择使用gRPC的要点

在选择使用gRPC作为API时,与之常常进行比较的是REST。
我负责设计和开发的这个API服务器是用于执行对后端数据存储层的CRUD操作的API,通过使用proto对从KVS等获取的数据进行结构化定义,相比REST进行通信更快的特点是选择采用数据存储API的最大优点。

此外,在实现微服务架构时,统一API规格是一项非常费时的工作。而采用gRPC可以通过严格的接口规则来保持接口的一致性,相比自由度较高的REST设计,gRPC的采用优势更大。

然而,与REST相比,并没有绝对的优势,所以我们认为从能否享受到上述优点的角度来看,以逐案选择为好。

gRPC的设计和开发要点。

1. protobuf文件的管理方法

1-1. 复杂的嵌套结构数据需要将proto文件分割并定义。

由于数据存储采用了Cassandra,处理的数据并不是扁平的数据层次,而是嵌套的复杂结构数据。因此,可以将深度嵌套的结构数据定义在一个proto文件中,但由于可读性和可维护性较差,我们选择将proto文件分割并进行定义,如下所示。

定义一个示例,在一个文件中。

在一个文件中定义层次结构是可能的,但是当在一个proto文件中表示多个层次模型或者嵌套层数增加到两层、三层时,那么可读性和可维护性都会变差。


syntax = "proto3";
option java_package = "jp.co.sample.datastore.common.model";

package common;

message ParentModel {
  string parentId    = 1;
  string parentNm    = 2;
  ChildModel child   = 3; // ファイル内に定義したChildModelを型に指定
}

message ChildModel {
  string childId    = 1;
  string childNm    = 2;
}

在多个文件中进行定义的例子。

由于可以在不同文件中分割定义ChildModel和ParentModel,因此我们选择按照结构单位分割文件进行管理。后续我们会继续讨论,但是Cassandra支持用户自定义类型(称为UDT),可以在DDL中定义任意结构体,所以我们也将proto模型按照UDT单位进行了分割。


syntax = "proto3";
option java_package = "jp.co.sample.datastore.common.model";

package common;

message ChildModel {
  string childId    = 1;
  string childNm    = 2;
}

syntax = "proto3";
option java_multiple_files = true;
option java_package = "jp.co.sample.datastore.common.model";

package common;

import "common/child_model.proto"; // ChildModelを定義したprotoを指定

message ParentModel {
  string parentId    = 1;
  string parentNm    = 2;
  ChildModel child   = 3;
}

1-2. proto文件应与数据定义语言(DDL)一起管理。

在gRPC中,我们可以通过proto文件定义和管理API规范。通过将这个proto文件使用Git等进行版本控制,我们可以始终保持API规范的最新状态。

然而,在使用数据存储API时,虽然应用程序只需管理proto定义的请求响应参数即可,但为了以结构化的方式处理从Cassandra获取的数据,需要确保与Cassandra表定义的一致性。

在应用程序开发中,DDL的更改和更新是家常便饭。因此,我们将Cassandra的DDL以公司内部的标准格式保存在表定义书文件中进行管理。通过将这个定义书作为输入,我们也可以自动生成proto文件,以确保两者的整合性,即使表定义发生变化。

随着开发规模的增大,吸收proto与DDL差异变得困难,因此从一开始就要建立良好的机制是最佳选择。

1-3. IF模块管理方式

可以根据proto文件自动生成各种语言所需的客户端/服务器接口的源代码。

然而,每次从proto文件自动生成源代码并提交非常麻烦,而且当开发人员增加时,这项工作变得冗杂。所以我们选择从最新的proto文件生成接口模块,并与nexus库协同工作,实现软件包管理。

因为这次是使用Java进行客户端/服务器开发,所以我们通过gradle从nexus获取包进行了定义。

2. 实现使用自定义选项的通用处理。

在API设计中,需要对请求参数进行验证设计。
在gRPC中,在为proto文件定义数据时,必须指定类型,如string或int。
还可以定义和处理集合类型,如map或set。

因此,不需要对来自客户端的请求参数进行类型检查,但需要考虑其他必要的检查和验证,如必填检查和位数检查等。

通过在 proto 文件中利用 Custom Options 对文件或字段进行定义,可以从 gRPC 模型中获取自定义选项并实现任意的处理。

2-1. 客制选项定义示例


syntax = "proto3";

option java_multiple_files = true;
option java_package = "jp.co.sample.datastore.option.model";

package option;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FieldOptions {
  bool required = 50000; // 必須チェックオプション
}

extend google.protobuf.FieldOptions {
  int32 strlen = 50001; // 桁数チェックオプション
}

将上述准备好的自定义选项定义到任意字段中。

syntax = "proto3";

option java_multiple_files = true;
option java_package = "jp.co.sample.datastore.common.model";

package common;

import "option/custom_option.proto"; // カスタムオプションを定義したprotoをimport

message User {
  string user_id            = 1[(required)=true,(strlen)=8]; // 複数オプション定義も可能
  string user_name          = 2[(required)=true];
}

2-2. 获取自定义选项的方法(Java)

这是一个示例,用于从上述定义的message模型的User字段中获取设置的自定义选项。
通过”User.getDescriptorForType().getFields()”可以获取到User模型的元信息FieldDescriptor,
通过操作该FieldDescriptor可以获取选项信息。

for(Descriptors.FieldDescriptor fds: User.getDescriptorForType().getFields()){
    System.out.println(fds.getName())
    for(Map.Entry<Descriptors.FieldDescriptor,Object> entry : fds.getOptions.getAllFields().entrySet()){
        System.out.println("option:" + entry.getKey().getName() + "=" entry.getValue());
    }
}

/* 出力結果 */
// user_id
// option:required=true
// option:strlen=8
// user_nm
// option:required=true

2-3. 验证实现示例

由于可以使用”hasExtension()”方法对Message的FieldDescriptor进行存在检查,因此您可以从gRPC模型中实现每个字段的任意选项验证。此外,gRPC模型继承自一个称为Message类型的共同接口类,通过将其转换为Message类型并操作FieldDescriptor,您可以实现通用的处理逻辑,而不依赖于特定的模型。


if(fds.getOptions().hasExtension(CustomOption.required)){
  // hasExtensionでフィールドメタ情報から"required"オプションが存在するかチェック

  Object value = fds.getOptions().getExtension(CustomOption.required); // getExtensionでオプションの中身を取り出す
  // バリデーション処理実装
}

3. 在 gRPC 模型中,明确处理空字符串和 0。

在proto文件中,如果在模型接口中定义的string或int类型的字段没有设置值,那么获取该字段的值时将得到默认值,即如果是string类型,则为空字符串,如果是int32或int64类型,则为0。

比如,当从客户端接收到gRPC模型并根据字段设置的值对数据存储执行更新操作时,服务器无法判断客户端是有意初始化为空字符串或0,还是仅未设置为gRPC模型的默认值(无需更新)。这是一个问题。

为了解决这个问题,gRPC提供了包装器类,定义这些类可以使其可判断。

3-1. 使用包装器类的proto文件定义示例。


message Test{
  string       value1  = 1; // 空文字をセットしたのかデフォルト値なのか判定できない
  int32        value2  = 2; // 0をセットしたのかデフォルト値なのか判定できない
  StringValue  value3  = 3; // 空文字をセットしたのかデフォルト値なのか判定できる
  Int32Value   value4  = 4; // 0をセットしたのかデフォルト値なのか判定できる
}

3-2. 值的存在检查实现示例


    Test.Builder testBuilder = Test.newBuilder();

    // 明示的に空文字,0をセットする
    testBuilder
        .setValue1("")
        .setValue2(0)
        .setValue3(StringValue.newBuilder().setValue(""))
        .setValue4(Int32Value.newBuilder().setValue(0))
        ;

    for(Descriptors.FieldDescriptor fds : testBuilder.build().getDescriptorForType().getFields()) {
        if (testBuilder.hasField(fds)) {
            System.out.println(fds.getName() + " has field");
        } else {
            System.out.println(fds.getName() + " has not field");
        }
    }

    /*出力例*/
    // value1 has not field
    // value2 has not field
    // value3 has field
    // value4 has field

4. 动态生成查询从gRPC模型。

由于使用Cassandra作为数据存储,因此需要实现一种名为CQL的自定义查询语言来对Cassandra表执行CRUD操作。

由于Cassandra的CQL基本上是基于SQL的,所以相对较直观地实现是可能的,但是为了进行并发更新控制,开发人员需要意识到CAS查询以及对深层次的结构层次(frozen UDT)进行的Update语句以及Map、Set元素的添加或删除等无法在SQL中表示的查询。为了隐藏处理过程并使其能够通过将gRPC的模型类作为参数传递给数据存储来进行CRUD操作,我们对其进行了封装。(类似于KVS版ORM/映射器)

在设计共通处理 gRPC 模型时,关键是通过使用 Message 类型来处理 FieldDescriptor,在自定义选项示例中已经进行了说明。通过利用 Message 类型来操作 FieldDescriptor,可以实现动态生成查询以及实现通用性处理功能。因此,在设计共通处理 gRPC 模型时,我们需要意识到使用 Message 类型。

4.1 CQL的SELECT语句实现示例


    public BuiltStatement select(Message message) {
        BuiltStatement select;
        try {
            // テーブル名セット
            String table = message.getDescriptorForType().getOptions().getExtension(CustomOption.entityOptions)
                    .getTableName();

            // CQL生成
            Select.Selection selection = QueryBuilder.select();
            Map<String, Object> partitionKeyMap = new HashMap<>();

            for (Descriptors.FieldDescriptor fds : message.getDescriptorForType().getFields()) {

                // SELECT句作成
                if (fds.getName().equals("select_enum")) {
                    if (message.getRepeatedFieldCount(fds) > 0) {
                        IntStream.range(0, message.getRepeatedFieldCount(fds)).forEach(
                                i -> selection.column(message.getRepeatedField(fds, i).toString()));
                    } else {
                        selection.all();
                    }
                }

                // パーティションキー抽出
                if (fds.getOptions().getExtension(CustomOption.attributeOptions).getPartitionKey() > 0
                        || fds.getOptions().getExtension(CustomOption.attributeOptions).getClusteringKey() > 0) {
                    partitionKeyMap.put(fds.getName(), message.getField(fds));
                }
            }

            // FROM句生成
            select = selection.json().from(getTableMetadata(table));

            // WHERE句作成
            for (Map.Entry<String, Object> entry : partitionKeyMap.entrySet()) {

                Object value = entry.getValue();

                if (value instanceof String) {
                    ((Select) select).where(eq(entry.getKey(), value));
                } else if 
                    ... 型判別処理省略
                } else {
                    logger.debug("パーティションの型が不正です");
                    throw new RuntimeException("unsupported type");
                }
            }
            return select;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

我们不仅使用Cassandra,还将ElasticSearch作为全文搜索引擎。我们还利用上述的Message类从gRPC模型中动态生成查询,以向ElasticSearch发送查询。这样设计使得应用开发者无需直接实现查询操作,即可通过CRUD操作对数据存储进行操作。

5. 利用gRPC模型的方便处理技巧

虽然在之前的介绍中我们提到过,但在处理gRPC模型时,有一些提示会很有帮助。下面是一些提示供参考。
请注意,这次我们是使用gRPC-Java进行实现的,请将其作为在其他语言中进行实现的参考。

(因为时间有限,我们稍后再添加提示。。。)

5-1. 从gRPC模型以Json格式输出

从gRPC模型中输出Json格式。
如果加上preservingProtoFieldNames,将按照proto中定义的字段名进行输出。
如果不加preservingProtoFieldNames,则将按照驼峰命名法进行输出,根据用途进行选择使用。


JsonFormat.printer().preservingProtoFieldNames().print(gRPCモデル) // proto定義に沿ったフィールド名で出力
JsonFormat.printer().print(gRPCモデル) // camelケースで出力

5-2. 对于 gRPC 模型进行类型判断


for (Descriptors.FieldDescriptor fds : gRPCモデル.getDescriptorForType().getFields()) {
    if (fds.isMapField()) {
        // フィールドがMap型か判定        
    } else if (fds).isRepeated()) {
        // フィールドがSet型か判定
    } else {
        // コレクション以外の型
    }
}

通过指定字段名称从Message类中获取值。


String val = (String) messageModel.getField(messageModel.getDescriptorForType().findFieldByName("フィールド名"));

5-4. gRPC模型之间的合并

一个将值从一个模型合并到另一个模型的示例。通过使用.ignoreUnknownFields(),即使合并目标中没有相应字段,也会被忽略。


JsonFormat.parser().ignoringUnknownFields().merge(
        JsonFormat.printer().preservingProtoFieldNames().print(merge元のモデル),merge先のモデル);
广告
将在 10 秒后关闭
bannerAds