Опыт использования контрактов при вызовах REST API

  • Tutorial

Существуют два непримиримых лагеря разработчиков программного обеспечения: первый — утверждает, что чем больше крешится приложение, тем лучше оно работает. Второй — что программист достаточно умен, чтоб обработать любую нештатную ситуацию. Характерной особенностью первых является обилие директив Asset в кода, вторые же, даже операции сложения помещают в блок try — catch. Причем, оба лагеря называют такого рода подход «Программированием по контракту». Аргументы первых сводятся к статье в википедии, аргументы вторых — к книге «Почувствуй класс» Бертрана Мейера.

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

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

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

Справедливости ради нужно сказать, что существуют библиотеки, которые производят документа-объектные преобразования по полученному JSON/XML объекту. Обратной стороной их медали является то, что, как правило, такой подход делает нежизнеспособным использование модели CoreData, поскольку, требуется наличие двух модельных логик: документа-объектных преобразований (JSON -> Binary Objects) и объектно-реляционных преобразований (Binary Objects -> CoreData Entities), поддерживать обе логики, и делать синхронизацию между ними — не хочется никому.

Обычный процесс клиент серверного взаимодействия следующий:
  1. Формируем запрос к серверу с имеющимися параметрами (request)
  2. Делаем запрос по заданному url
  3. Получаем ответ (response)
  4. Извлекаем из ответа данные (parsing)


Как правило в процессе парсинга нас поджидают неожиданности, поскольку не всё что приходит от сервера заслуживает доверия. Приходится каждый параметр проверять на соответствие типу, принадлежность диапазону, правильности ключа и пр., а это существенно увеличивает код метода парсинга, особенно, если получаемая иерархическая структура имеет множество степеней вложенности и разные типы данных (массивы, словари и т. п.) Попытка провалидизировать каждый из параметров наводит на мысль вынести логику валидации хотя бы в отдельный метод. Это позволит сделать подход несколько более гибким:
Получаем response. Валидируем response. Если валидация была успешной — делаем парсинг, иначе — ничего не делаем (или выдаем уведомление серверу/пользователю).
Не секрет, что в основе REST взаимодействия лежит JSON. Те кто предпочитают использовать XML, как правило, имеют свои механизмы решения аналогичных проблем. К примеру, WCF контролирует типы на этапе создания прокси-классов. Увы, пользователи JSON этого сахара лишены, и все приходится делать вручную. В результате, код проверки валидности объекта, чаще всего становится столь же большим, как и код парсинга.

Помочь в решении этой ситуации позволяет использование механизма JSON схем. Формат весьма неплохо стандартизирован и имеет избыточное описание: json-schema.org, кроме того, имеется множество online инструментов, позволяющих формировать схемы по введенному JSON: jsonschema.net/#

Попробуем рассмотреть практический пример для языка программирования Swift.
При беглом поиске удалось найти публичный сервис, который возвращает JSON ответ на простой GET запрос: httpbin.org/get?myFirstParam=myFirstValue&mySecondParam=MySecondValue

Ответ будет примерно следующим:
Response
{
"args": {
"myFirstParam": "myFirstValue",
"mySecondParam": "MySecondValue"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:45.0) Gecko/20100101 Firefox/45.0"
},
"origin": "193.105.7.55",
"url": "https://httpbin.org/get?myFirstParam=myFirstValue&mySecondParam=MySecondValue"
}



