Pull to refresh
1200.23
OTUS
Цифровые навыки от ведущих экспертов

Поддержка Null в Protobuf

Reading time9 min
Views25K
Original author: Erik Englund

Для будущих студентов курса "Java Developer. Professional" подготовили перевод материала.

Приглашаем также посетить открытый вебинар на тему
«gRPC для микросервисов или не REST-ом единым». На открытом вебинаре посмотрим, что такое gRPC и как его можно использовать (а можно ли?) вместо REST-а для коммуникаций между микросервисами.


Рассмотрим следующее определение Protocol Buffer / gRPC:

service MyDataService {
  rpc UpateMyData (UpdateMyDataRequest) 
     returns (UpdateMyDataResponse);
}
message MyData {
  int32 id = 1;
  string stringValue = 2;
  SubData subData = 3;
}

message SubData {
 int64 bigValue = 1;
}
message UpdateMyDataRequest {
  MyData update = 1;
}

И предположим, что вам нужно удалить запись в базе данных для MyData.stringValue. Ваш первый вариант решения, вероятно, будет чем-то вроде этого:

UpdateMyDataRequest request = UpdateMyDataRequest.newBuilder()
  .setUpdate(MyData.newBuilder()
    .setId(id)
    .setStringValue(null)
  )

serviceFutureStub.update(request)

Но это только пока вы не запустите и не получите NullPointerException

По умолчанию сеттеры в коде, сгенерированном с помощью protoc, не разрешают установку null и бросают исключение NullPointerException. А с другой стороны, сгенерированные геттеры никогда не возвращают null. Если поля не заданы, то get возвращает значение по умолчанию.

UpdateMyDataRequest.newBuilder().build().getStringValue() == ""

Как отправить значение null с помощью Protocol Buffers?

Позвольте мне ответить вопросом на вопрос.

Что значит значение равно null?

Проблема в том, что null может обозначать разные вещи в разных контекстах:

  • Null — это null.

  • Null — значение опционально / не установлено.

  • Null — значение по умолчанию.

  • Null — перепутано с другим значением.

Чтобы избежать этой путаницы, разработчики protobuf решили не сериализовать null. Вместо этого protobuf принуждает вас использовать одну из явных стратегий, избегая тем самым семантической путаницы в вашем Protobuf / gRPC API.

Далее мы рассмотрим каждый из указанных выше вариантов использования null и то, как мы можем реализовать их в Protobuf.

Мы будем использовать proto3. У proto2 другая семантика, в которую мы не будем здесь углубляться.

Сначала немного базовых сведений о proto3

Все поля:

  1. Необязательные

  2. НИКОГДА не бывают null

  3. Инициализируются значениями по умолчанию (0, пустая строка и т. д.) 

Null — это Null (паттерн OneOf NullValue)

Иногда null является допустимым значением. Например, null можно использовать для удаления значения из базы данных. Допустим, что в нашем примере мы хотим разрешить клиенту установить значение MyData.stringValue в null.

Эквивалентный Json-объект MyData будет таким:

{
  "id": 123
  "stringValue": null
}

Как мы уже упоминали ранее, мы не можем напрямую просто установить значение в null. Следовательно, нам нужно предоставить информацию о null каким-то другим способом. Это можно сделать, введя nullable-тип. Те, кто знаком с Kotlin, узнают этот паттерн.

Определение Protobuf:

syntax = "proto3";

package io.github.efenglu.protobuf.examples.oneof;

option java_multiple_files = true;

import "google/protobuf/struct.proto";

service MyDataService {
  rpc UpateMyData (UpdateMyDataRequest) 
     returns (UpdateMyDataResponse);
}

message MyData {
  int32 intValue = 1;
  NullableString stringValue = 2;
  NullableSubData subData = 3;
}

message SubData {
  int64 bigValue = 1;
}

message NullableSubData {
  oneof kind {
    google.protobuf.NullValue null = 1;
    SubData data = 2;
  }
}

message NullableString {
  oneof kind {
    google.protobuf.NullValue null = 1;
    string data = 2;
  }
}

message UpdateMyDataRequest {
 MyData data = 1;
}


message UpdateMyDataResponse {

}

