Как стать автором
Обновить

Optional vs Nullable на стыке технологий

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров3.2K

При разработке современных приложений важнейшую роль играет качество клиент-серверного взаимодействия. Для достижения стабильной связи между мобильными и веб приложениями и серверной частью необходимо стремиться к созданию надежного и эффективного механизма обмена данными. Это подразумевает создание устойчивого API, которое трудно сломать даже при изменениях в архитектуре сервера, а также легкость интеграции новых клиентов.

Кроме того, одним из ключевых приоритетов становится повышение продуктивности команд разработчиков. Важно сократить цикл разработки новых функций и обновлений, позволив разработчикам быстро вносить изменения и выпускать обновления, ускоряя процесс итераций.

История одного решения

Будучи участником команды мобильной инфраструктуры, однажды перед нами встала задача реализовать универсальное решение для автоматической генерации клиентских библиотек на основе OpenAPI спецификаций. Эти спецификации создавались для всех зарегистрированных на корпоративном API Gateway (AG) эндпоинтов напрямую из protobuf-ов, используемых нашими микросервисами. API Gateway это такая сущность, которая умела разбирать в некую мета-модель предоставленные бэкэнд командами protobuf-ы и потом пересобирать их в REST запросы. У нас, как у клиентской команды мечтающей о жестком контракте с сервером, возникла идею научить собирать из этой мета модели OpenApi  схемы, что мы успешно и сделали.

 При разработке клиентских библиотек мы учли основные требования к полученным артефактам, такие как 

  • Количество классов и размер итогового файла APK (или формата для iOS и веб) не должно значительно увеличиваться.

  • Если объект используется повторно, должна генерироваться единственная реализация клиента

  • Интеграция и поддержка созданных артефактов не должны становиться дополнительной нагрузкой для команд разработчиков.

  • Нарушение контракта API одной командой не должно блокировать работу другой команды, если этот API не является общим ресурсом.

И все у нас было хорошо, пока мы не заметили, что все 3 наших клиентских приложения  написанных на JavaScript, Swift, Kotlin трактуют обязательные элементы спецификации по разному. При более детальном рассмотрении, мы поняли что не только клиенты трактуют обязательность каких либо полей по разному, но и различные части нашей системы смотрят на обязательность полей под разным углом, так например, наши микросервисы используют протокол сериализации данных Protobuf версии 3, а он предполагает, что все поля необязательны. Однако в REST API, которые описывают наше клиент-серверное взаимодействие, поля могут иметь статус обязательных или необязательных.

Но обо всем по порядку.

При рассмотрении коммуникации между клиентом и сервером мы неизменно сталкиваемся с такими понятиями, как запросы, ответы, заголовки, параметры запроса и параметры пути. Эти элементы могут быть обязательными или необязательными в зависимости от конкретного сценария использования. Например, в RESTful API:

  • Параметры тела запроса являются необязательными и используются для передачи дополнительной информации серверу.

  • Параметры запроса также являются необязательными и применяются для фильтрации или сортировки данных.

  • Параметры пути, напротив, обязательны и служат для идентификации конкретного ресурса на сервере.

  • Заголовки ответа тоже необязательны и могут содержать дополнительную информацию о самом ответе.

Спецификация OpenAPI и обязательное использование элементов

В спецификации OpenAPI такие параметры помечаются как required=true. Рассмотрим пример обязательного параметра пути и тела запроса:

post:
operationId: "postChatPlatformOperationId"
parameters:
- name: "
channel.id"
  in: "path"
  description: "Path parameter channel_id"
  required: true
  schema:
    type: "string"
    nullable: true
requestBody:
  content:
    application/json:
      schema:
        $ref: "#/components/schemas/ChatPlatformChannel"
  required: true

Здесь видно, что тело запроса (requestBody) обязательно должно присутствовать, а параметр пути (channel.id) — идентификатор канала — является обязательным элементом маршрута.

Обязательные и необязательные поля

Протокол Protobuf версии 3 предполагает, что все поля необязательны. Однако в REST API поля могут иметь статус обязательных или необязательных:

  • Обязательное: отсутствие значения приведет к ошибке недействительного запроса.

  • Необязательное: значение может отсутствовать в запросе или ответе.

Например, некоторые объекты требуют наличия определенных полей для успешной обработки, тогда как другие допускают пропуск отдельных значений.

Null-значения