Ответ не содержит никакой практически-полезной информации, но позволяет отладить процесс взаимодействия. В ответе содержится строка GET запроса, и параметры, которые были переданы, а так же, некоторые сведения о браузере, через который был произведен запрос. При осуществлении запроса с симулятора или реального устройства результат ответа может быть немного другим. Вместе с тем, он обязан быть подчинен определенной схеме, которую можно извлечь, при помощи online инструментов (http://jsonschema.net/#/ и подобных).

В левой панели установим все галочки. Переключатель опции «Arrays» рекомендую поставить в значение «Single schema (list validation)» (особенности языка Swift).


Скопируем браузерный ответ в верхнее левое окно, и убедимся, что мы разместили валидный JSON. Это будет ясно по надписи «Well done! You provided valid JSON.» на зеленом фоне непосредственно под окном. К сожалению, при выводе ответа в XCode консоль даже при помощи оператора print() не соблюдаются требования формата. Если Вы все же решитесь брать текст ответа из консоли, Вам придется заменить все символы равенства «=» на двоеточие «:», и все имена полей взять в парные кавычки.


После нажатия на кнопку Generate Schema мы получаем в правом окне довольно длинную схему для такого небольшого запроса:

Scheme
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "http://myjsonschema.net",
"type": "object",
"additionalProperties": true,
"title": "Root schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "/",
"properties": {
"args": {
"id": "http://myjsonschema.net/args",
"type": "object",
"additionalProperties": true,
"title": "Args schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "args",
"properties": {}
},
"headers": {
"id": "http://myjsonschema.net/headers",
"type": "object",
"additionalProperties": true,
"title": "Headers schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "headers",
"properties": {
"Accept": {
"id": "http://myjsonschema.net/headers/Accept",
"type": "string",
"minLength": 1,
"title": "Accept schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "Accept",
"default": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"enum": [
null,
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
]
},
"Accept-Encoding": {
"id": "http://myjsonschema.net/headers/Accept-Encoding",
"type": "string",
"minLength": 1,
"title": "Accept-Encoding schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "Accept-Encoding",
"default": "gzip, deflate, br",
"enum": [
null,
"gzip, deflate, br"
]
},
"Accept-Language": {
"id": "http://myjsonschema.net/headers/Accept-Language",
"type": "string",
"minLength": 1,
"title": "Accept-Language schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "Accept-Language",
"default": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3",
"enum": [
null,
"ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3"
]
},
"Host": {
"id": "http://myjsonschema.net/headers/Host",
"type": "string",
"minLength": 1,
"title": "Host schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "Host",
"default": "httpbin.org",
"enum": [
null,
"httpbin.org"
]
},
"User-Agent": {
"id": "http://myjsonschema.net/headers/User-Agent",
"type": "string",
"minLength": 1,
"title": "User-Agent schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "User-Agent",
"default": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:45.0) Gecko/20100101 Firefox/45.0",
"enum": [
null,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:45.0) Gecko/20100101 Firefox/45.0"
]
}
}
},
"origin": {
"id": "http://myjsonschema.net/origin",
"type": "string",
"minLength": 1,
"title": "Origin schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "origin",
"default": "193.105.7.55",
"enum": [
null,
"193.105.7.55"
]
},
"url": {
"id": "http://myjsonschema.net/url",
"type": "string",
"minLength": 1,
"title": "Url schema.",
"description": "An explanation about the purpose of this instance described by this schema.",
"name": "url",
"default": "https://httpbin.org/get",
"enum": [
null,
"https://httpbin.org/get"
]
}
},
"required": [
"args",
"headers",
"origin",
"url"
]
}



В принципе, схему можно сократить, не устанавливая галочки, и приведя переключатель «Array» в состояние «Single empty schema», но так мы лишимся возможности использовать некоторые плюшки совместного использования схемы и языка Swift.

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

Создайте файл с именем response.json и добавьте его в проект.
Если Вы используете Cocoapods добавьте строку
pod ‘VVJSONSchemaValidation' в Ваш Podfile. Если Cocoapods Вы не испоьзуете, то придется обратится непосредственно к GitHub репозиторию Власа Волошина: github.com/vlas-voloshin/JSONSchemaValidation

После обновления Cocoapods в исходный код проекта будет достаточно добавить следующий класс:

Validator class
import UIKit
import VVJSONSchemaValidation

class Validator
{

private var _schemaName = ""
var schemaName:String {
get {
return _schemaName
}
set(value)
{
_schemaName = value
guard let path = NSBundle.mainBundle().pathForResource(value, ofType: "json") else {
return
}

do
{
if let schemaData = NSData(contentsOfFile:path) {
self.schema = try VVJSONSchema(data: schemaData, baseURI: nil, referenceStorage: nil)
}
}
catch let error as NSError
{
print("\n")
print("===============================================================")
print("Schema '\(value).json' didn't create:\n\(error.localizedDescription)")
print("===============================================================")
print("\n")
}
}
}

private var schema:VVJSONSchema?

func validate(response:AnyObject?) -> Bool
{
if let schema = self.schema
{
do {
try schema.validateObject(response!)
}
catch let error as NSError
{
print("\n")
print("===============================================================")
print("\(error.userInfo["NSLocalizedDescription"]!)\n\(error.userInfo["NSLocalizedFailureReason"]!)")
print("===============================================================")
print("\n")
return false
}
}

return true
}
}