Здесь вы можете заметить два “nullable” типа:

  • NullableString

  • NullableSubData

Типы объявлены как oneof с двумя возможными значениями:

  • Null

  • Не null объект

Oneof обеспечивает контроль того, чтобы данные не будут одновременно и null и не null.

Java-клиент будет использовать сгенерированный код следующим образом:

Отправка значения null:

UpdateMyDataRequest request = UpdateMyDataRequest.newBuilder()
  .setData(MyData.newBuilder()
    .setStringValue(NullableString.newBuilder()
      .setNull(NullValue.NULL_VALUE)
      .build()
    )
    .setSubData(NullableSubData.newBuilder()
      .setNull(NullValue.NULL_VALUE)
      .build()
    ).build()
).build();

service.upateMyData(request);

Обратите внимание, что здесь мы явно устанавливаем null через вызов setNull.

Отправка не null значения:

UpdateMyDataRequest request = UpdateMyDataRequest.newBuilder()
  .setData(MyData.newBuilder()
    .setStringValue(NullableString.newBuilder()
      .setData("hello")
      .build()
    )
    .setSubData(NullableSubData.newBuilder()
      .setData(SubData.newBuilder()
        .setBigValue(1234567)
      .build()
    ).build()
  ).build()
).build();

service.upateMyData(request);

Обратите внимание, как здесь вызывается setData для отправки реальных данных.

Реализация сервера:

if (request.hasData()) {

  if (request.getData().hasStringValue()) {
    final String nullableString;
    if (request.getData().getStringValue().hasNull()) {
      nullableString = null;
    } else {
      nullableString = request.getData()
        .getStringValue()
        .getData();
      }
  }

  if (request.getData().hasSubData()) {
    final SubData nullableSubData;
    if (request.getData().getSubData().hasNull()) {
      nullableSubData = null;
    } else {
      nullableSubData = request.getData()
       .getSubData()
       .getData();
    }
  }

}

Обратите внимание, как мы можем проверить значение на null (не null), и что оно было установлено.

Плюсы:

  • Безопасность типов для значений, допускающих null, генерация отдельного типа сообщений для значений, допускающих null.

  • Явная установка null

Минусы:

  • Требуется отдельный тип сообщения для значения null

  • Для многих типов такой подход будет неудобен

Null как опциональное значение: паттерн FieldMask

Данный подход полезен, когда клиенту нужно обновить только часть полей объекта или при создании параметров запроса / поиска, возвращающих частично заполненные объекты.

Здесь null используется для обозначения отсутствующей информации, которая НЕ должна интерпретироваться. Например, значение равно null не потому, что мы хотим, чтобы оно было null, а потому что нам все равно. Обычно вы видите это в виде отсутствующих json-полей.

{
 "id": 123
 -- ommited "stringValue" --
}

Мы сделаем то же самое с proto, только явно укажем, какие поля на самом деле пропустили.

Определение Protobuf:

service MyDataService {
  rpc Update (UpdateMyDataRequest) returns (UpdateMyDataResponse);
  rpc List (ListMyDataRequest) returns (ListMyDataResponse);
}

message MyData {
  int32 id = 1;
  string stringValue = 2;
  SubData subData = 3;
}

message SubData {
  int64 bigValue = 1;
}

message UpdateMyDataRequest {
  MyData update = 1;
  google.protobuf.FieldMask field_mask = 2;
}

message UpdateMyDataResponse {
  MyData new_data = 1;
}

message ListMyDataRequest {
  int32 id = 1;
  google.protobuf.FieldMask field_mask = 2;
}

message ListMyDataResponse {
  repeated MyData data = 1;
}

Обратите внимание, что в сообщениях UpdateMyDataRequest и ListMyDataRequest есть поле FieldMask. Это специальный тип, который содержит информацию о том, какие из полей нас беспокоят.

Пример клиента:

MyData sendUpdate(int id, String value) {
  UpdateMyDataRequest request = UpdateMyDataRequest.newBuilder()
    .setUpdate(MyData.newBuilder()
      .setId(id)
      .setStringValue(value)
    )
    .setFieldMask(FieldMaskUtil.fromFieldNumbers(
      MyData.class, 
      MyData.STRINGVALUE_FIELD_NUMBER)
    )
    .build();

  return serviceFutureStub.update(request).getNewData();
}