Отдельная проблема возникает при определении возможности установки поля в значение null. Здесь смешиваются два уровня:

  • Высокий уровень: null-поведение для крупных частей взаимодействия клиента и сервера (например, заголовков, запроса, ответа).

  • Низкий уровень: возможность устанавливать конкретные поля объектов в значение null.

Рассмотрим пример, где параметр пути может быть нулевым:

post:
  operationId: "postChatPlatformOperationId"
  parameters:
  - name: "
channel.id"
    in: "path"
    description: "Path parameter channel_id"
    required: true
    schema:
      type: "string"
      nullable: true
  requestBody:
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/ChatPlatformChannel"
    required: true

Однако даже при наличии определения в схеме OpenAPI неясно, какие именно поля внутри объекта ChatPlatformChannel должны передаваться и какие могут быть пропущены. Это связано с отсутствием четких инструкций в исходных proto-файлах, используемых нами как источник истины для генерации OpenApi . Из-за этого поведение генерируемых клиентов различается.

Опциональность  в Kotlin

Для моделирования необязательных полей в Kotlin каждое поле отмечается как nullable, и по умолчанию присваивается значение null, что скрывает реальную возможность присутствия null-значений. Разработчики теряют ясность относительно того, какие поля действительно могут быть пустыми, а какие нет.

Таким образом, правильное определение поведения полей и объектов имеет ключевое значение для понимания и корректной реализации коммуникационных процессов между клиентами и серверами.

Опциональность  в Swift

Опционал — это тип, который может содержать либо действительное значение, либо специальное значение nil. Опциональность записываются с добавлением знака вопроса ? после базового типа:
var username: String? = nil // Может быть строкой или nil

Опциональность  в TypeScript

 Typescript вводит концепцию опциональности для защиты от ошибок, связанных с отсутствием значений (undefined или null).

Тип данных | undefined

Тип данных в Typescript может быть объявлен как допускающий дополнительное состояние отсутствия значения, путём добавления специального типа | undefined (или | null). Такая конструкция означает, что переменная может быть либо обычным типом, либо иметь особое значение, сигнализирующее об отсутствии текущего значения.

let maybeName: string | undefined; maybeName = 'John'; // Допустимо maybeName = undefined; // Допустимо

Но простое объявление с возможностью наличия значения undefined ещё не делает свойства опциональными.

Для обозначения поля класса или параметра метода как опционального используется символ вопроса ? после имени поля. Такой синтаксис показывает компилятору, что данное свойство может быть пропущено или установлено позже.

 

Рассмотрим внимательно нашу OpenApi схему и полученный код на 3х клиентских 

Объект в Open Api схеме:

Сгенерированная kotlin модель:

ChatPlatforChannel:
  type: "object"
  properties:
    id:
      type: "string"
    created_at:
      type: "string"
      nullable: true
    updated_at:
      type: "string"
      nullable: true
    custom_type:
      type: "string"
      nullable: true
    is_frozen:
      type: "boolean"
    last_message:
      $ref: "#/components/schemas/ChatPlatformMessage"

   

data class ChatPlatformChannel (
@Json(name = "id")
 val id: kotlin.String? = null,
  @Json(name = "created_at")
 val createdAt: kotlin.String? = null,
  @Json(name = "updated_at")
 val updatedAt: kotlin.String? = null,

  @Json(name = "custom_type")
  val customType: kotlin.String? = null,
   @Json(name = "is_frozen")
  val isFrozen: kotlin.Boolean? = null,
   @Json(name = "last_message")
  val lastMessage: ChatPlatformMessage? = null,
 )
 

Сгенерированная Swift модель:

Сгенерированная typescript модель:

public struct ChatPlatformChannel: Codable, JSONEncodable, Hashable {
 
     public var id: String?
     public var createdAt: String?
     public var updatedAt: String?
     public var customType: String?
     public var isFrozen: Bool?
     public var lastMessage: ChatPlatformMessage?
 
     public init(id: String? = nil, createdAt: String? = nil, updatedAt: String? = nil, customType: String? = nil, isFrozen: Bool? = nil, lastMessage: ChatPlatformMessage? = nil) {
         
self.id = id
         self.createdAt = createdAt
         self.updatedAt = updatedAt
         self.customType = customType
         self.isFrozen = isFrozen
         self.lastMessage = lastMessage
     }
 }

export interface 

