[Перевод] Правильный дизайн API: что такое «один», «много», «нуль» и «ничто»
Здравствуйте, наши постоянные и эпизодические читатели.
Сегодня хотим предложить вам интересную статью о дизайне API и связанных с этим подводных камнях. Не спрашивайте, как мы на нее набрели, творческий поиск — дело очень нелинейное.
Приятного чтения
Обзор
При проектировании API необходимо учитывать множество факторов. Безопасность, согласованность, управление состоянием, стиль; кажется, этот список бесконечен. Однако один фактор часто упускается из виду — речь идет о масштабе. Если при проектировании API с самого начала учитывать масштабы системы, то впоследствии (когда система будет расти) можно сэкономить сотни часов рабочего времени.
Введение
Порой сложно сформулировать, что же представляет собой интерфейс программирования приложений (API). C технической точки зрения к API можно отнести любую функцию, вызываемую кодом другого программиста. Дискуссии о том, какой код «тянет» на API, выходят за рамки этой статьи, поэтому мы будем считать, что API — это и самые простые функции.
В этой статье специально подобраны простые примеры, служащие только для иллюстрации ее основной темы. Использованы функции на языке C#, но основные принципы, изложенные здесь, применимы практически в любых языках, фреймворках или системах. Структуры данных в статье смоделированы в распространенном реляционном стиле, используемом во многих промышленных базах данных. Опять же, примеры написаны только в качестве иллюстраций, не следует рассматривать их в качестве рекомендаций.
Требования
Допустим, вы пишете простейшую систему обработки заказов для клиента, и у уже определены три основных класса (или, если хотите, «структуры данных»). В классе Customer есть «внешний ключ» (по терминологии баз данных) для класса Address, а у класса Order есть внешние ключи для классов Address и Customer. Перед вами стоит задача создать библиотеку, которую можно будет использовать для обработки заказов (Orders). Первое бизнес-правило на такой случай: состояние HomeAddress клиента (Customer) должно быть таким же, как состояние BillingAddress у Order (заказ). Не спрашивайте почему, бизнес-правила обычно умом не понять :)
public class Address
{
public int AddressId { get; set; }
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Zipcode { get; set; }
}
public class Customer
{
public Address HomeAddress { get; set; }
public int CustomerId { get; set; }
public int HomeAddressId { get; set; }
public string CustomerName { get; set; }
}
public class Order
{
public Customer MainCustomer { get; set; }
public Address ShippingAddress { get; set; }
public Address BillingAddress { get; set; }
public int OrderId { get; set; }
public int CustomerId { get; set; }
public int ShippingAddressId { get; set; }
public int BillingAddressId { get; set; }
public decimal OrderAmount { get; set; }
public DateTime OrderDate { get; set; }
}
Реализация
Проверка того, совпадают ли два поля — очевидно, простая задача. Вы надеетесь впечатлить начальника, поэтому состряпали решение менее чем за 10 минут. Функция VerifyStatesMatch возвращает булево значение, по которому вызывающая сторона сможет определить, выполняется бизнес-правило или нет. Вы прогоняете вашу библиотеку через несколько простейших тестов и убеждаетесь, что на выполнение кода тратится в среднем 50 мс, никаких косяков в нем не видно. Начальник очень доволен, дает вашу библиотеку другим разработчикам, чтобы те использовали ее в своих приложениях.
public bool VerifyStatesMatch(Order order)
{
bool retVal = false;
try
{
// Допустим, на эту операцию тратится 25 мс.
Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
// Допустим, на эту операцию тратится 25 мс.
Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
retVal = customer.HomeAddress.State == shippingAddress.State;
}
catch (Exception ex)
{
SomeLogger.LogError(ex);
}
return retVal;
}
Проблема
На следующий день приходите на работу, а у вас на мониторе красуется стикер: «Зайдите ко мне срочно — Шеф». Вы догадываетесь, что вчера так преуспели со своей библиотекой, что сегодня начальник решил поручить вам еще более серьезную задачу. Однако вскоре оказывается, что с вашим кодом возникли серьезные проблемы.
Вы: Доброе утро, шеф, что случилось?
Начальник: Эта ваша библиотека, от нее в коде сплошные проблемы!
Вы: Что? Как?
Начальник: Боб говорит, ваш алгоритм слишком медленный, Джон жалуется, что все неправильно работает, а Стив вот что сказал: «ссылка на объект не указывает на экземпляр объекта».
Вы: Ума не приложу, вчера ее тестировал, и все было нормально
Начальник: Не хочу ничего слышать. Идите и разберитесь!
Не лучшее начало дня, правда? Мне кажется, что большинство разработчиков когда-либо сталкивались с подобной ситуацией. Вы думали, что написали библиотеку «идеально», а она принесла целый ворох проблем. Но если правильно понимать, что такое «Один», «Много», «Нуль» и «Ничто», то вы научитесь различать, где ваш API не соответствует ожиданиям коллег.
Один
en.wikipedia.org/wiki/The_Matrix
Первое руководство к действию — понимать, что такое «Один», и как с ним работать. Я имею в виду, что ваш API должен в любом случае обрабатывать одну порцию ожидаемого ввода без всяких ошибок. Такие ошибки теоретически возможны, но сообщать о них вызывающей стороне вы не обязаны. «Разве это не очевидно?», — можете подумать вы. Что ж, давайте обратимся к примеру и рассмотрим, какие ошибки могут возникнуть при обработке Order.
Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
// что если customer.HomeAddress не загрузилось правильно или оказалось равно null?
retVal = customer.HomeAddress.State == shippingAddress.State;
Как понятно из вышеуказанного комментария, мы предполагаем, что свойство HomeAddress правильно загрузилось из источника данных. Хотя в 99,99% случаев, вероятно, так и будет, по-настоящему надежный API должен учитывать и такой сценарий, когда этого не произойдет. Кроме того, в зависимости от языка сравнение двух свойств State может не пройти, если любое из этих свойств загрузится неправильно. В данном случае важно, что мы ничего не знаем о вводе, который можем получить, либо о данных, извлекаемых из кода, которые мы не контролируем.
Это простейший пример, так что давайте исправим наш код и пойдем дальше.
Customer customer = SomeDataSource.GetCustomer(order.CustomerId);
Address shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
if(customer.HomeAddress != null)
{
retVal = customer.HomeAddress.State == shippingAddress.State;
}
Много
msdn.microsoft.com/en-us/library/w5zay9db.aspx
Возвращаемся к вышеописанному сценарию. Надо поговорить с Бобом. Боб пожаловался, что код работает медленно, но значение 50 мс вполне соответствует длительности выполнения, ожидаемой в системе с данной архитектурой. Но оказывается, что Боб обрабатывает 100 заказов вашего крупнейшего пользователя одним пакетом, поэтому в цикле Боба на выполнение вашего метода тратится 5 секунд.
// Код Боба:
foreach(Order order in bobsOrders)
{
...
bool success = OrderProcess.VerifyStatesMatch(order);
....
}
Вы: Боб, с чего ты взял, что мой код слишком медленный? В нем на обработку заказа тратится всего 50 мс.
Боб: Наш клиент Acme Inc. требует, чтобы их пакетные заказы обрабатывались с максимальной скоростью. Мне приходится обслужить 100 заказов, так что 5 секунд — это слишком долго.
Вы: Ой, я же не знал, что нам приходится обрабатывать заказы пакетами.
Боб: Ну, это только для Acme, они же у нас самый крупный клиент.
Вы: Мне ничего не говорили ни об Acme, ни о пакетных заказах
Боб: Разве твой код не должен обеспечивать эффективную обработку нескольких заказов одновременно?
Вы: Ах… да, конечно.
Совершенно очевидно, что произошло, и почему код кажется Бобу «слишком медленным». Вам ничего не сказали ни об Acme, ни о пакетной обработке. Цикл Боба загружает обычный класс Customer и, скорее всего, 100 раз загружает одну и ту же запись Address. Эту проблему легко решить, если принимать массив заказов, а не один, плюс добавить какое-нибудь простое кэширование. Ключевое слово params в C# существует именно для таких ситуаций.
public bool VerifyStatesMatch(params Order[] orders)
{
bool retVal = false;
try
{
var customerMap = new Dictionary();
var addressMap = new Dictionary();
foreach (Orderorder in orders)
{
Customer customer = null;
if(customerMap.ContainsKey(order.CustomerId))
{
customer = customerMap[order.CustomerId];
}
else
{
customer = SomeDataSource.GetCustomer(order.CustomerId);
customerMap.Add(order.CustomerId, customer);
}
Address shippingAddress = null;
if(addressMap.ContainsKey(order.ShippingAddressId))
{
shippingAddress = addressMap[order.ShippingAddressId];
}
else
{
shippingAddress = SomeDataSource.GetAddress(order.ShippingAddressId);
addressMap.Add(order.ShippingAddressId,shippingAddress);
}
retVal = customer.HomeAddress.State == shippingAddress.State;
if(!retVal)
{
break;
}
}
}
catch (Exception ex)
{
SomeLogger.LogError(ex);
}
return retVal;
}
Если модифицировать функцию таким образом, то пакетная обработка у Боба резко ускорится. Большинство вызовов данных исчезнет, поскольку можно простой найти запись по ее ID во временном кэше (словарь).
Стоит вам открыть свой API для «Много» — и сразу придется подключить какой-либо контроль границ. Что делать, например, если кто-нибудь отправит миллион заказов в ваш метод? Выходит ли такое большое число за пределы возможностей данной архитектуры? Именно в таком случае пригодится представление как о системной архитектуре, так и о бизнес-процессах. Если вы знаете, что на практике может потребоваться обработать максимум 10 000 заказов, то можете уверенно установить контроль на уровне 50 000. Таким образом вы гарантируете, что никто не сможет положить систему одним гигантским неприемлемым вызовом.
Конечно, список возможных оптимизаций этим не ограничивается, но пример показывает, как можно избавиться от ненужной работы, если с самого начала рассчитывать на «множество» экземпляров.
Нуль
Вы: Стив, ты что, передаешь в мой код нулевой указатель?
Стив: Думаю нет, а что?
Вы: Начальник говорит, система ругается «ссылка не указывает…».
Стив: А, так дело, наверное, в унаследованной системе. Я не контролирую вывод из этой системы, мы просто закачиваем ее вывод в новую систему по конвейеру, как есть.
Вы: Бред какой-то, так почему не решить проблему с этими нулями?
Стив: Решаю; делаю в коде проверку на нуль. А ты нет?
Вы: O… да, конечно.
«Ссылка на объект не указывает на экземпляр объекта». Стоит ли объяснять смысл этой ошибки? Многим из нас довелось потратить на борьбу с ней не один час жизни. В большинстве языков нуль, пустое множество и т.д. — совершенно допустимое состояние для любого типа с неопределенным значением (non-value type). Таким образом, любой серьезный API должен учитывать значение «Null», даже если технически вызывающей стороне не разрешается его передавать.
Разумеется, проверка всех ссылок на нуль — сложное дело и порой избыточная мера. Однако ни в коем случае не следует доверять вводу, приходящему из источника, который вы не контролируете. Поэтому мы должны проверять на нуль параметр «orders», а также экземпляры Order внутри него на нуль.
Исправно выполняя проверку на нуль, можно избежать досадных звонков от клиентов, обращающихся за техподдержкой и спрашивающих, что такое «экземпляр объекта». Я всегда предпочитаю перебдеть; пусть лучше моя функция возвращает значение по умолчанию и логирует сообщение (или присылает предупреждение), чем будет выбрасывать довольно-таки бесполезную ошибку «не указывает на экземпляр объекта». Разумеется, такое решение полностью зависит от типа системы, от того, выполняется код на клиенте или на сервере и т.д. Смысл в том, что нуль можно игнорировать, но только до тех пор, пока он вам не аукнется.
ПОЯСНЕНИЕ: Честно говоря, я не утверждаю, что функция должна «бездействовать», если ей попадется недопустимое состояние. Если нулевые параметры для вашей системы неприемлемы, выдавайте исключение (как ArgumentNull в .NET). Однако в некоторых ситуациях совершенно допустим возврат значимого умолчания, а в выдаче исключения нет никакой необходимости. Например, текущие методы обычно возвращают то значение, которое было им передано, если ничего не могут сделать с этим значением. Существует слишком много факторов, не позволяющих давать общие рекомендации на случай, когда придется столкнуться с нулем.
Ничто
youtu.be/CrG-lsrXKRM
Вы: Джон, что ты передаешь в мой код? С виду похоже на неполный Order.
Джон: Ой, извини. Мне-то твой метод и не нужен, но другая библиотека требует, чтобы я передавал параметр Order. Думаю, эта библиотека вызывает твой код. Я с заказами не работаю, но использовать другую библиотеку должен.
Вы: Эту библиотеку нужно поправить: криво же спроектировано!
Джон: Понимаешь, та библиотека органично развивалась вместе с бизнес-задачами — они-то менялись. Написал ее Мэтт, а его на этой неделе не будет; в общем, не знаю, как ее изменить. А разве твой код не должен проверять, допустим ли ввод?
Вы: Да… в самом деле.
Из всех четырех принципов «Ничто», вероятно, сложнее всего описать. Нуль, хоть и кажется «ничем» и «пустотой», имеет определение и поддается количественному выражению. Да что там, в большинстве языков для нуля встроено специальное ключевое слово. Работая с null, ваш API должен иметь дело с таким вводом, который, в сущности, представляет собой мусор. В нашем примере речь идет об обработке Order, у которого нет CustomerId, либо имеющем значение OrderDate пятивековой давности. Более наглядный пример — коллекция, в которой нет ни одного элемента. Эта коллекция не является нулем, поэтому должна относиться к категории «Много», но вызывающая сторона не наполнила коллекцию никакими данными. Всегда необходимо учитывать и такой сценарий, в котором фигурирует «ничто». Давайте скорректируем наш пример, чтобы «ничто» в нем тоже обрабатывалось. Вызывающая сторона не сможет просто передать что-то вроде Order; ее заказ должен будет удовлетворять минимальным общим требованиям. В противном случае эта информация будет расцениваться как «ничто».
...
// Да, я схалтурил. ;-)
if (order != null && order.IsValid)
...
Заключение
Надеюсь, мне удалось донести до читателей главную идею этой статьи: не бывает, чтобы код мог принять любую вводимую информацию без проблем. При реализации любой функции или API приходится учитывать, как будет использоваться этот API. В нашем примере исходная функция увеличилась с 12 до 50 строк, хотя мы не внесли в нее каких-либо кардинальных изменений. Весь код, который мы добавили, нужен для обеспечения масштабирования, контроля границ, а также для того, чтобы функция обращалась с любым вводом правильно и эффективно.
Объем хранимых данных в последние годы рос экспоненциально, поэтому и масштабы вводимых данных будут увеличиваться, тогда как качество этих данных может только падать. Если с самого начала правильно написать API, это может сыграть важнейшую роль для роста бизнеса, адаптации под увеличивающуюся клиентскую базу, а в перспективе — для экономии расходов на техподдержку (да и головной боли у вас будет меньше).