List<MyData> listOnlySubData(int id) {
  ListMyDataRequest request = ListMyDataRequest.newBuilder()
    .setId(id)
    .setFieldMask(FieldMaskUtil.fromFieldNumbers(
      MyData.class, 
      MyData.SUBDATA_FIELD_NUMBER)
    )
    .build();

  return serviceFutureStub.list(request).getDataList();
}

Пример сервера:

@Override
public void update(
  UpdateMyDataRequest request,
  StreamObserver<UpdateMyDataResponse> responseObserver
) {

  MyData updateData = request.getUpdate();
  FieldMask fieldMask = request.getFieldMask();

  // Fetch exiting Values
  MyData existing = repo.readData(updateData.getId());
  MyData.Builder builder = existing.toBuilder();

  // Update only the fields listed in the fieldmask
  FieldMaskUtil.merge(fieldMask, updateData, builder);

  // Store the result
  repo.writeData(builder.build());

  // Send the new state back
  responseObserver.onNext(UpdateMyDataResponse.newBuilder()
    .setNewData(builder)
    .build()
  );
}

Примечания к update:

  1. Получаем текущее значение объекта, который хотим обновить.

  2. Превращаем его в билдер.

  3. Объединяем полученные данные с билдером с помощью FieldMaskUtil.

  4. Сохраняем новое состояние.

  5. Возвращаем новое значение.

FieldMaskUtil копирует только поля, указанные в маске полей, полученной из запроса, и оставляет все остальные поля без изменений.

@Override
public void list(
  ListMyDataRequest request,
  StreamObserver<ListMyDataResponse> responseObserver
) {
  int id = request.getId();
  FieldMask fieldMask = request.getFieldMask();
  // Fetch the list
  List<MyData> result = repo.listData(id);

  ListMyDataResponse.Builder response = 
    ListMyDataRespons.newBuilder();
  MyData.Builder builder = MyData.newBuilder();
  for (MyData data : result) {
    builder.clear();

    // Use the field mask to send back ONLY the data requested
    FieldMaskUtil.merge(fieldMask, data, builder);

    response.addData(builder);
  }

  // Send the filtered list back
  responseObserver.onNext(response.build());
}

Многое из этого мы уже видели, только возвращается отфильтрованное значение.

  1. Получаем список.

  2. Фильтруем каждый элемент списка, чтобы он возвращал только запрошенные поля.

  3. Возвращаем отфильтрованный список.

Плюсы:

  1. Лаконичный код.

  2. Проще тестировать.

Минусы:

  1. Концепция FieldMask может быть трудна для понимания.

  2. Клиент должен вручную установить маску поля, что может показаться дублированием.

  3. Может нарушиться семантический контракт полей.

Null как опциональное значение: паттерн Has

Для каждого не примитивного поля сообщения генерируется метод "has", который возвращает boolean. Если значение было установлено (“has been set”), то этот метод возвращает true. Мы можем использовать это, чтобы увидеть, было ли значение установлено клиентом. Таким образом, мы можем сделать вывод, что неустановленные поля не важны.

Но это работает только с непримитивными типами, например, с Message. Если вам нужно такое же поведение с примитивами, то Proto3 предоставляет обёртки (wrapper) для всех примитивов.

...

import "google/protobuf/wrappers.proto";

service MyDataService {
  rpc Update (UpdateMyDataRequest) returns (UpdateMyDataResponse);
}
...
message UpdateMyDataRequest {
  int32 id = 1;
  google.protobuf.StringValue stringValue = 2;
  UpdateSubData subData = 3;
}

message UpdateSubData {
  google.protobuf.Int64Value bigValue = 1;
}
...

Обратите внимание на импорт google/protobuf/wrappers.proto и на google.protobuf.StringValue и google.protobuf.Int64Value. Эти поля больше не будут примитивами, поэтому для них будет сгенерирован метод "has".

Использование в клиенте:

void update() {
  service.update(UpdateMyDataRequest.newBuilder()
    .setStringValue(StringValue.of("customValue"))
    .build()
  );
}

Здесь клиент устанавливает поля, которые он хочет использовать. Единственная особенность — строковое значение должно присваиваться через объект StringValue.