ChatPlatformChannel {

id?: string;   

createdAt?: string | null;    updatedAt?: string | null;    customType?: string | null;    isFrozen?: boolean;    lastMessage?: ChatPlatformMessage; 

}

Все три сгенерированные модели имеют общую черту: поля ChatPlatformChannel представлены как опциональные, используя доступные возможности языка. Такое представление модели не даёт чёткого понимания разработчикам клиентов, как выглядят контракты API и что с ними делать. 

Проверка запросов и ответов в API Gateway (AG)

Поскольку OpenApi схемы мы строили напрямую из protobuf-ов, используемых нашими микро сервисами, то мы постарались решить возникшую проблему посредством описания правил поведения на стороне клиента и сервера, а также введения валидации на стороне ApiGateway.

Что бы сделать OpenApi схему более удобной для разработчиков клиентской части, нам пришлось пересмотреть наш подход к обязательности присутсвтия полей и постараться минимизировать наличие необязательных нулевых значений. Мы приняли за правило давать значения по умолчанию, если поле являтеся необязательным.

Цель данных правил — обеспечить способность АG успешно обрабатывать запросы от устаревших клиентов и адаптироваться к будущим изменениям протоколов до их полной реализации на стороне бэкенда, а также точной декларации способов использования REST API.

Эта концепция была введена в дополнение к уже существующим на стороне AG правилам проверки. Правила проверки на стороне бэкенда имеют приоритет над клиентскими правилами. Например, если значение помечено как FAIL в правилах проверки, оно автоматически становится обязательным, а переопределение на стороне клиента недопустимо и должно завершиться сбоем при разборе proto-файла.

Заключение

Ниже в таблицах представлены наши правила. Нам пришлось принести в жертву гибкость изначальной реализации, для того что бы дат разработчикам более информативный клиент-серверный контракт.  Как вы видите проблемы необязательности и nullability, возникающая на стыке технологических стеков. Подобная проблема существует и в GraphQL и там пока тоже нет оптимального решения. Поэтому если у вас есть мысли на эту тему – пишите, очень интересно узнать сталкивались ли вы с похожей ситуацией и как ее решали.

Таблица соответствия nullability и опциональности для полей тела запроса

Тип

Default Nullability для OpenAPI

AG value к бэкенду при explicit null

АG value клиенту при отсутствии значения от бэкенда

Primitives (string)

Not Nullable

FAIL с 400

default value

Wrappers (StringValue)

Nullable

no value

null

Timestamp, Duration

Not Nullable

no value

nullable: null

Enums

Not Nullable

FAIL с 400

first enum value

Arrays

Not Nullable

no value

nullable: null

OneOf

Not Nullable

no value

nullable: null

Maps

Not Nullable

no value

nullable: null

Messages

Not Nullable

no value

nullable: null

Циклические ссылки

Nullable

no value

null

Таблица соответствия опциональности для полей тела запроса

Тип

Default Optionality для OpenAPI

АG value к бэкенду при отсутствии значения от клиента

UG value клиенту при отсутствии значения от бэкенда

Primitives (string)

Required

Behavior/value согласно правилам проверки запросов

Required: not-nullable: default value

Wrappers (StringValue)

Optional

required-nullable: null

not required-nullable: no value

Timestamp, Duration

Required

required: not-nullable: default value

required: nullable: null

Enums

Required

required: first enum value

not required: no value

Arrays

Required

required: not-nullable: empty array

required: nullable: null

OneOf

Required

required: not-nullable: first message w/defaults

required: nullable: null

Maps

Required

required: not-nullable: empty map

required: nullable: null

Messages

Required

required: not-nullable: message w/defaults

required: nullable: null

Циклические ссылки

Required

required: null

not required: no value

Nullability и опциональность для параметров пути

Параметры пути не могут быть ни null, ни опциональными. Они всегда обязательны и не допускают null-значений. Поддерживаются следующие типы:

  • примитивные типы

  • wrapper-типы

  • перечисления

Nullability и опциональность для параметров запроса

Параметры запроса могут быть null и опциональными. Правила nullability и опциональности совпадают с теми, что установлены для полей тела запроса. Единственное отличие — запрет на использование некоторых типов в качестве параметров запроса. Сейчас разрешено использовать только следующие типы:

  • примитивные типы

  • wrapper-типы

  • перечисления

  • массивы вышеуказанных типов

Теги:
Хабы:
+7
Комментарии3

Публикации

Работа

Ближайшие события