Pull to refresh

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

Reading time 9 min
Views 8.1K

Существуют два непримиримых лагеря разработчиков программного обеспечения: первый — утверждает, что чем больше крешится приложение, тем лучше оно работает. Второй — что программист достаточно умен, чтоб обработать любую нештатную ситуацию. Характерной особенностью первых является обилие директив 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
Only registered users can participate in poll. Log in, please.
На моей майке будет написано:
0% Asset(Asset_our_everything!) 0
32.14% Try — catch Forever! 9
28.57% Validate, validate and validate again! 8
39.29% I'm Feeling Lucky! 11
28 users voted. 26 users abstained.
Tags:
Hubs:
+4
Comments 6
Comments Comments 6

Articles