Руководство Google по стилю в C++. Часть 8
Все мы при написании кода пользуемся правилами оформления кода. Иногда изобретаются свои правила, в других случаях используются готовые стайлгайды. Хотя все C++ программисты читают на английском легче, чем на родном, приятнее иметь руководство на последнем.
Эта статья является переводом части руководства Google по стилю в C++ на русский язык.
Исходная статья (fork на github), обновляемый перевод.
Именование
Основные правила стиля кодирования приходятся на именование. Вид имени сразу же (без поиска объявления) говорит нам что это: тип, переменная, функция, константа, макрос и т.д. Правила именования могут быть произвольными, однако важна их согласованность, и правилам нужно следовать.
Общие принципы именования
- Используйте имена, который будут понятны даже людям из другой команды.
- Имя должно говорить о цели или применимости объекта.
- Не экономьте на длине имени, лучше более длинное и более понятное (даже новичкам) имя.
- Поменьше аббревиатур, особенно если они незнакомы вне проекта.
- Используйте только известные аббревиатуры (Википедия о них знает?).
- Не сокращайте слова.
class MyClass < public: int CountFooErrors(const std::vector& foos) < int n = 0; // Чёткий смысл для небольшой области видимости for (const auto& foo : foos) < . ++n; >return n; > void DoSomethingImportant() < std::string fqdn = . ; // Известная аббревиатура полного доменного имени >private: const int kMaxAllowedConnections = . ; // Чёткий смысл для контекста >;
class MyClass < public: int CountFooErrors(const std::vector& foos) < int total_number_of_foo_errors = 0; // Слишком подробное имя для короткой функции for (int foo_index = 0; foo_index < foos.size(); ++foo_index) < // Лучше использовать `i` . ++total_number_of_foo_errors; >return total_number_of_foo_errors; > void DoSomethingImportant() < int cstmr_id = . ; // Сокращённое слово (удалены буквы) >private: const int kNum = . ; // Для целого класса очень нечёткое имя >;
Отметим, что типовые имена также допустимы: i для итератора или счётчика, T для параметра шаблона.
В дальнейшем при описании правил «word» / «слово» это всё, что пишется на английском без пробелов, в том числе и аббревиатуры. В слове первая буква может быть заглавной (зависит от стиля: «camel case» или «Pascal case»), остальные буквы — строчные. Например, предпочтительно StartRpc(), нежелательно StartRPC().
Параметры шаблона также следуют правилам своих категорий: Имена типов, Имена переменных и т.д…
Имена файлов
Имена файлов должны быть записаны только строчными буквами, для разделения можно использовать подчёркивание (_) или дефис (—). Используйте тот разделитель, который используется в проекте. Если единого подхода нет — используйте «_».
Примеры подходящих имён:
- my_useful_class.cc
- my-useful-class.cc
- myusefulclass.cc
- myusefulclass_test.cc // _unittest and _regtest are deprecated.
Не используйте имена, уже существующие в /usr/include, такие как db.h.
Старайтесь давать файлам специфичные имена. Например, http_server_logs.h лучше чем logs.h. Когда файлы используются парами, лучше давать им одинаковые имена. Например, foo_bar.h и foo_bar.cc (и содержат класс FooBar).
Имена типов
Имена типов начинаются с прописной буквы, каждое новое слово также начинается с прописной буквы. Подчёркивания не используются: MyExcitingClass, MyExcitingEnum.
Имена всех типов — классов, структур, псевдонимов, перечислений, параметров шаблонов — именуются в одинаковом стиле. Имена типов начинаются с прописной буквы, каждое новое слово также начинается с прописной буквы. Подчёркивания не используются. Например:
// classes and structs class UrlTable < . class UrlTableTester < . struct UrlTableProperties < . // typedefs typedef hash_mapPropertiesMap; // using aliases using PropertiesMap = hash_map; // enums enum UrlTableErrors < .
Имена переменных
Имена переменных (включая параметры функций) и членов данных пишутся строчными буквами с подчёркиванием между словами. Члены данных классов (не структур) дополняются подчёркиванием в конце имени. Например: a_local_variable, a_struct_data_member, a_class_data_member_.
Имена обычных переменных
std::string table_name; // OK - строчные буквы с подчёркиванием
std::string tableName; // Плохо - смешанный стиль
Члены данных класса
Члены данных классов, статические и нестатические, именуются как обычные переменные с добавлением подчёркивания в конце.
class TableInfo < . private: std::string table_name_; // OK - подчёркивание в конце static Pool* pool_; // OK. >;
Члены данных структуры
Члены данных структуры, статические и нестатические, именуются как обычные переменные. К ним не добавляется символ подчёркивания в конце.
struct UrlTableProperties < std::string name; int num_entries; static Pool* pool; >;
См. также Структуры vs Классы, где описано когда использовать структуры, когда классы.
Имена констант
Объекты объявляются как constexpr или const, чтобы значение не менялось в процессе выполнения. Имена констант начинаются с символа «k», далее идёт имя в смешанном стиле (прописные и строчные буквы). Подчёркивание может быть использовано в редких случаях когда прописные буквы не могут использоваться для разделения. Например:
const int kDaysInAWeek = 7; const int kAndroid8_0_0 = 24; // Android 8.0.0
Все аналогичные константные объекты со статическим типом хранилища (т.е. статические или глобальные, подробнее тут: Storage Duration) именуются также. Это соглашение является необязательным для переменных в других типах хранилища (например, автоматические константные объекты).
Имена функций
Обычные функции именуются в смешанном стиле (прописные и строчные буквы); функции доступа к переменным (accessor и mutator) должны иметь стиль, похожий на целевую переменную.
Обычно имя функции начинается с прописной буквы и каждое слово в имени пишется с прописной буквы.
void AddTableEntry(); void DeleteUrl(); void OpenFileOrDie();
(Аналогичные правила применяются для констант в области класса или пространства имён (namespace) которые представляют собой часть API и должны выглядеть как функции (и то, что они не функции — некритично))
Accessor-ы и mutator-ы (функции get и set) могут именоваться наподобие соответствующих переменных. Они часто соответствуют реальным переменным-членам, однако это не обязательно. Например, int count() и void set_count(int count).
Именование пространства имён (namespace)
Пространство имён называется строчными буквами. Пространство имён верхнего уровня основывается на имени проекта. Избегайте коллизий ваших имён и других, хорошо известных, пространств имён.
Пространство имён верхнего уровня — это обычно название проекта или команды (которая делала код). Код должен располагаться в директории (или поддиректории) с именем, соответствующим пространству имён.
Не забывайте правило не использовать аббревиатуры — к пространствам имён это также применимо. Коду внутри вряд ли потребуется упоминание пространства имён, поэтому аббревиатуры — это лишнее.
Избегайте использовать для вложенных пространств имён известные названия. Коллизии между именами могут привести к сюрпризам при сборке. В частности, не создавайте вложенных пространств имён с именем std. Рекомендуются уникальные идентификаторы проекта (websearch::index, websearch::index_util) вместо небезопасных к коллизиям websearch::util.
Для internal / внутренних пространств имён коллизии могут возникать при добавлении другого кода (внутренние хелперы имеют свойство повторяться у разных команд). В этом случае хорошо помогает использование имени файла для именования пространства имён. (websearch::index::frobber_internal для использования в frobber.h)
Имена перечислений
Перечисления (как с ограничениями на область видимости (scoped), так и без (unscoped)) должны именоваться либо как константы, либо как макросы. Т.е.: либо kEnumName, либо ENUM_NAME.
Предпочтительно именовать отдельные значения в перечислителе как константы. Однако, допустимо именовать как макросы. Имя самого перечисления UrlTableErrors (и AlternateUrlTableErrors), это тип. Следовательно, используется смешанный стиль.
enum UrlTableErrors < kOk = 0, kErrorOutOfMemory, kErrorMalformedInput, >; enum AlternateUrlTableErrors < OK = 0, OUT_OF_MEMORY = 1, MALFORMED_INPUT = 2, >;
Вплоть до января 2009 года стиль именования значений перечисления был как у макросов. Это создавало проблемы дублирования имён макросов и значений перечислений. Применение стиля констант решает проблему и в новом коде предпочтительно использовать стиль констант. Однако, старый код нет необходимости переписывать (пока нет проблем дублирования).
Имена макросов
Вы ведь не собираетесь определять макросы? На всякий случай (если собираетесь), они должны выглядеть так:
MY_MACRO_THAT_SCARES_SMALL_CHILDREN_AND_ADULTS_ALIKE.
Пожалуйста прочтите как определять макросы; Обычно, макросы не должны использоваться. Однако, если они вам абсолютно необходимы, именуйте их прописными буквами с символами подчёркивания.
#define ROUND(x) . #define PI_ROUNDED 3.0
Исключения из правил именования
Если вам нужно именовать что-то, имеющее аналоги в существующем C или C++ коде, то следуйте используемому в коде стилю.
bigopen()
имя функции, образованное от open()
uint
определение, похожее на стандартные типы
bigpos
struct или class, образованный от pos
sparse_hash_map
STL-подобная сущность; следуйте стилю STL
LONGLONG_MAX
константа, такая же как INT_MAX
Прим.: ссылки могут вести на ещё не переведённые разделы руководства.
- C++
- styleguide
- перевод с английского
Как писать код на C++
1. Этот текст носит рекомендательный характер.
2. Если вы редактируете код, то имеет смысл писать так, как уже написано.
3. Стиль нужен для единообразия. Единообразие нужно, чтобы было проще (удобнее) читать код. А также, чтобы было легче осуществлять поиск по коду.
4. Многие правила продиктованы не какими либо разумными соображениями, а сложившейся практикой.
Форматирование
1. Большую часть форматирования сделает автоматически clang-format .
2. Отступы — 4 пробела. Настройте среду разработки так, чтобы таб добавлял четыре пробела.
3. Открывающая и закрывающие фигурные скобки на отдельной строке.
inline void readBoolText(bool & x, ReadBuffer & buf) char tmp = '0'; readChar(tmp, buf); x = tmp != '0'; >
4. Если всё тело функции — один statement , то его можно разместить на одной строке. При этом, вокруг фигурных скобок ставятся пробелы (кроме пробела на конце строки).
inline size_t mask() const return buf_size() - 1; > inline size_t place(HashValue x) const return x & mask(); >
5. Для функций. Пробелы вокруг скобок не ставятся.
void reinsert(const Value & x)
memcpy(&buf[place_value], &x, sizeof(x));
6. В выражениях if , for , while и т.д. перед открывающей скобкой ставится пробел (в отличие от вызовов функций).
for (size_t i = 0; i rows; i += storage.index_granularity)
7. Вокруг бинарных операторов ( + , - , * , / , % , …), а также тернарного оператора ?: ставятся пробелы.
UInt16 year = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + (s[2] - '0') * 10 + (s[3] - '0'); UInt8 month = (s[5] - '0') * 10 + (s[6] - '0'); UInt8 day = (s[8] - '0') * 10 + (s[9] - '0');
8. Если ставится перенос строки, то оператор пишется на новой строке, и перед ним увеличивается отступ.
if (elapsed_ns) message " (" rows_read_on_server * 1000000000 / elapsed_ns " rows/s., " bytes_read_on_server * 1000.0 / elapsed_ns " MB/s.) ";
9. Внутри строки можно, выполнять выравнивание с помощью пробелов.
dst.ClickLogID = click.LogID; dst.ClickEventID = click.EventID; dst.ClickGoodEvent = click.GoodEvent;
10. Вокруг операторов . , -> не ставятся пробелы.
При необходимости, оператор может быть перенесён на новую строку. В этом случае, перед ним увеличивается отступ.
11. Унарные операторы -- , ++ , * , & , … не отделяются от аргумента пробелом.
12. После запятой ставится пробел, а перед — нет. Аналогично для точки с запятой внутри выражения for .
13. Оператор [] не отделяется пробелами.
14. В выражении template <. >, между template и < ставится пробел, а после < и до >не ставится.
template typename TKey, typename TValue> struct AggregatedStatElement >
15. В классах и структурах, public , private , protected пишется на том же уровне, что и class/struct , а остальной код с отступом.
template typename T> class MultiVersion public: /// Version of object for usage. shared_ptr manage lifetime of version. using Version = std::shared_ptrconst T>; ... >
16. Если на весь файл один namespace и кроме него ничего существенного нет, то отступ внутри namespace не нужен.
17. Если блок для выражения if , for , while , … состоит из одного statement , то фигурные скобки не обязательны. Вместо этого поместите statement на отдельную строку. Это правило справедливо и для вложенных if , for , while , …
Если внутренний statement содержит фигурные скобки или else , то внешний блок следует писать в фигурных скобках.
/// Finish write. for (auto & stream : streams) stream.second->finalize();
18. Не должно быть пробелов на концах строк.
19. Исходники в кодировке UTF-8.
20. В строковых литералах можно использовать не-ASCII.
", " (timer.elapsed() / chunks_stats.hits) " μsec/hit.";
21. Не пишите несколько выражений в одной строке.
22. Внутри функций группируйте блоки кода, отделяя их не более, чем одной пустой строкой.
23. Функции, классы, и т. п. отделяются друг от друга одной или двумя пустыми строками.
24. const (относящийся к значению) пишется до имени типа.
//correct const char * pos const std::string & s //incorrect char const * pos
25. При объявлении указателя или ссылки, символы * и & отделяются пробелами с обеих сторон.
//correct const char * pos //incorrect const char* pos const char *pos
26. При использовании шаблонных типов, пишите using (кроме, возможно, простейших случаев).
То есть, параметры шаблона указываются только в using и затем не повторяются в коде.
using может быть объявлен локально, например, внутри функции.
//correct using FileStreams = std::mapstd::string, std::shared_ptrStream>>; FileStreams streams; //incorrect std::mapstd::string, std::shared_ptrStream>> streams;
27. Нельзя объявлять несколько переменных разных типов в одном выражении.
//incorrect int x, *y;
28. C-style cast не используется.
//incorrect std::cerr (int)c ; std::endl; //correct std::cerr static_castint>(c) std::endl;
29. В классах и структурах, группируйте отдельно методы и отдельно члены, внутри каждой области видимости.
30. Для не очень большого класса/структуры, можно не отделять объявления методов от реализации.
Аналогично для маленьких методов в любых классах/структурах.
Для шаблонных классов/структур, лучше не отделять объявления методов от реализации (так как иначе они всё равно должны быть определены в той же единице трансляции).
31. Не обязательно умещать код по ширине в 80 символов. Можно в 140.
32. Всегда используйте префиксный инкремент/декремент, если постфиксный не нужен.
for (Names::const_iterator it = column_names.begin(); it != column_names.end(); ++it)
Комментарии
1. Необходимо обязательно писать комментарии во всех нетривиальных местах.
Это очень важно. При написании комментария, можно успеть понять, что код не нужен вообще, или что всё сделано неверно.
/** Part of piece of memory, that can be used. * For example, if internal_buffer is 1MB, and there was only 10 bytes loaded to buffer from file for reading, * then working_buffer will have size of only 10 bytes * (working_buffer.end() will point to position right after those 10 bytes available for read). */
2. Комментарии могут быть сколь угодно подробными.
3. Комментарии пишутся до соответствующего кода. В редких случаях после, на той же строке.
/** Parses and executes the query. */ void executeQuery( ReadBuffer & istr, /// Where to read the query from (and data for INSERT, if applicable) WriteBuffer & ostr, /// Where to write the result Context & context, /// DB, tables, data types, engines, functions, aggregate functions. BlockInputStreamPtr & query_plan, /// Here could be written the description on how query was executed QueryProcessingStage::Enum stage = QueryProcessingStage::Complete /// Up to which stage process the SELECT query )
4. Комментарии следует писать только на английском языке.
5. При написании библиотеки, разместите подробный комментарий о том, что это такое, в самом главном заголовочном файле.
6. Нельзя писать комментарии, которые не дают дополнительной информации. В частности, нельзя писать пустые комментарии вроде этого:
/* * Procedure Name: * Original procedure name: * Author: * Date of creation: * Dates of modification: * Modification authors: * Original file name: * Purpose: * Intent: * Designation: * Classes used: * Constants: * Local variables: * Parameters: * Date of creation: * Purpose: */
7. Нельзя писать мусорные комментарии (автор, дата создания…) в начале каждого файла.
8. Однострочные комментарии начинаются с трёх слешей: /// , многострочные с /** . Такие комментарии считаются «документирующими».
Замечание: такие комментарии могут использоваться для генерации документации с помощью Doxygen. Но, фактически, Doxygen не используется, так как для навигации по коду гораздо удобнее использовать возможности IDE.
9. В начале и конце многострочного комментария, не должно быть пустых строк (кроме строки, на которой закрывается многострочный комментарий).
10. Для закомментированных кусков кода, используются обычные, не «документирующие» комментарии.
11. Удаляйте закомментированные куски кода перед коммитом.
12. Не нужно писать нецензурную брань в комментариях или коде.
13. Не пишите прописными буквами. Не используйте излишнее количество знаков препинания.
/// WHAT THE FAIL.
14. Не составляйте из комментариев строки-разделители.
15. Не нужно писать в комментарии диалог (лучше сказать устно).
/// Why did you do this stuff?
16. Не нужно писать комментарий в конце блока о том, что представлял собой этот блок.
Имена
1. В именах переменных и членов класса используйте маленькие буквами с подчёркиванием.
size_t max_block_size;
2. Имена функций (методов) camelCase с маленькой буквы.
std::string getName() const override return "Memory"; >
3. Имена классов (структур) - CamelCase с большой буквы. Префиксы кроме I для интерфейсов - не используются.
class StorageMemory : public IStorage
4. using называются также, как классы, либо с _t на конце.
5. Имена типов — параметров шаблонов: в простых случаях - T ; T , U ; T1 , T2 .
В более сложных случаях - либо также, как имена классов, либо можно добавить в начало букву T .
template typename TKey, typename TValue> struct AggregatedStatElement
6. Имена констант — параметров шаблонов: либо также, как имена переменных, либо N в простом случае.
template bool without_www> struct ExtractDomain
7. Для абстрактных классов (интерфейсов) можно добавить в начало имени букву I .
class IBlockInputStream
8. Если переменная используется достаточно локально, то можно использовать короткое имя.
В остальных случаях используйте имя, описывающее смысл.
bool info_successfully_loaded = false;
9. В именах define и глобальных констант используется ALL_CAPS с подчёркиванием.
#define MAX_SRC_TABLE_NAMES_TO_STORE 1000
10. Имена файлов с кодом называйте по стилю соответственно тому, что в них находится.
Если в файле находится один класс, назовите файл, как класс (CamelCase).
Если в файле находится одна функция, назовите файл, как функцию (camelCase).
11. Если имя содержит сокращение, то:
- для имён переменных, всё сокращение пишется маленькими буквами mysql_connection (не mySQL_connection ).
- для имён классов и функций, сохраняются большие буквы в сокращении MySQLConnection (не MySqlConnection ).
12. Параметры конструктора, использующиеся сразу же для инициализации соответствующих членов класса, следует назвать также, как и члены класса, добавив подчёркивание в конец.
FileQueueProcessor( const std::string & path_, const std::string & prefix_, std::shared_ptrFileHandler> handler_) : path(path_), prefix(prefix_), handler(handler_), log(&Logger::get("FileQueueProcessor")) >
Также можно называть параметры конструктора так же, как и члены класса (не добавлять подчёркивание), но только если этот параметр не используется в теле конструктора.
13. Именование локальных переменных и членов класса никак не отличается (никакие префиксы не нужны).
timer (not m_timer)
14. Константы в enum — CamelCase с большой буквы. Также допустим ALL_CAPS. Если enum не локален, то используйте enum class .
enum class CompressionMethod QuickLZ = 0, LZ4 = 1, >;
15. Все имена - по-английски. Транслит с русского использовать нельзя.
не Stroka
16. Сокращения (из нескольких букв разных слов) в именах можно использовать только если они являются общепринятыми (если для сокращения можно найти расшифровку в английской википедии или сделав поисковый запрос).
`AST`, `SQL`. Не `NVDH` (что-то неведомое)
Сокращения в виде обрезанного слова можно использовать, только если такое сокращение является широко используемым.
Впрочем, сокращения также можно использовать, если расшифровка находится рядом в комментарии.
17. Имена файлов с исходниками на C++ должны иметь расширение только .cpp . Заголовочные файлы - только .h .
Как писать код
1. Управление памятью.
Ручное освобождение памяти ( delete ) можно использовать только в библиотечном коде.
В свою очередь, в библиотечном коде, оператор delete можно использовать только в деструкторах.
В прикладном коде следует делать так, что память освобождается каким-либо объектом, который владеет ей.
- проще всего разместить объект на стеке, или сделать его членом другого класса.
- для большого количества маленьких объектов используйте контейнеры.
- для автоматического освобождения маленького количества объектов, выделенных на куче, используйте shared_ptr/unique_ptr .
2. Управление ресурсами.
Используйте RAII и см. пункт выше.
3. Обработка ошибок.
Используйте исключения. В большинстве случаев, нужно только кидать исключения, а ловить - не нужно (потому что RAII ).
В программах офлайн обработки данных, зачастую, можно не ловить исключения.
В серверах, обрабатывающих пользовательские запросы, как правило, достаточно ловить исключения на самом верху обработчика соединения.
В функциях потока, следует ловить и запоминать все исключения, чтобы выкинуть их в основном потоке после join .
/// Если вычислений ещё не было - вычислим первый блок синхронно if (!started) calculate(); started = true; > else /// Если вычисления уже идут - подождём результата pool.wait(); if (exception) exception->rethrow();
Ни в коем случае не «проглатывайте» исключения без разбора. Ни в коем случае, не превращайте все исключения без разбора в сообщения в логе.
//Not correct catch (...) >
Если вам нужно проигнорировать какие-то исключения, то игнорируйте только конкретные, а остальные кидайте обратно.
catch (const DB::Exception & e) if (e.code() == ErrorCodes::UNKNOWN_AGGREGATE_FUNCTION) return nullptr; else throw; >
При использовании функций, использующих коды возврата или errno , проверяйте результат и кидайте исключение.
if (0 != close(fd)) throwFromErrno("Cannot close file " + file_name, ErrorCodes::CANNOT_CLOSE_FILE);
assert не используются.
4. Типы исключений.
В прикладном коде не требуется использовать сложную иерархию исключений. Желательно, чтобы текст исключения был понятен системному администратору.
5. Исключения, вылетающие из деструкторов.
Использовать не рекомендуется, но допустимо.
Используйте следующие варианты:
- Сделайте функцию ( done() или finalize() ), которая позволяет заранее выполнить всю работу, в процессе которой может возникнуть исключение. Если эта функция была вызвана, то затем в деструкторе не должно возникать исключений.
- Слишком сложную работу (например, отправку данных по сети) можно вообще не делать в деструкторе, рассчитывая, что пользователь заранее позовёт метод для завершения работы.
- Если в деструкторе возникло исключение, желательно не «проглатывать» его, а вывести информацию в лог (если в этом месте доступен логгер).
- В простых программах, если соответствующие исключения не ловятся, и приводят к завершению работы с записью информации в лог, можно не беспокоиться об исключениях, вылетающих из деструкторов, так как вызов std::terminate (в случае noexcept по умолчанию в C++11), является приемлемым способом обработки исключения.
6. Отдельные блоки кода.
Внутри одной функции, можно создать отдельный блок кода, для того, чтобы сделать некоторые переменные локальными в нём, и для того, чтобы соответствующие деструкторы были вызваны при выходе из блока.
Block block = data.in->read(); std::lock_guardstd::mutex> lock(mutex); data.ready = true; data.block = block; > ready_any.set();
7. Многопоточность.
В программах офлайн обработки данных:
- cначала добейтесь более-менее максимальной производительности на одном процессорном ядре, потом можно распараллеливать код, но только если есть необходимость.
В программах - серверах:
- используйте пул потоков для обработки запросов. На данный момент, у нас не было задач, в которых была бы необходимость использовать userspace context switching.
Fork для распараллеливания не используется.
8. Синхронизация потоков.
Часто можно сделать так, чтобы отдельные потоки писали данные в разные ячейки памяти (лучше в разные кэш-линии), и не использовать синхронизацию потоков (кроме joinAll ).
Если синхронизация нужна, то в большинстве случаев, достаточно использовать mutex под lock_guard .
В остальных случаях, используйте системные примитивы синхронизации. Не используйте busy wait.
Атомарные операции можно использовать только в простейших случаях.
Не нужно писать самостоятельно lock-free структуры данных, если вы не являетесь экспертом.
9. Ссылки и указатели.
В большинстве случаев, предпочитайте ссылки.
10. const.
Используйте константные ссылки, указатели на константу, const_iterator , константные методы.
Считайте, что const — вариант написания «по умолчанию», а отсутствие const только при необходимости.
Для переменных, передающихся по значению, использовать const обычно не имеет смысла.
11. unsigned.
Используйте unsigned , если нужно.
12. Числовые типы.
Используйте типы UInt8 , UInt16 , UInt32 , UInt64 , Int8 , Int16 , Int32 , Int64 , а также size_t , ssize_t , ptrdiff_t .
Не используйте для чисел типы signed/unsigned long , long long , short , signed/unsigned char , char .
13. Передача аргументов.
Сложные значения передавайте по ссылке (включая std::string ).
Если функция захватывает владение объектом, созданным на куче, то сделайте типом аргумента shared_ptr или unique_ptr .
14. Возврат значений.
В большинстве случаев, просто возвращайте значение с помощью return . Не пишите return std::move(res) .
Если внутри функции создаётся объект на куче и отдаётся наружу, то возвращайте shared_ptr или unique_ptr .
В некоторых редких случаях, может потребоваться возвращать значение через аргумент функции. В этом случае, аргументом будет ссылка.
using AggregateFunctionPtr = std::shared_ptrIAggregateFunction>; /** Позволяет создать агрегатную функцию по её имени. */ class AggregateFunctionFactory public: AggregateFunctionFactory(); AggregateFunctionPtr get(const String & name, const DataTypes & argument_types) const;
15. namespace.
Для прикладного кода отдельный namespace использовать не нужно.
Для маленьких библиотек - не требуется.
Для не совсем маленьких библиотек - поместите всё в namespace .
Внутри библиотеки в .h файле можно использовать namespace detail для деталей реализации, не нужных прикладному коду.
В .cpp файле можно использовать static или анонимный namespace для скрытия символов.
Также, namespace можно использовать для enum , чтобы соответствующие имена не попали во внешний namespace (но лучше использовать enum class ).
16. Отложенная инициализация.
Обычно, если для инициализации требуются аргументы, то не пишите конструктор по умолчанию.
Если потом вам потребовалась отложенная инициализация, то вы можете дописать конструктор по умолчанию (который создаст объект с некорректным состоянием). Или, для небольшого количества объектов, можно использовать shared_ptr/unique_ptr .
Loader(DB::Connection * connection_, const std::string & query, size_t max_block_size_); /// Для отложенной инициализации Loader() >
17. Виртуальные функции.
Если класс не предназначен для полиморфного использования, то не нужно делать функции виртуальными зря. Это относится и к деструктору.
18. Кодировки.
Везде используется UTF-8. Используется std::string , char * . Не используется std::wstring , wchar_t .
19. Логирование.
См. примеры везде в коде.
Перед коммитом, удалите всё бессмысленное и отладочное логирование, и другие виды отладочного вывода.
Не должно быть логирования на каждую итерацию внутреннего цикла, даже уровня Trace.
При любом уровне логирования, логи должно быть возможно читать.
Логирование следует использовать, в основном, только в прикладном коде.
Сообщения в логе должны быть написаны на английском языке.
Желательно, чтобы лог был понятен системному администратору.
Не нужно писать ругательства в лог.
В логе используется кодировка UTF-8. Изредка можно использовать в логе не-ASCII символы.
20. Ввод-вывод.
Во внутренних циклах (в критичных по производительности участках программы) нельзя использовать iostreams (в том числе, ни в коем случае не используйте stringstream ).
Вместо этого используйте библиотеку DB/IO .
21. Дата и время.
См. библиотеку DateLUT .
22. include.
В заголовочном файле используется только #pragma once , а include guards писать не нужно.
23. using.
using namespace не используется. Можно использовать using что-то конкретное. Лучше локально, внутри класса или функции.
24. Не нужно использовать trailing return type для функций, если в этом нет необходимости.
auto f() -> void
25. Объявление и инициализация переменных.
//right way std::string s = "Hello"; std::string s"Hello">; //wrong way auto s = std::string"Hello">;
26. Для виртуальных функций, пишите virtual в базовом классе, а в классах-наследниках, пишите override и не пишите virtual .
Неиспользуемые возможности языка C++
2. Спецификаторы исключений из C++03 не используются.
Сообщения об ошибках
Сообщения об ошибках -- это часть пользовательского интерфейса программы, предназначенная для того, чтобы позволить пользователю:
- замечать ошибочные ситуации,
- понимать их смысл и причины,
- устранять эти ситуации.
Форма и содержание сообщений об ошибках должны способствовать достижению этих целей.
Есть два основных вида ошибок:
- пользовательская или системная ошибка,
- внутренняя программная ошибка.
Пользовательская ошибка
Такая ошибка вызвана действиями пользователя (неверный синтаксис запроса) или конфигурацией внешних систем (кончилось место на диске). Предполагается, что пользователь может устранить её самостоятельно. Для этого в сообщении об ошибке должна содержаться следующая информация:
- что произошло. Это должно объясняться в пользовательских терминах ( Function pow() is not supported for data type UInt128 ), а не загадочными конструкциями из кода ( runtime overload resolution failed in DB::BinaryOperationBuilder::Impl, UInt128, Int8>::kaboongleFastPath() ).
- почему/где/когда -- любой контекст, который помогает отладить проблему. Представьте, как бы её отлаживали вы (программировать и пользоваться отладчиком нельзя).
- что можно предпринять для устранения ошибки. Здесь можно перечислить типичные причины проблемы, настройки, влияющие на это поведение, и так далее.
Пример нормального сообщения:
No alias for subquery or table function in JOIN (set joined_subquery_requires_alias=0 to disable restriction). While processing '(SELECT 2 AS a)'.
Сказано что не хватает алиаса, показано, для какой части запроса, и предложена настройка, позволяющая ослабить это требование.
Пример катастрофически плохого сообщения:
The dictionary is configured incorrectly.
Из него не понятно:
- какой словарь?
- в чём ошибка конфигурации?
Что может сделать пользователь в такой ситуации: применять внешние отладочные инструменты, спрашивать совета на форумах, гадать на кофейной гуще, и, конечно же, ненавидеть софт, который над ним так издевается. Не нужно издеваться над пользователями, это плохой UX.
Внутренняя программная ошибка
Такая ошибка вызвана нарушением внутренних инвариантов программы: например, внутренняя функция вызвана с неверными параметрами, не совпадают размеры колонок в блоке, произошло разыменование нулевого указателя, и так далее. Сигналы типа SIGSEGV относятся к этой же категории.
Появление такой ошибки всегда свидетельствует о наличии бага в программе. Пользователь не может исправить такую ошибку самостоятельно, и должен сообщить о ней разработчикам.
Есть два основных варианта проверки на такие ошибки:
- Исключение с кодом LOGICAL_ERROR . Его можно использовать для важных проверок, которые делаются в том числе в релизной сборке.
- assert . Такие условия не проверяются в релизной сборке, можно использовать для тяжёлых и опциональных проверок.
Пример сообщения, у которого должен быть код LOGICAL_ERROR : Block header is inconsistent with Chunk in ICompicatedProcessor::munge(). It is a bug! По каким признакам можно заметить, что здесь говорится о внутренней программной ошибке?
- в сообщении упоминаются внутренние сущности из кода,
- в сообщении написано it's a bug,
- непосредственные действия пользователя не могут исправить эту ошибку. Мы ожидаем, что пользователь зарепортит её как баг, и будем исправлять в коде.
Как выбрать код ошибки?
Код ошибки предназначен для автоматической обработки некоторых видов ошибок, подобно кодам HTTP. SQL стандартизирует некоторые коды, но на деле ClickHouse не всегда соответствует этим стандартам. Лучше всего выбрать существующий код из ErrorCodes.cpp , который больше всего подходит по смыслу. Можно использовать общие коды типа BAD_ARGUMENTS или TYPE_MISMATCH . Заводить новый код нужно, только если вы чётко понимаете, что вам нужна специальная автоматическая обработка конкретно этой ошибки на клиенте. Для внутренних программных ошибок используется код LOGICAL_ERROR .
Как добавить новое сообщение об ошибке?
Когда добавляете сообщение об ошибке:
- Опишите, что произошло, в пользовательских терминах, а не кусками кода.
- Добавьте максимум контекста (с чем произошло, когда, почему, и т.д.).
- Добавьте типичные причины.
- Добавьте варианты исправления (настройки, ссылки на документацию).
- Вообразите дальнейшие действия пользователя. Ваше сообщение должно помочь ему решить проблему без использования отладочных инструментов и без чужой помощи.
- Если сообщение об ошибке не формулируется в пользовательских терминах, и действия пользователя не могут исправить проблему -- это внутренняя программная ошибка, используйте код LOGICAL_ERROR или assert.
Платформа
1. Мы пишем код под конкретные платформы.
Хотя, при прочих равных условиях, предпочитается более-менее кроссплатформенный или легко портируемый код.
2. Язык - C++20 (см. список доступных C++20 фич).
3. Компилятор - clang . На данный момент (апрель 2021), код собирается версией 11. (Также код может быть собран gcc версии 10, но такая сборка не тестируется и непригодна для продакшена).
Используется стандартная библиотека (реализация libc++ ).
4. ОС - Linux, Mac OS X или FreeBSD.
5. Код пишется под процессоры с архитектурой x86_64, AArch64 и ppc64le.
6. Используются флаги компиляции -Wall -Wextra -Werror и -Weverything с некоторыми исключениями.
7. Используется статическая линковка со всеми библиотеками кроме libc.
Инструментарий
1. Хорошая среда разработки - KDevelop.
2. Для отладки используется gdb , valgrind ( memcheck ), strace , -fsanitize=. , tcmalloc_minimal_debug .
3. Для профилирования используется Linux Perf , valgrind ( callgrind ), strace -cf .
4. Исходники в Git.
5. Сборка с помощью CMake .
6. Программы выкладываются с помощью deb пакетов.
7. Коммиты в master не должны ломать сборку проекта.
А работоспособность собранных программ гарантируется только для отдельных ревизий.
8. Коммитьте как можно чаще, в том числе и нерабочий код.
Для этого следует использовать бранчи.
Если ваш код в ветке master ещё не собирается, исключите его из сборки перед push , также вы будете должны его доработать или удалить в течение нескольких дней.
9. Для нетривиальных изменений, используются бранчи. Следует загружать бранчи на сервер.
10. Ненужный код удаляется из исходников.
Библиотеки
1. Используются стандартные библиотеки C++20 (допустимо использовать экспериментальные расширения), а также фреймворки boost , Poco .
2. Библиотеки должны быть расположены в виде исходников в директории contrib и собираться вместе с ClickHouse. Не разрешено использовать библиотеки, доступные в пакетах ОС, или любые другие способы установки библиотек в систему. Подробнее смотрите раздел Рекомендации по добавлению сторонних библиотек и поддержанию в них пользовательских изменений.
3. Предпочтение отдаётся уже использующимся библиотекам.
Общее
1. Пишите как можно меньше кода.
2. Пробуйте самое простое решение.
3. Не нужно писать код, если вы ещё не знаете, что будет делать ваша программа, и как будет работать её внутренний цикл.
4. В простейших случаях, используйте using вместо классов/структур.
5. Если есть возможность - не пишите конструкторы копирования, операторы присваивания, деструктор (кроме виртуального, если класс содержит хотя бы одну виртуальную функцию), move-конструкторы и move-присваивания. То есть, чтобы соответствущие функции, генерируемые компилятором, работали правильно. Можно использовать default .
6. Приветствуется упрощение и уменьшение объёма кода.
Дополнительно
1. Явное указание std:: для типов из stddef.h .
Рекомендуется не указывать. То есть, рекомендуется писать size_t вместо std::size_t , это короче.
При желании, можно дописать std:: , этот вариант допустим.
2. Явное указание std:: для функций из стандартной библиотеки C.
Не рекомендуется. То есть, пишите memcpy вместо std::memcpy .
Причина - существуют похожие нестандартные функции, например, memmem . Мы можем использовать и изредка используем эти функции. Эти функции отсутствуют в namespace std .
Если вы везде напишете std::memcpy вместо memcpy , то будет неудобно смотреться memmem без std:: .
Тем не менее, указывать std:: тоже допустимо, если так больше нравится.
3. Использование функций из C при наличии аналогов в стандартной библиотеке C++.
Допустимо, если это использование эффективнее.
Для примера, для копирования длинных кусков памяти, используйте memcpy вместо std::copy .
4. Перенос длинных аргументов функций.
Допустимо использовать любой стиль переноса, похожий на приведённые ниже:
function( T1 x1, T2 x2)
function( size_t left, size_t right, const & RangesInDataParts ranges, size_t limit)
function(size_t left, size_t right, const & RangesInDataParts ranges, size_t limit)
function(size_t left, size_t right, const & RangesInDataParts ranges, size_t limit)
function( size_t left, size_t right, const & RangesInDataParts ranges, size_t limit)
С какой буквы желательно начинать имена интерфейсов
PHP The Right Way — обязательно к прочтению перед работой.
Правило бойскаута: оставлять место после себя чище чем, оно было до твоего визита.
Переписывайте код, который не соответствует стандартам и правилам хорошего тона.
Стандарты¶
Общий стиль¶
- Полное соблюдение стандартов PSR-1 и PSR-2.
- Для автоматического приведения кода к требуемым стандартам существует инструмент PHP Coding Standards Fixer.
Именование переменных, ключей в массивах и свойств классов¶
- Имена переменных обязательно должны быть в нижнем регистре, в формате snake_case (например, $cart_content ).
- Обязательно осмысленное и информативное именование.
- Правильно:
$counter, $user_id, $product, $is_valid
$с, $uid, $obj, $flag
foreach ($applied_promotion_list as $applied_promotion) // чёткое визуально разделение >
foreach ($applied_promotions as $applied_promotion) // $applied_promotions и $applied_promotion легко перепутать при беглом взгляде >
$is_valid, $has_rendered, $has_children, $use_cache
$valid, $render_flag, $parentness_status, $cache
Именование и объявление констант¶
- Обязательно полностью в верхнем регистре, разделитель — нижнее подчёркивание ( _ ): SORT_ORDER_ASC , COLOR_GREEN .
- Желательный порядок слов в названиях однотипных констант — сначала повторяющаяся часть, потом различающаяся:
- Правильно:
COLOR_GREEN, COLOR_RED, SORTING_ASC, SORTING_DESC
GREEN_COLOR, RED_COLOR, ASC_SORTING, DESC_SORTING
Строковые литералы¶
- При обращении к элементу массива по ключу заключать имя ключа в одинарные кавычки: $product['price']; .
- Все строковые переменные, не содержащие в себе других переменных, заключать в одинарные кавычки: $foo = 'bar'; .
- Если в строку необходимо включить значение переменной, то строка берётся в двойные кавычки, а название переменной обрамляется в фигурные скобки: $greeting_text = "Hello, !"; .
Магические значения прямо посреди кода¶
- В коде не должно быть числовых значений и строковых литералов, значение которых неочевидно:
$product->tracking = 'O'; // Что значит 'O'? . $order_status = 'Y'; // "Y" == "Yes"? "Yellow"?
$product->tracking = Tygh\Enum\ProductTracking::TRACK_WITH_OPTIONS;
Комментарии¶
- Комментарии пишутся только на английском языке. Для комментирования кода внутри функции/в контроллере использовать двойной слеш // .
- Использование perl style(#) не допускается.
- Не пишите комментарий, который дублирует то, что и так выражено кодом. Лучше код без комментариев, чем код с ложными и неактуальными комментариями.
- Будьте точны и кратки.
PHPDoc¶
- Желательно соблюдение черновика стандарта PSR-5. Как только стандарт будет принят, он станет обязательным.
- Обязательно используйте блок с комментарием и описанием аргументов при объявлении всех функций, методов, свойств классов и самих классов.
- Если функция не возвращает значение:
- запрещено писать @return ;
- можно оформлять как @return void;
- Обязательно выравнивайте на один уровень комментарии к тегам, названия параметров и свойств.
- Обязательно оставляйте одну пустую строку перед первым тегом.
- Обязательно оставляйте пустую строку перед и после группы последовательно идущих тегов @param .
- Запрещено оставлять более одной пустой строки подряд.
- Обязательно разбивайте длинный комментарий на несколько строк, а строки выравнивайте на один уровень.
- Запрещено использовать теги @throws и @author .
- Для того, чтобы отметить функцию или метод как устаревшие, обязательно используйте тег @deprecated и указывайте версию с которой функция или метод считаются устаревшими.
- Тип агрументов, содержащих массив экземпляров одного класса, обязательно должен быть описан как коллекция объектов: Class[] .
- Пример правильного форматирования:
/** * Generates date-time intervals of a given period for sales reports * * @param Timezone[] $timezone_list List of timezones to be used * @param int $interval_id Sales reports interval ID * @param int $timestamp_from Timestamp of report period beginning date * @param int $timestamp_to Timestamp of report period end date * @param int $limit Maximal number of the generated intervals. Also, this string * is used to illustrate the wrapped and aligned long comment. * * @deprecated 4.4.1 * @return array */
Быстродействие¶
Желательно не использовать внутри тела циклов вызов Registry::get() . Эта операция очень ресурсоёмкая, и обращение к хранилищу значительно снижает производительность. Чтобы избежать циклических вызовов, необходимо перед циклом присвоить переменной значение из Registry , а уже внутри цикла использовать переменную.
Неприятные запахи кода¶
Code smells — внешние признаки, указывающие на непродуманость архитектуры кода, и зачастую являющиеся причиной многих проблем с поддержкой, расширяемостью и тестируемостью кода.
Отступы и вложенность¶
Один из самых неприятных “запахов кода” — многоуровневая вложенность конструкций, создающая огромное количество отступов слева. Это ухудшает читаемость кода и является симптомом непродуманной архитектуры. К этой проблеме так же относятся случаи, когда весь код функции находится в теле какого-то условия.
Таких ситуаций следует избегать, меняя структуру кода: делать все нужные проверки в самом начале тела функции, иметь несколько точек выхода, либо декомпозировать функцию на более мелкие.
Запомните простое правило: если в рамках одной функции вам приходится делать больше трёх табуляций слева, значит, скорее всего, что-то идёт не так. Реструктурируйте или декомпозируйте ваш код.
php function foobar($foo, $bar, $baz = null) if (!empty($foo['foo_bar'])) $foo_bar = $foo['foo_bar']; if (!empty($bar) && $foo_bar > 10) if (!empty($baz)) // И только здесь начинается какая-то логика > > > return false; >
php public static function filterPickupPoints($data, $service_params) $pickup_points = array(); if (!empty($service_params['deliveries'])) foreach ($data as $key => $delivery) if (!empty($delivery['is_pickup_point']) && in_array($key, $service_params['deliveries'])) foreach ($delivery['pickupPoints'] as $pickup) $pickup_points[$pickup['id']] = $pickup; > > > > return $pickup_points; >
Работа с типами данных¶
PHP — язык со слабой динамической системой типов данных. Это означает, что любая объявленная переменная может содержать любой тип данных. Предоставляя много степеней свободы, такая система прощает много потенциальных ошибок на этапе выполнения кода, которые могут проявиться в самый неожиданный момент.
Работая с переменными и типами данных, полезно выстроить строгую систему контроля типов у себя в голове. Учитывайте, какой тип данных может храниться в той или иной переменной, и выстраивайте структуру кода исходя из явного приведения типов — не сравнивайте строки с числами, а массивы с нулём, и т.д.
Разрабатывая функцию или метод, описание получаемых и возвращаемых типов в PHPDoc помогает осуществлять контроль типов. Внутри тела функции вы можете явно привести значение переменной-аргумента к ожидаемому типу и работать с ним, будучи уверенным наверняка в том, с каким типом данных вы столкнулись.
В таком случае вы сможете использовать оператор строгого сравнения === , и это сбережёт вам и вашим коллегам кучу времени в дальнейшем.
Код, ориентированный на платформу PHP7, обязательно должен использовать строгое указание типов возвращаемых значений и агрументов функций.
Значение по умолчанию¶
Зачастую в коде можно встретить указание пустого значения по умолчанию. В PHP для этой цели существует отдельных тип данных - null .
Если вы используете ноль или пустую строку в качестве пустого значения по-умолчанию, то ваш код подвержен множеству ошибок бизнес-логики, когда реальные данные будут принимать значение нуля или пустой строки, но будут интерпретироваться кодом как пустое значение по умолчанию. Часто этому способствует использование функции empty в условиях и проверках.
Старайтесь по максимуму использовать null и оператор строгого сравнения === для подобных целей.
Инверсированные условия¶
Условия вида !empty($_REQUEST) ухудшают читаемость кода, особенно в составе более комплексных условий и выражений. Старайтесь избегать использования инверсированных условий там, где без них можно обойтись без ухудшения читаемости кода.
Пример исправления кода¶
Возьмем код сразу с несколькими неприятными запахами:
if ($mode == 'assign_manager') if (!empty($_REQUEST['order_id'])) $order_id = $_REQUEST['order_id']; $issuer_id = (!empty($_REQUEST['issuer_id'])) ? $_REQUEST['issuer_id'] : ''; $user_id = $auth['user_id']; if (empty($issuer_id) || ($issuer_id != $user_id)) db_query('UPDATE ?:orders SET issuer_id = ?i WHERE order_id = ?i', $user_id, $order_id); > $order_info = fn_get_order_info($order_id, false, true, true, false); Tygh::$app['view']->assign('order_info', $order_info); $suffix = ".details?order_id=$order_id"; > return array(CONTROLLER_STATUS_REDIRECT, 'orders' . $suffix); >
Этот код можно переписать так:
if ($mode == 'assign_manager') // Теперь значение либо integer, либо null - не задано $order_id = isset($_REQUEST['order_id']) ? (int) $_REQUEST['order_id'] : null; $issuer_id = isset($_REQUEST['issuer_id']) ? (int) $_REQUEST['issuer_id'] : null; $user_id = (int) $auth['user_id']; // Все необходимые валидации в одном месте if ($order_id === null || $issuer_id === $user_id) return array(CONTROLLER_STATUS_REDIRECT, 'orders'); > // Бизнес-логика db_query('UPDATE ?:orders SET issuer_id = ?i WHERE order_id = ?i', $user_id, $order_id); Tygh::$app['view']->assign( 'order_info', fn_get_order_info($order_id, false, true, true, false) ); return array(CONTROLLER_STATUS_REDIRECT, "orders.details?order_id=$order_id>"); >
Функции¶
Именование¶
Обязательно называйте функции полностью в нижнем регистре и начинайте имена либо с префикса fn_ , либо с db_ :
fn_get_addon_option_variants
Аргументы¶
Если у нескольких аргументов есть стандартные значения, либо аргументы по смыслу не являются основными, то объединяйте их в один аргумент $params . Таким образом, в функцию будут передаваться только основные аргументы и массив $params .
Пример такой трансформации:
// до function fn_get_product_data($product_id, &$auth, $lang_code = CART_LANGUAGE, $field_list = '', $get_add_pairs = true, $get_main_pair = true, $get_taxes = true, $get_qty_discounts = false, $preview = false, $features = true, $skip_company_condition = false) // после function fn_get_product_data($product_id, &$auth, $params) $default_params = array( 'lang_code' => CART_LANGUAGE, 'field_list' => '', 'get_add_pairs' => true, 'get_main_pair' => true 'get_taxes' => true, 'get_qty_discounts' = false, 'preview' = false, 'get_features' = true ) $params = fn_array_merge($default_params, $params);
DRY - Don’t repeat yourself¶
Если какой-либо кусок кода встречается в двух и более местах в контроллере/функции, обязательно выносите код в отдельную функцию.
Возвращать значение - это хорошо¶
Кроме функций-обработчиков хуков, желательно избегать передачи переменных в функцию по ссылке, не возвращая функцией ничего, и модифицируя значение исходной переменной. Это может приводить к необъяснимым и неочевидным модификациям значений переменных — сэкономьте своим коллегам и самому себе время, которое вы будете проводить за отладкой кода. Если передача по ссылке делается с целью уменьшить потребление памяти, то спешу вас расстроить: PHP сам делает нужные оптимизации даже при передаче переменной по значению.
Точка выхода¶
Желательно, чтобы функция имела только одну точку выхода. Использование двух и более точек выхода допускается лишь в случаях, если этим достигается:
- низкое ветвление кода (лучше множественный return , чем 5 вложенных if );
- значительная экономия ресурсов (например, функция fn_apply_exceptions_rules в fn.catalog.php).
Комментарии для удаленных функций¶
Этот комментарий добавляется к устаревшим функциям, содержимое которых заменено на вывод нотиса:
php /** * This function is deprecated and no longer used. * Its reference is kept to avoid fatal error occurances. * * @deprecated deprecated since version 3.0 */ ?>
php /** * This function is deprecated and no longer used. * Its reference is kept to avoid fatal error occurances. * * @deprecated deprecated since version 3.0 */ function fn_get_setting_description($object_id, $object_type = 'S', $lang_code = CART_LANGUAGE) fn_generate_deprecated_function_notice('fn_get_setting_description()', 'Settings::get_description($name, $lang_code)'); return false; > ?>
Комментарии для часто встречающихся параметров¶
Это утвержденные комментарии для описания переменных в коде. Если они вам встречаются при определении хука, используйте их, пока смысл соответствует:
$auth - Array of user authentication data (e.g. uid, usergroup_ids, etc.) $cart - Array of the cart contents and user information necessary for purchase $lang_code - 2-letter language code (e.g. 'en', 'ru', etc.) $product_id - Product identifier $category_id - Category identifier $params - Array of various parameters used for element selection $field_list - String of comma-separated SQL fields to be selected in an SQL-query $join - String with the complete JOIN information (JOIN type, tables and fields) for an SQL-query $condition - String containing SQL-query condition possibly prepended with a logical operator (AND or OR) $group_by - String containing the SQL-query GROUP BY field
Объектно-ориентированное программирование¶
Именование сущностей¶
- Обязательно называйте классы, интерфейсы и трейты с заглавной буквы в формате UpperCamelCase.
- Названия абстрактных классов обязательно должны иметь префикс A , например: ABackend , ADatabaseConnection .
- Имена интерфейсов обязательно должны иметь префикс I , например: ICountable , IFilesystemDriver .
- Если имя класса, интерфейса, трейта или метода должно содержать аббревиатуру наподобие URL, API, REST и т.п., то аббревиатура обязательно должна подчиняться правилам CamelCase.
- Правильно:
$a->getApiUrl(), $a = new Rest();, class ApiTest
$a->getAPIURL(), $a = new REST();, class APITest
Константы¶
Правила именования такие же, как и для констант вне классов. Пример:
class Api /** * Default HTTP request format mime type * * @const DEFAULT_REQUEST_FORMAT */ const DEFAULT_REQUEST_FORMAT = 'text/plain';
Свойства¶
- Правила именования такие же, как и для обычных переменных.
- Названия private- и protected- свойств запрещено начинать со знака нижнего подчёркивания ( _ ).
class Api /** * Current request data * * @var Request $request */ private $request = null; /** * Sample var * * @var array $sample_var */ private $sample_var = array();
Методы¶
- В отличие от функций, названия методов обязательно должны начинаться со строчной буквы, формат именования — lowerСamelCase.
- Названия private- и protected- методов запрещено начинать со знака нижнего подчёркивания ( _ ).
- В общем случае, методы в классе желательно группировать по типу области видимости: public -> protected -> private . Пример:
class ClassLoader /** * Creates a new ClassLoader that loads classes of the * specified namespace. * * @param string $include_path Path to namespace */ public function __construct($include_path = null) // . > /** * Gets request method name (GET|POST|PUT|DELETE) from current http request * * @return string Request method name */ private function getMethodFromRequestHeaders() // . >
Пространства имён¶
Tygh — название пространства имён, в котором находятся все пространства имён и классы ядра CS-Cart.
- Все классы, интерфейсы и трейты ядра и аддонов обязательно должны принадлежать этому пространству имён.
- Если несколько классов, интерфейсов или трейтов относятся по смыслу к одному функционалу, то обязательно выделяйте их в общее подпространство, например, как классы менеджера блоков ( Tyqh\BlockManager ) и REST API ( Tyqh\Api ).
- В каждом файле, в котором используются классы, интерфейсы либо трейты, обязательно используйте в начале файла директиву use , которая определяет, какие пространства имён используются в файле. В случае совпадения названий двух и более классов из разных пространств имён, обязательно описывайте алиасы для имён конфликтующих классов ( use \Tygh\BlockManager\RenderManager as BlockRenderer ).
- Любая сущность (класс, интерфейс или трейт) обязательно должна находиться в своем отдельном файле. Наиболее часто это правило нарушается, когда разработчик в одном файл объявляет и класс, и исключение.
- Желательно, чтобы аддоны добавлял свои классы, интерфейсы и трейты только в свое пространство имен \Tygh\Addons\AddonName . Например, для аддона form_builder разрешённое пространство имен — \Tygh\Addons\FormBuilder . Исключением этому правилу служит:
- добавление новых сущностей API (следует добавлять класс в пространство имен \Tygh\Api\Entities ),
- добавление новых коннекторов для центра обновлений (следует добавлять класс в неймспейс TyghUpgradeCenterConnectors).
- Следует помнить, что корневая директория каждого установленного и включённого аддона является директорией-источником автозагрузки классов. Это означает, что класс \Foo\Bar\MyClass , находящийся в папке app/addons/my_changes/Foo/Bar/MyClass.php, будет автоматически загружен в память при вызове в коде конструкции вроде $my_class_instance = new \Foo\Bar\MyClass(); .
- Обязательно требуется группировать директивы use друг с другом. Пример:
use Tygh\Registry; use Tygh\Settings; use Tygh\Addons\SchemesManager as AddonSchemesManager; use Tygh\BlockManager\SchemesManager as BlockSchemesManager; use Tygh\BlockManager\ProductTabs; use Tygh\BlockManager\Location; use Tygh\BlockManager\Exim;
Шаблоны проектирования¶
Не рекомендуется создавать Singleton -классы, и классы, состоящие из статических методов. Код, их использующий, практически невозможно покрыть юнит-тестами.
Оформление SQL-запросов¶
- Запрос необходимо разделять следующим образом (кавычки и точки должны жестко соблюдаться):
$partner_balances = db_get_hash_array( "SELECT pa.partner_id, u.user_login, u.firstname, u.lastname, u.email, SUM(amount) as amount" . ' FROM ?:aff_partner_actions as pa' . ' LEFT JOIN ?:users as u ON pa.partner_id = u.user_id' . ' LEFT JOIN ?:aff_partner_profiles as pp ON pa.partner_id = pp.user_id' . ' LEFT JOIN ?:affiliate_plans as ap ON ap.plan_id = pp.plan_id AND ap.plan_id2 = pp.plan_id2' . ' AND ap.plan_id3 = pp.plan_id3' . ' WHERE pa.approved = ?s AND payout_id = 0 ?p ?p' . ' ORDER BY ?p ?p', 'partner_id', 'Y', $condition, $group, $sorting, $limit );
$joins = array(); // Каждая составная часть запроса обёрнута в вызов db_quote(), вне зависимости от наличия необходимости в плейсхолдерах $joins[] = db_quote(' LEFT JOIN foo AS f ON f.product_id = products.product_id'); $joins[] = db_quote(' LEFT JOIN bar AS b ON b.product_id = products.product_id AND b.order_id = ?n', $order_id); $query = db_quote( 'SELECT * FROM products' . ' WHERE products.status = ?s' . ' ?p', // Список joins внедрён в запрос с помощью плейсхолдера "?p" 'A', implode(' ', $joins) );
Совместимость с PostgreSQL¶
В CS-Cart 5 добавится поддержка PostgreSQL в дополнение к MySQL. В связи с этим, структура запросов должна соответствовать общему стандарту SQL.
Нельзя использовать проприетарную функциональность PostgreSQL или MySQL.
- Не используйте бэктики ( ` ). Названия полей обрамляются двойными кавычками. Кавычки можно опускать; они нужны для названий, содержащих ключевые слова SQL языка.
SELECT "from" FROM table WHERE field = 'test';
SELECT field FROM table WHERE 1=1 AND field2 = 3;
SELECT CASE WHEN(a=b) THEN 'true' ELSE 'false' END FROM table;
SELECT col AS col_alias FROM table AS t_alias
SELECT a as b FROM table HAVING a > 10
Общие правила¶
- Настоятельно не рекомендуем использовать “приглушение” PHP-ошибок с помощью оператора @ .
- Нельзя допускать появления любых ошибок, выдаваемых PHP-интерпретатором — Warnings, Notices и т. п. Случаи с несуществующими переменными, неправильными типами данных и т.п. должны обрабатываться в коде.
- Запрещено использовать функции current() и each() , если вы достоверно не знаете, где именно находится внутренний указатель в массиве. Если вам нужно получить первый элемент в массиве — используйте функцию reset() .
- Запрещено использовать HTTP_REFERER . Если вам нужно сделать редирект туда, откуда пришли — передавайте redirect_url .
Использование исключений¶
Чтобы систематизировать отлавливание фатальных ошибок программы (когда дальнейшее выполнение невозможно), в CS-Cart введены исключения (exceptions).
Когда нужно вызывать исключение¶
Когда что-то пошло не так, например: не найден нужный класс; вызван хук, который не объявлен и т.п. — всё, что не дает программе выполняться дальше.
Как вызывать исключение¶
use Tygh\Exceptions\DeveloperException; . throw new DeveloperException('Registry: object not found')
Название класса — это тип ошибки. Первый параметр — это сообщение, которое мы хотим отобразить:
new ClassNotFoundException() // попытка вызвать неизвестный класс new ExternalException() // ошибка, возвращаемая внешним сервисом new DatabaseException() // ошибка при работе с базой данных new DeveloperException() // ошибка разработчика - вызывается то, что не должно вызываться new InputException() // неправильные входные данные new InitException() // ошибка инициализации магазина new PermissionsException() // недостаточно прав для операции
Отладочная информация¶
Если у нас включен дебагер , выставлена константа DEVELOPMENT или мы в консольном режиме — на экран выведется отладочная информация.
В остальных случаях отобразится страница store_closed.html и будет выдана ошибка 503 (если возможно). Отладочная информация появится в коде этой страницы, в самом низу внутри HTML-комментария. Это сделано, чтобы не показывать посетителям магазина техническую информацию прямо на странице.
PHPUnit¶
Данная инструкция актуальна только при наличии доступа к репозиторию CS-Cart.
Установка¶
Устанавливаем PHPUnit c зависимостями:
cd /path/to/cart/app/lib composer install --dev
Запуск¶
Запускаем новые тесты:
/path/to/cart/app/lib/vendor/bin/phpunit -c /path/to/cart/_tools/unit_tests/phpunit.xml
/path/to/cart/_tools/restore.php u /path/to/cart/app/lib/vendor/bin/phpunit -c /path/to/cart/_tools/build/phpunit.xml
Не запускайте legacy-тесты в живом магазине! Они меняют базу данных.
Содержание
- Установка и переезд
- Администрирование
- Разработка
- Начало разработки
- Быстрый старт
- Платформа
- Структура платформы
- Стандарты разработки
- HTML, CSS, JavaScript, Smarty
- PHP
- JQuery
- Хуки
- Дизайн
- Обновление CS-Cart
- Часто задаваемые вопросы
- История изменений
Сейчас
- PHP
- Стандарты
- Общий стиль
- Именование переменных, ключей в массивах и свойств классов
- Именование и объявление констант
- Строковые литералы
- Магические значения прямо посреди кода
- Комментарии
- PHPDoc
- Быстродействие
- Неприятные запахи кода
- Отступы и вложенность
- Работа с типами данных
- Значение по умолчанию
- Инверсированные условия
- Пример исправления кода
- Именование
- Аргументы
- DRY - Don’t repeat yourself
- Возвращать значение - это хорошо
- Точка выхода
- Комментарии для удаленных функций
- Комментарии для часто встречающихся параметров
- Именование сущностей
- Константы
- Свойства
- Методы
- Пространства имён
- Шаблоны проектирования
- Совместимость с PostgreSQL
- Когда нужно вызывать исключение
- Как вызывать исключение
- Отладочная информация
- Установка
- Запуск
25 правил разработки программных интерфейсов [издание третье, дополненное и расширенное]
Это — очередная (уже третья) ревизия главы 11 мой книги о разработке API. Если вы найдёте этот текст полезным, я буду очень благодарен за рейтинг на Амазоне.
Важное уточнение под номером ноль:
0. Правила не должны применяться бездумно
Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно.
Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам необходимо, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.
Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API).
Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов set_entity / get_entity в пользу одного метода entity с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.
Обеспечение читабельности и консистентности
Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.
1. Явное лучше неявного
Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.
Плохо:
// Отменяет заказ GET /orders/cancellation
Неочевидно, что достаточно просто обращения к сущности cancellation (что это?), тем более немодифицирующим методом GET , чтобы отменить заказ.
Хорошо:
// Отменяет заказ POST /orders/cancel
Плохо:
// Возвращает агрегированную // статистику заказов за всё время GET /orders/statistics
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
Хорошо:
// Возвращает агрегированную // статистику заказов за указанный период POST /v1/orders/statistics/aggregate
Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
Два важных следствия:
1.1. Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за GET .
1.2. Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, либо должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
2. Указывайте использованные стандарты
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому всегда указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.
Плохо: "date": "11/12/2020" — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц.
Хорошо: "iso_date": "2020-11-12" .
Плохо: "duration": 5000 — пять тысяч чего?
Хорошо:
"duration_ms": 5000
либо
"duration": "5000ms"
либо"duration":
Отдельное следствие из этого правила — денежные величины всегда должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
3. Сущности должны именоваться конкретно
Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.
Плохо: user.get() — неочевидно, что конкретно будет возвращено.
Хорошо: user.get_id() .
4. Не экономьте буквы
В XXI веке давно уже нет нужды называть переменные покороче.
Плохо: order.time() — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
Хорошо:
order .get_estimated_delivery_time()
Плохо:
// возвращает положение // первого вхождения в строку str1 // любого символа из строки str2 strpbrk (str1, str2)
Возможно, автору этого API казалось, что аббревиатура pbrk что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк str1 , str2 является набором символов для поиска.
Хорошо:
str_search_for_characters( str, lookup_character_set )
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение string до str выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
NB: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.
5. Тип поля должен быть ясен из его названия
Если поле называется recipe — мы ожидаем, что его значением является сущность типа Recipe . Если поле называется recipe_id — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности Recipe .
То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — objects , children ; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений.
Плохо: GET /news — неясно, будет ли получена какая-то конкретная новость или массив новостей.
Хорошо: GET /news-list .
Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, is_ready , open_now .
Плохо: "task.status": true — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым.
Хорошо: "task.is_finished": true .
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа Date , и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса _at ( created_at , occurred_at и т.д.) или _date .
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
Плохо:
// Возвращает список // встроенных функций кофемашины GET /coffee-machines//functions
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
Хорошо:
GET /v1/coffee-machines/⮠ /builtin-functions-list
6. Подобные сущности должны называться подобно и вести себя подобным образом
Плохо: begin_transition / stop_transition
— begin и stop — непарные термины; разработчик будет вынужден рыться в документации.Хорошо: begin_transition / end_transition либо start_transition / stop_transition .
Плохо:
// Находит первую позицию строки `needle` // внутри строки `haystack` strpos(haystack, needle)
// Находит и заменяет // все вхождения строки `needle` // внутри строки `haystack` // на строку `replace` str_replace(needle, replace, haystack)
Здесь нарушены сразу несколько правил:
- написание неконсистентно в части знака подчёркивания;
- близкие по смыслу методы имеют разный порядок аргументов needle / haystack ;
- первый из методов находит только первое вхождение строки needle , а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
7. Избегайте двойных отрицаний
Плохо: "dont_call_me": false
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.Лучше: "prohibit_calling": true или "avoid_calling": true
— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».Стоит также отметить, что в использовании законов де Моргана ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
GET /coffee-machines//stocks →
Условие «кофе можно приготовить» будет выглядеть как has_beans && has_cup — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
— то разработчику потребуется вычислить флаг !beans_absence && !cup_absence , что эквивалентно !(beans_absence || cup_absence) , а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».
8. Избегайте неявного приведения типов
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
POST /v1/orders < … >→
Новая опция contactless_delivery является необязательной, однако её значение по умолчанию — true . Возникает вопрос, каким образом разработчик должен отличить явное нежелание пользоваться опцией ( false ) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
if (Type( order.contactless_delivery ) == 'Boolean' && order.contactless_delivery == false)
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа null или -1 .
NB. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция NULL , и значения полей по умолчанию, и поддержка операций вида UPDATE … SET field = DEFAULT (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил UPDATE … DEFAULT ), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
Хорошо:
POST /v1/orders <> →
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
Плохо:
// Создаёт пользователя POST /v1/users < … >→ // Пользователи создаются по умолчанию // с указанием лимита трат в месяц < "spending_monthly_limit_usd": "100", … >// Для отмены лимита требуется // указать значение null PUT /v1/users/
Хорошо:
POST /v1/users < // true — у пользователя снят // лимит трат в месяц // false — лимит не снят // (значение по умолчанию) "abolish_spending_limit": false, // Необязательное поле, имеет смысл // только если предыдущий флаг // имеет значение false "spending_monthly_limit_usd": "100", … >
NB: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в abolish_spending_limit . Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.
9. Отсутствие результата — тоже результат
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
Плохо:
POST /v1/coffee-machines/search < "query": "lungo", "location": > → 404 Not Found
Статусы 4xx означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
Хорошо:
POST /v1/coffee-machines/search < "query": "lungo", "location": > → 200 OK
Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)
10. Ошибки должны быть информативными
При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.
Плохо:
POST /v1/coffee-machines/search < "recipes": ["lngo"], "position": < "latitude": 110, "longitude": 55 >> → 400 Bad Request <>
— да, конечно, допущенные ошибки (опечатка в "lngo" и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
Хорошо:
< "reason": "wrong_parameter_value", "localized_message": "Что-то пошло не так.⮠ Обратитесь к разработчику приложения." "details": < "checks_failed": [ < "field": "recipe", "error_type": "wrong_value", "message": "Value 'lngo' unknown.⮠ Did you mean 'lungo'?" >, < "field": "position.latitude", "error_type": "constraint_violation", "constraints": < "min": -90, "max": 90 >, "message": "'position.latitude' value⮠ must fall within⮠ the [-90, 90] interval" > ] > >
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
11. Соблюдайте правильный порядок ошибок
Во-первых, всегда показывайте неразрешимые ошибки прежде разрешимых:
POST /v1/orders < "recipe": "lngo", "offer" >→ 409 Conflict < "reason": "offer_expired" >// Повторный запрос // с новым `offer` POST /v1/orders < "recipe": "lngo", "offer" >→ 400 Bad Request
— какой был смысл получать новый offer , если заказ всё равно не может быть создан?
Во-вторых, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений.
Плохо:
POST /v1/orders < "items": [< "item_id": "123", "price": "0.10" >] > → 409 Conflict < "reason": "price_changed", "details": [< "item_id": "123", "actual_price": "0.20" >] > // Повторный запрос // с актуальной ценой POST /v1/orders < "items": [< "item_id": "123", "price": "0.20" >] > → 409 Conflict
— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
В-третьих, постройте схему: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок.
// Создаём заказ с платной доставкой POST /v1/orders < "items": 3, "item_price": "3000.00" "currency_code": "MNT", "delivery_fee": "1000.00", "total": "10000.00" >→ 409 Conflict // Ошибка: доставка становится бесплатной // при стоимости заказа от 9000 тугриков < "reason": "delivery_is_free" >// Создаём заказ с бесплатной доставкой POST /v1/orders < "items": 3, "item_price": "3000.00" "currency_code": "MNT", "delivery_fee": "0.00", "total": "9000.00" >→ 409 Conflict // Ошибка: минимальная сумма заказа // 10000 тугриков
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
Правила разработки машиночитаемых интерфейсов
В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.
12. Состояние системы должно быть понятно клиенту
Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус.
Плохо:
// Создаёт заказ и возвращает его id POST /v1/orders < … >→
// Возвращает заказ по его id GET /v1/orders/ // Заказ ещё не подтверждён // и ожидает проверки → 404 Not Found
— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние GET /v1/orders/ . Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами:
- клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения;
- потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту.
В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами.
Хорошо:
// Создаёт заказ и возвращает его POST /v1/orders < > → < "order_id", // Заказ создаётся в явном статусе // «идёт проверка» "status": "checking", … >
// Возвращает заказ по его id GET /v1/orders/ →
// Возвращает все заказы пользователя // во всех статусах GET /v1/users//orders
Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема.
Плохо: < "error": "email malformed" >— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю
Хорошо:
13. Указывайте время жизни ресурсов и политики кэширования
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
Плохо:
// Возвращает цену лунго в кафе, // ближайшем к указанной точке GET /v1/price?recipe=lungo⮠ &longitude=⮠ &latitude= →
Возникает два вопроса:
- в течение какого времени эта цена действительна?
- на каком расстоянии от указанной точки цена всё ещё действительна?
Хорошо: Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком Cache-Control . В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
// Возвращает предложение: за какую сумму // наш сервис готов приготовить лунго GET /v1/price?recipe=lungo⮠ &longitude=⮠ &latitude= → < "offer": < "id", "currency_code", "price", "conditions": < // До какого времени // валидно предложение "valid_until", // Где валидно предложение: // * город // * географический объект // * … "valid_within" >> >
14. Пагинация, фильтрация и курсоры
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.
Плохо:
// Возвращает указанный limit записей, // отсортированных по дате создания // начиная с записи с номером offset GET /v1/records?limit=10&offset=100
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.
- Каким образом клиент узнает о появлении новых записей в начале списка? Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit ? Представим себе ситуацию:
- клиент обрабатывает записи в порядке поступления;
- произошла какая-то проблема, и накопилось большое количество необработанных записей;
- клиент запрашивает новые записи ( offset=0 ), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit ;
- клиент вынужден продолжить перебирать записи (увеличивая offset ) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает;
- таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
- Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена? Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
- Какие параметры кэширования мы можем выставить на этот эндпойнт? Никакие: повторяя запрос с теми же limit - offset , мы каждый раз получаем новый набор записей.
Хорошо: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так:
// Возвращает указанный limit записей, // отсортированных по дате создания, // начиная с первой записи, // созданной позднее, // чем запись с указанным id GET /v1/records⮠ ?older_than=&limit=10 // Возвращает указанный limit записей, // отсортированных по дате создания, // начиная с первой записи, // созданной раньше, // чем запись с указанным id GET /v1/records⮠ ?newer_than=&limit=10
При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор. Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.
Другой вариант организации таких списков — возврат курсора cursor , который используется вместо record_id , что делает интерфейсы более универсальными.
// Первый запрос данных POST /v1/records/list < // Какие-то дополнительные // параметры фильтрации "filter": < "category": "some_category", "created_date": < "older_than": "2020-12-07" >> > →
// Последующие запросы GET /v1/records?cursor=
Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. filter в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.
Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один.
NB: в некоторых источниках такой подход, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
- подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
- если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
- подход с курсором не означает, что limit / offset использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида GET /items?cursor=… , и на запросы вида GET /items?offset=…&limit=… ;
- наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
Плохо:
// Возвращает указанный limit записей, // отсортированных по полю sort_by // в порядке sort_order, // начиная с записи с номером offset GET /records?sort_by=date_modified⮠ &sort_order=desc&limit=10&offset=100
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.
Хорошо: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
Вариант 1: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
// Создаёт представление по указанным параметрам POST /v1/record-views < sort_by: [< "field": "date_modified", "order": "desc" >] > →
// Позволяет получить часть представления GET /v1/record-views/⮠ ?cursor=
Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
Вариант 2: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
POST /v1/records/modified/list < // Опционально "cursor" >→ < "modified": [ < "date", "record_id" >], "cursor" >
Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.
Техническое качество API
Хороший API должен не просто решать проблемы разработчиков и пользователей, но и делать это максимально качественно, т.е. не содержать в себе логических и технических ошибок (и не провоцировать на них разработчика), экономить вычислительные ресурсы и вообще имплементировать лучшие практики в своей предметной области.
15. Сохраняйте точность дробных чисел
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат 00:20 формату 0.333333… ), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.
16. Все операции должны быть идемпотентны
Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
Плохо:
// Создаёт заказ POST /orders
Повтор запроса создаст два заказа!
Хорошо:
// Создаёт заказ POST /v1/orders X-Idempotency-Token:
Клиент на своей стороне запоминает X-Idempotency-Token , и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
Альтернатива:
// Создаёт черновик заказа POST /v1/orders/drafts →
// Подтверждает черновик заказа PUT /v1/orders/drafts/
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности. Операция подтверждения заказа — уже естественным образом идемпотентна, для неё draft_id играет роль ключа идемпотентности.
Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так изначально идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации:
- клиент не получил ответ из-за сетевых проблем и пытается повторить запрос;
- клиент ошибся, пытаясь применить конфликтующие изменения.
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
POST /resource/updates
Сервер извлекает актуальный номер ревизии и обнаруживает, что он равен 124. Как ответить правильно? Можно просто вернуть 409 Conflict , но тогда клиент будет вынужден попытаться выяснить причину конфликта и как-то решить его, потенциально запутав пользователя. К тому же, фрагментировать алгоритмы разрешения конфликтов, разрешая каждому клиенту реализовать какой-то свой — плохая идея.
Сервер мог бы попытаться сравнить значения поля updates , предполагая, что одинаковые значения означают перезапрос, но это предположение будет опасно неверным (например, если ресурс представляет собой счётчик, то последовательные запросы с идентичным телом нормальны).
Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему
POST /resource/updates X-Idempotency-Token: < "resource_revision": 123 "updates" >→ 201 Created
— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.
POST /resource/updates X-Idempotency-Token: < "resource_revision": 123 "updates" >→ 409 Conflict
— сервер обнаружил, что ревизия 123 была создана с другим токеном, значит имеет место быть конфликт общего доступа к ресурсу.
Более того, добавление токена идемпотентности не только решает эту проблему, но и позволяет в будущем сделать продвинутые оптимизации. Если сервер обнаруживает конфликт общего доступа, он может попытаться решить его, «перебазировав» обновление, как это делают современные системы контроля версий, и вернуть 200 OK вместо 409 Conflict . Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима и предотвращает фрагментацию кода разрешения конфликтов.
Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно:
- нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
- клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
17. Избегайте неатомарных операций
С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
Плохо:
// Возвращает список рецептов GET /v1/recipes → < "recipes": [< "id": "lungo", "volume": "200ml" >, < "id": "latte", "volume": "300ml" >] > // Изменяет параметры PATCH /v1/recipes < "changes": [< "id": "lungo", "volume": "300ml" >, < "id": "latte", "volume": "-1ml" >] > → 400 Bad Request // Перечитываем список GET /v1/recipes → < "recipes": [< "id": "lungo", // Это значение изменилось "volume": "300ml" >, < "id": "latte", // А это нет "volume": "300ml" >] >
— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
Лучше:
PATCH /v1/recipes < "changes": [< "recipe_id": "lungo", "volume": "300ml" >, < "recipe_id": "latte", "volume": "-1ml" >] > // Можно воспользоваться статусом // «частичного успеха», // если он предусмотрен протоколом → 200 OK < "changes": [< "change_id", "occurred_at", "recipe_id": "lungo", "status": "success" >, < "change_id", "occurred_at", "recipe_id": "latte", "status": "fail", "error" >] >
- change_id — уникальный идентификатор каждого атомарного изменения;
- occurred_at — время проведения каждого изменения;
- error — информация по ошибке для каждого изменения, если она возникла.
Не лишним будет также:
- ввести в запросе sequence_id , чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом;
- предоставить отдельный эндпойнт /changes-history , чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
PATCH /v1/recipes < "idempotency_token", "changes": [< "recipe_id": "lungo", "volume": "300ml" >, < "recipe_id": "latte", "volume": "400ml" >] > → 200 OK < "changes": [< … "status": "success" >, < … "status": "fail", "error": < "reason": "too_many_requests" >>] >
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
PATCH /v1/recipes < "idempotency_token", "changes": [< "recipe_id": "lungo", "volume": "300ml" >, < "recipe_id": "latte", "volume": "400ml" >] > → 200 OK < "changes": [< … "status": "success" >, < … "status": "success", >] >
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.
18. Не изобретайте безопасность
Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.
Во-первых, почти всегда процедуры, обеспечивающие безопасность той или иной операции, уже разработаны. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки Man-in-the-Middle, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.
Во-вторых, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен атаке по времени, а веб-сервер — атаке с разделением запросов.
19. Декларируйте технические ограничения явно
У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
20. Считайте трафик
В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.
Три основные причины раздувания объёма трафика достаточно очевидны:
- не предусмотрен постраничный перебор данных;
- не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.);
- клиент слишком часто запрашивает данные и/или слишком мало их кэширует.
Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:
- не злоупотребляйте асинхронными интерфейсами;
- с одной стороны, они позволяют нивелировать многие технические проблемы с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;
- с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;
- да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
21. Избегайте неявных частичных обновлений
Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.
Плохо:
// Создаёт заказ из двух напитков POST /v1/orders/ < "delivery_address", "items": [< "recipe": "lungo", >, < "recipe": "latte", "milk_type": "oats" >] > →
// Частично перезаписывает заказ // обновляет объём второго напитка PATCH /v1/orders/ < "items": [null, < "volume": "800ml" >] > → < /* изменения приняты */ >
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления ( delivery_address , milk_type ) — они будут сброшены в значения по умолчанию или останутся неизменными?
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция <"items":[null, <…>]> означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
Простое решение состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
- повышенные размеры запросов и, как следствие, расход трафика;
- необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
- невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
Все эти соображения, однако, на поверку оказываются мнимыми:
- причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
- концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
- это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
- существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
- кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
Лучше: разделить эндпойнт. Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем разделе.
// Создаёт заказ из двух напитков POST /v1/orders/ < "parameters": < "delivery_address" >"items": [< "recipe": "lungo", >, < "recipe": "latte", "milk_type": "oats" >] > → < "order_id", "created_at", "parameters": < "delivery_address" >"items": [ < "item_id", "status">, < "item_id", "status">] >
// Изменяет параметры, // относящиеся ко всему заказу PUT /v1/orders//parameters < "delivery_address" >→
// Частично перезаписывает заказ // обновляет объём одного напитка PUT /v1/orders//items/ < // Все поля передаются, даже если // изменилось только какое-то одно "recipe", "volume", "milk_type" >→
// Удаляет один из напитков в заказе DELETE /v1/orders//items/
Теперь для удаления volume достаточно не передавать его в PUT items/ . Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля ( created_at и status ) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить created_at ?).
Также в ответах операций PUT можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования).
NB: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
Ещё лучше: разработать формат описания атомарных изменений.
POST /v1/order/changes X-Idempotency-Token: < "changes": [< "type": "set", "field": "delivery_address", "value": >, < "type": "unset_item_field", "item_id", "field": "volume" >], … >
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения.
Продуктовое качество API
Помимо технологических ограничений, любой реальный API скоро столкнётся и с несовершенством окружающей действительности. Конечно, мы все хотели бы жить в мире розовых единорогов, свободном от накопления legacy, злоумышленников, национальных конфликтов и происков конкурентов. Но, к сожалению или к счастью, живём мы в реальном мире, в котором хороший API должен учитывать всё вышеперечисленное.
22. Используйте глобально уникальные идентификаторы
Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например UUID-4). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.
Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. urn:order: (или просто order: ), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.
Отдельное важное следствие: не используйте инкрементальные номера как идентификаторы. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
NB: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
23. Предусмотрите ограничения доступа
С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.
Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку 429 Too Many Requests или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
24. Не предоставляйте endpoint-ов массового получения чувствительных данных
Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.
Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.
25. Локализация и интернационализация
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка Accept-Language ), даже если на текущем этапе нужды в локализации нет.
Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.
Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
Важно: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение localized_message адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение details.checks_failed[].message написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс localized_ .
И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.
- Стандарты