В классе, в котором получаете ответ от сервера добавляем:

let validator = Validator()
validator.schemaName = "response"


А в методе (блоке) где получаем серверный ответ пишем:

if self.validator.validate(response) {
self.parse(response) // <— это метод извлечения данных их JSON
}


Вот и все.
Теперь, если со стороны сервера придут данные которые не соответствуют указанной схеме, то механизм парсинга не будет запущен. Вам не нужно описывать в коде логику JSON ответа, только для того, чтоб понять, не допущена ли там какая-то ошибка. Т. е. если он верный — можно смело парсить. Конечно, такой код не защищает Вас на 100%, но 99.9% ошибочных ответов будет отсеяно. Опыт показывает, что при ручном программировании логики, количество ошибочных ответов приводящих к крешу системы отсеивается только в 68,2%.

Дополнительными плюшками от такого подхода можно выделить то, что можно указать дефолтные значения в прямо в схеме:
"default": «193.105.7.55" можно заменить на "default": "127.0.0.1",


А в «enum» привести перечень тех значений которые допустимы для объекта модели данных. В моем случае, это Optional String (String?), т. е. строка которая потенциально может содержать либо nil, либо «193.105.7.55»:
Enum
"enum": [
null,
"193.105.7.55"
]



Очень легко перейти к развитию этой концепции:
  1. Схемы могут создаваться разработчиками, которые разрабатывают REST API, и в готовом виде интегрироваться в приложение. В случае ошибок будет всегда один ответственный за нарушение целостности данных.
  2. В случае, если валидация данных не проходит, на сторону сервера передается название схемы, запрос и ответ сервера. Это позволит оперативно отследить и корректировать работу API на стороне back-end

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

На моей майке будет написано:

Поделиться публикацией

Комментарии 6

    +2
    Простите, я не очень понимаю, что именно вы предлагаете. Может потому что я не знаком с Swift.
    Вы хотите валидировать ответ сервера?
    Если от сервера пришёл ответ, то это либо то, что мы просили, либо сообщение об ошибке. Как ответ сервера может быть невалидным?
    Почему клиент решает, какой ответ сервера корректный, а какой нет?
    А главное — зачем для этого схема?
    Если мы являемся разработчиком сервера и клиента, то у нас вероятно общая codebase между сервером и клиентом. Мы будем проверять, что эти классы правильно сериализуются и десериализуются?
      0
      Серверные разработчики могут изменить API таким образом, что его обработка приведет к крешу приложения. Если клиент пишется на готовом API — такая ситуация никогда не возникает. Но вот когда API пишется одновременно с клиентом — такая ситуация возникает сплошь и рядом (заменили поле id на ident). Но даже уже существующее API может вызвать проблему, если, вдруг, отправит вместо int тип double или string. Для языков с нестрогой типизацией это обычное явление.
        0
        Про версионность API не слышали?
      0
      Опять из REST-а кто-то пытается вернуться в SOAP. В REST специфицировать нужно не запросы (они уже специфицированы в HTTP, там выделено строго ограниченное количество глаголов и кодов ошибок для взаимодействия с ресурсами), а сами ресурсы (их отношения с другими ресурсами). Делается это согласно HATEOAS в каждом возвращаемом ресурсе с помощью HAL, а не отдельной схемой, как было принято в WSDL.
        0
        HATEOAS и HAL это частные реализации HTTP протокола. Нет нужды подменять ими REST. REST/RESTful повсеместно используется мобильными разработчиками. Поскольку, как правило, мобильный API ограничивается GET и POST запросами, то в тексте упоминается именно REST, а не RESTful, хотя этот факт ни на что не влияет. Кроме того, Вы подменяете HTTP статусы кодами ошибок REST — последние нигде не определены стандартом, и могут меняться разработчиками в широких пределах.
          0
          HTTP и HAL — это частная реализация протокола и языка описания ресурсов в парадигме (архитектуре, подходе) REST. Не называйте REST-ом «мобильный API, ограниченным запросами GET и POST». Вы запутались в аббревиатурах и терминах, сочиняя их смысл на ходу.

          То, что вы описываете, называется RPC, это противоположный REST-у подход. И в RPC over HTTP уже давно придуманы стандарты контрактов (попробуйте SOAP или XML-RPC).

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.