Те, кто знаком с автоупаковкой (pre-auto boxing) в Java, узнают этот паттерн.

Реализация сервера:

@Override
public void update(
  UpdateMyDataRequest request,
  StreamObserver<UpdateMyDataResponse> responseObserver) {

  // Fetch exiting Values
  MyData existing = repo.readData(request.getId());
  MyData.Builder builder = existing.toBuilder();

  // Update Fields as necessary
  if (request.hasStringValue()) {
    builder.setStringValue(request.getStringValue().getValue());
  }

  if (request.hasSubData()) {
    if (request.getSubData().hasBigValue()) {
      builder.setSubData(
        builder.getSubData().toBuilder()
          .setBigValue(request.getSubData()
            .getBigValue()
            .getValue()
          )
        );
      }
  }

  repo.writeData(builder.build());

  responseObserver.onNext(UpdateMyDataResponse.newBuilder()
    .setNewData(builder)
    .build()
  );
}

Здесь, в отличие от делегирования объединения полей в FieldMaskUtil, мы должны объединить поля вручную.

  1. Получаем существующий объект.

  2. Преобразуем в билдер.

  3. Для каждого поля проверяем has и изменяем при необходимости.

  4. Сохраняем значение.

  5. Возвращаем новое значение.

Плюсы:

  1. Проще для концептуального понимания.

  2. Меньше клиентского кода.

Минусы:

  1. Сервер легко сломать: пропустите has или добавьте поле, и слияние сломается.

  2. Много серверного код с множеством ветвлений.

Null антипаттерн: значение по умолчанию

Мы обсудили несколько паттернов, которые вам "следует" использовать, а также их плюсы и минусы. Теперь давайте обсудим паттерны, которые НЕ НУЖНО ИСПОЛЬЗОВАТЬ!

Проверка значения по умолчанию.

У вас может возникнуть желание сказать: «Если здесь значение по умолчанию, то значит оно не было установлено и поэтому равно null.»

НЕТ!

  • Клиент мог установить значение по умолчанию.

  • Proto3 не позволяет указать свои значение по умолчанию, это просто обычные значения по умолчанию (0, "") и, поэтому несколько неоднозначны.

Не пытайтесь умничать, просто относитесь к значению как к значению.

Не пытайтесь создавать свои методы "has" для примитивных типов из значений по умолчанию. Используйте существующие методы "has" и обертки примитивов.

Null антипаттерн: Null-строка

Посмотрите на следующий код:

String value;
if (value != null) {
  // insert value into database
}

Видите ли здесь что-нибудь странное?

  • Что будет в случае пустой строки ""?

  • А если value состоит только из пробелов " "?

Protobuf рассматривает строки как примитивные типы, поэтому они не могут быть null. И вместо того, чтобы проверять строку на null, проверяйте ее на пустоту с помощью стандартных библиотек, таких как apache commons. 

String value;
if (StringUtils.isNotBlank(value)) {
  // insert value into database
}

Теперь ясно, что значение будет вставлено, если value не будет пустым.

Более сложные варианты

Мы пошли немного дальше с "поддержкой null" и написали специальные плагины protoc для адаптации генерируемого кода к нашим требованиям.

Мы добавили поддержку для:

  • Возврата Optional

А также мы форкнули protoc и реализовали:

  • Проверку на null в get.

  • Возможность установки null в сеттерах для очистки значения.

  • Метод "has" для примитивных типов.

Подробнее о разработке плагинов для protoc вы можете посмотреть в статье How to customize the gRPC generated code.

Что в итоге? Да, Protocol Buffers НЕ поддерживает null. Однако по большому счету всё не так уж плохо.

Protocol buffers провоцируют вас задаться следующими вопросами:

  • Как вы используете значение?

  • Что вы пытаетесь сказать с помощью null?

  • Можете ли выразить это без неопределенности с null?

Полный исходный код доступен в репозитории Github. 


Узнать подробнее о курсе "Java Developer. Professional".

Посетить открытый вебинар на тему «gRPC для микросервисов или не REST-ом единым».

Tags:
Hubs:
Total votes 11: ↑8 and ↓3+9
Comments2

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS