Статьи

Создание REST API с нуля — реализация

Мы завершили первую часть этого руководства со всеми основными уровнями нашего API. У нас есть настройка сервера, система аутентификации, ввод / вывод JSON, управление ошибками и несколько фиктивных маршрутов. Но, самое главное, мы написали файл README который определяет ресурсы и действия. Теперь пришло время разобраться с этими ресурсами.

Создание и обновление контактов

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

 $app->post( '/contacts', function () use ($app, $log) { $body = $app->request()->getBody(); $errors = $app->validateContact($body); if (empty($errors)) { $contact = \ORM::for_table('contacts')->create(); if (isset($body['notes'])) { $notes = $body['notes']; unset($body['notes']); } $contact->set($body); if (true === $contact->save()) { // Insert notes if (!empty($notes)) { $contactNotes = array(); foreach ($notes as $item) { $item['contact_id'] = $contact->id; $note = \ORM::for_table('notes')->create(); $note->set($item); if (true === $note->save()) { $contactNotes[] = $note->asArray(); } } } $output = $contact->asArray(); if (!empty($contactNotes)) { $output['notes'] = $contactNotes; } echo json_encode($output, JSON_PRETTY_PRINT); } else { throw new Exception("Unable to save contact"); } } else { throw new ValidationException("Invalid data", 0, $errors); } } ); с $app->post( '/contacts', function () use ($app, $log) { $body = $app->request()->getBody(); $errors = $app->validateContact($body); if (empty($errors)) { $contact = \ORM::for_table('contacts')->create(); if (isset($body['notes'])) { $notes = $body['notes']; unset($body['notes']); } $contact->set($body); if (true === $contact->save()) { // Insert notes if (!empty($notes)) { $contactNotes = array(); foreach ($notes as $item) { $item['contact_id'] = $contact->id; $note = \ORM::for_table('notes')->create(); $note->set($item); if (true === $note->save()) { $contactNotes[] = $note->asArray(); } } } $output = $contact->asArray(); if (!empty($contactNotes)) { $output['notes'] = $contactNotes; } echo json_encode($output, JSON_PRETTY_PRINT); } else { throw new Exception("Unable to save contact"); } } else { throw new ValidationException("Invalid data", 0, $errors); } } ); 

Здесь мы находимся в групповом маршруте /api/v1 , имея дело с ресурсом /contacts методом POST . Сначала нам нужно тело запроса. Наше промежуточное программное обеспечение гарантирует, что это допустимый JSON, иначе мы не были бы в этом месте в коде. Метод $app->validateContact() гарантирует, что предоставленные данные очищаются и выполняет базовую проверку; он гарантирует, что у нас есть хотя бы имя и уникальный действующий адрес электронной почты. Мы можем разумно думать, что полезная нагрузка JSON может содержать как данные о контактах, так и данные заметок, поэтому я обрабатываю оба. Я создаю новый контакт с моим конкретным кодом ORM, и в случае успеха я вставляю связанные примечания, если они есть. ORM предоставляет мне объекты как для контактов, так и для заметок, содержащих идентификатор из базы данных, поэтому, наконец, я создаю один массив для кодирования в JSON. Опция JSON_PRETTY_PRINT доступна из версии 5.4 PHP, для более старой версии вы можете попросить Google о замене.

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

 $contact = \ORM::forTable('contacts')->findOne($id); if ($contact) { $body = $app->request()->getBody(); $errors = $app->validateContact($body, 'update'); // other stuff here... } 

Мы можем оптимизировать дальнейшее, сопоставляя один и тот же код более чем одному методу, например, я сопоставляю методы PUT и PATCH с одним и тем же кодом:

 $app->map( '/contacts/:id', function ($id) use ($app, $log) { // Update code here... )->via('PUT', 'PATCH'); 

Список контактов

Теперь, когда у нас есть некоторые контакты в нашей базе данных, пришло время составлять список и фильтровать. Давайте начнем с простого:

 // Get contacts $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $results = \ORM::forTable('contacts'); $contacts = $results->findArray(); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); 

Оператор, который извлекает данные, зависит от вашего ORM. Idiorm упрощает и возвращает ассоциативный массив или пустой массив, который кодируется в JSON и отображается. В случае ошибки или исключения промежуточное программное обеспечение JSON, которое мы написали ранее, перехватывает исключение и преобразует его в JSON. Но давайте усложним это немного …

Поля, фильтры, сортировка и поиск

Хороший API должен позволять нам ограничивать полученные поля, сортировать результаты и использовать базовые фильтры или поисковые запросы. Например, URL:

 /api/v1/contacts?fields=firstname,email&sort=-email&firstname=Viola&q=vitae 

Если должны быть возвращены все контакты с именем «Viola», где имя OR адрес электронной почты содержит строку vitae , они должны быть упорядочены по убыванию в алфавитном порядке адреса электронной почты ( -email ), и мне нужны только поля имени и email . как нам это сделать?

 $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); с $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); конкретные $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); конкретные $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); конкретные $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); конкретные $app->get( '/contacts', function () use ($app, $log) { $contacts = array(); $filters = array(); $total = 0; // Default resultset $results = \ORM::forTable('contacts'); // Get and sanitize filters from the URL if ($rawfilters = $app->request->get()) { unset( $rawfilters['sort'], $rawfilters['fields'], $rawfilters['page'], $rawfilters['per_page'] ); foreach ($rawfilters as $key => $value) { $filters[$key] = filter_var($value, FILTER_SANITIZE_STRING); } } // Add filters to the query if (!empty($filters)) { foreach ($filters as $key => $value) { if ('q' == $key) { $results->whereRaw( '(`firstname` LIKE ? OR `email` LIKE ?)', array('%'.$value.'%', '%'.$value.'%') ); } else { $results->where($key,$value); } } } // Get and sanitize field list from the URL if ($fields = $app->request->get('fields')) { $fields = explode(',', $fields); $fields = array_map( function($field) { $field = filter_var($field, FILTER_SANITIZE_STRING); return trim($field); }, $fields ); } // Add field list to the query if (is_array($fields) && !empty($fields)) { $results->selectMany($fields); } // Manage sort options if ($sort = $app->request->get('sort')) { $sort = explode(',', $sort); $sort = array_map( function($s) { $s = filter_var($s, FILTER_SANITIZE_STRING); return trim($s); }, $sort ); foreach ($sort as $expr) { if ('-' == substr($expr, 0, 1)) { $results->orderByDesc(substr($expr, 1)); } else { $results->orderByAsc($expr); } } } // Pagination logic $page = filter_var( $app->request->get('page'), FILTER_SANITIZE_NUMBER_INT ); if (!empty($page)) { $perPage = filter_var( $app->request->get('per_page'), FILTER_SANITIZE_NUMBER_INT ); if (empty($perPage)) { $perPage = 10; } // Total after filters and // before pagination limit $total = $results->count(); // Pagination "Link" headers go here... $results->limit($perPage)->offset($page * $perPage - $perPage); } $contacts = $results->findArray(); // ORM fix needed if (empty($total)) { $total = count($contacts); } $app->response->headers->set('X-Total-Count', $total); echo json_encode($contacts, JSON_PRETTY_PRINT); } ); 

Сначала я определяю набор результатов по умолчанию (все контакты), затем извлекаю полные параметры строки запроса в массив $rawfilters , сбрасывая fields ключей, sort , page и per_page , с ними я разберусь позже. Я очищаю ключи и значения, чтобы получить окончательный массив $filters . Затем фильтры применяются к запросу с использованием специального синтаксиса ORM. Я делаю то же самое для списка полей и параметров сортировки, добавляя части к нашему запросу к набору результатов. Только тогда я могу выполнить запрос с помощью findArray() и вернуть результаты.

Логика и заголовки нумерации страниц

Это хорошая идея, чтобы обеспечить способ ограничения возвращаемых данных. В приведенном выше коде я предоставляю параметры page и per_page . После проверки они могут быть переданы в ORM для фильтрации результатов:

 $results->limit($perPage)->offset(($page * $perPage) - $perPage); 

Перед этим я получаю счетчик итоговых результатов, чтобы установить HTTP-заголовок X-Total-Count . Теперь я могу вычислить заголовок ссылки, чтобы опубликовать URL-адреса нумерации страниц следующим образом:

 Link: <https://mycontacts.dev/api/v1/contacts?page=2&per_page=5>; rel="next",<https://mycontacts.dev/api/v1/contacts?page=20&per_page=5>; rel="last" 

URL-адреса нумерации страниц рассчитываются с использованием фактических очищенных параметров:

 $linkBaseURL = $app->request->getUrl() . $app->request->getRootUri() . $app->request->getResourceUri(); // Adding fields if (!empty($fields)) { $queryString[] = 'fields=' . join( ',', array_map( function($f){ return urlencode($f); }, $fields ) ); } // Adding filters if (!empty($filters)) { $queryString[] = http_build_query($filters); } // Adding sort options if (!empty($sort)) { $queryString[] = 'sort=' . join( ',', array_map( function($s){ return urlencode($s); }, $sort ) ); } if ($page < $pages) { $next = $linkBaseURL . '?' . join( '&', array_merge( $queryString, array( 'page=' . (string) ($page + 1), 'per_page=' . $perPage ) ) ); $links[] = sprintf('<%s>; rel="next"', $next); } 

Сначала я вычисляю текущий базовый URL для ресурса, затем добавляю поля, фильтры и параметры сортировки в строку запроса. В конце я создаю полные URL, соединяя параметры разбивки на страницы.

Контактная информация и автозагрузка

На данный момент получение сведений об одном контакте действительно легко:

 $app->get( '/contacts/:id', function ($id) use ($app, $log) { // Validate input code here... $contact = \ORM::forTable('contacts')->findOne($id); if ($contact) { echo json_encode($contact->asArray(), JSON_PRETTY_PRINT); return; } $app->notFound(); } ); 

Мы пробуем простой запрос ORM и кодируем результат, если таковой имеется, или ошибку 404. Но мы могли бы пойти дальше. Для создания контакта достаточно разумно, что нам могут понадобиться контакт и заметки, поэтому вместо нескольких вызовов мы можем активировать эту опцию, используя параметры строки запроса, например:

 https://mycontacts.dev/api/v1/contacts/1?embed=notes 

Мы можем отредактировать код для:

 // ... if ($contact) { $output = $contact->asArray(); if ('notes' === $app->request->get('embed')) { $notes = \ORM::forTable('notes') ->where('contact_id', $id) ->orderByDesc('id') ->findArray(); if (!empty($notes)) { $output['notes'] = $notes; } } echo json_encode($output, JSON_PRETTY_PRINT); return; } // ... 

Если у нас есть действующий контакт и параметр embed который запрашивает заметки, мы запускаем другой запрос, ища связанные заметки, в обратном порядке по идентификатору (или дате, или как мы хотим). С полнофункциональной структурой ORM / Model мы могли бы и должны сделать один запрос к нашей базе данных, чтобы повысить производительность.

Кэширование

Кэширование важно для производительности нашего приложения. Хороший API должен по крайней мере разрешить кэширование на стороне клиента с использованием инфраструктуры кэширования протокола HTTP. В этом примере я буду использовать ETag и в дополнение к этому мы добавим простой слой внутреннего кэша с использованием APC. Все эти функции работают на промежуточном программном обеспечении. Год назад Тим писал о Slim Middleware здесь, на Sitepoint, в качестве примера кодируя Middleware. Я расширил его код для нашего объекта API\Middleware\Cache . Промежуточное программное обеспечение добавляется стандартным способом на этапе начальной загрузки наших приложений:

 $app->add(new API\Middleware\Cache('/api/v1')); 

Конструктор Cache принимает корневой URI в качестве параметра, поэтому мы можем активировать кеш из /api/v1 и его подпутей в методе main.

 public function __construct($root = '') { $this->root = $root; $this->ttl = 300; // 5 minutes } 

Мы также установили TTL по умолчанию на 5 минут, который может быть переопределен позже с помощью служебного метода $app->config() .

 // Cache middleware public function call() { $key = $this->app->request->getResourceUri(); $response = $this->app->response; if ($ttl = $this->app->config('cache.ttl')) { $this->ttl = $ttl; } if (preg_match('|^' . $this->root . '.*|', $key)) { // Process cache here... } // Pass the game... $this->next->call(); } 

Начальный ключ кеша — это URI ресурса. Если он не совпадает с нашим корнем, мы передаем действие следующему промежуточному программному обеспечению. Следующим перекрестком является метод HTTP: мы хотим очистить кэш при помощи методов обновления (PUT, POST и PATCH) и прочитать из него запросы GET:

 $method = strtolower($this->app->request->getMethod()); if ('get' === $method) { // Process cache here... } else { if ($response->status() == 200) { $response->headers->set( 'X-Cache', 'NONE' ); $this->clean($key); } } 

Если было выполнено успешное действие записи, мы очищаем кеш для соответствующего ключа. На самом деле метод clean() будет очищать все объекты, ключ которых начинается с $key . Если запрос GET, механизм кэша начинает работать.

 if ('get' === $method) { $queryString = http_build_query($this->app->request->get()); if (!empty($queryString)) { $key .= '?' . $queryString; } $data = $this->fetch($key); if ($data) { // Cache hit... return the cached content $response->headers->set( 'Content-Type', 'application/json' ); $response->headers->set( 'X-Cache', 'HIT' ); try { $this->app->etag($data['checksum']); $this->app->expires($data['expires']); $response->body($data['content']); } catch (\Slim\Exception\Stop $e) { } return; } // Cache miss... continue on to generate the page $this->next->call(); if ($response->status() == 200) { // Cache result for future look up $checksum = md5($response->body()); $expires = time() + $this->ttl; $this->save( $key, array( 'checksum' => $checksum, 'expires' => $expires, 'content' => $response->body(), ) ); $response->headers->set( 'X-Cache', 'MISS' ); try { $this->app->etag($checksum); $this->app->expires($expires); } catch (\Slim\Exception\Stop $e) { } return; } } else { // other methods... } 

Сначала я вычисляю полный ключ, это URI ресурса, включая строку запроса, затем я ищу его в кеше. Если имеются кэшированные данные (попадание в кэш), они имеют форму ассоциативного массива, составленного из даты истечения срока действия, контрольной суммы md5 и фактического содержимого. Первые два значения используются для заголовков Etag и Expires , содержимое заполняет тело ответа, а метод возвращает. В Slim метод $app->etag() заботится о заголовках типа If-None-Match от клиента, возвращающего код состояния 304 Not Modified .

Если кэшированные данные отсутствуют (отсутствует кеш), действие передается другому промежуточному программному обеспечению, и ответ обрабатывается в обычном режиме. Наше промежуточное программное обеспечение кеша вызывается снова перед рендерингом (как лук, помните?), На этот раз с обработанным ответом. Если окончательный ответ действителен (статус 200), он сохраняется в кэше для повторного использования, а затем отправляется клиенту.

REST Ограничение скорости

Пока не стало слишком поздно, у нас должен быть способ ограничить количество обращений клиентов к нашему API. Здесь нам на помощь приходит другое промежуточное ПО.

 $app->add(new API\Middleware\RateLimit('/api/v1')); 
 public function call() { $response = $this->app->response; $request = $this->app->request; if ($max = $this->app->config('rate.limit')) { $this->max = $max; } // Activate on given root URL only if (preg_match('|^' . $this->root . '.*|', $this->app->request->getResourceUri())) { // Use API key from the current user as ID if ($key = $this->app->user['apikey']) { $data = $this->fetch($key); if (false === $data) { // First time or previous perion expired, // initialize and save a new entry $remaining = ($this->max -1); $reset = 3600; $this->save( $key, array( 'remaining' => $remaining, 'created' => time() ), $reset ); } else { // Take the current entry and update it $remaining = (--$data['remaining'] >= 0) ? $data['remaining'] : -1; $reset = (($data['created'] + 3600) - time()); $this->save( $key, array( 'remaining' => $remaining, 'created' => $data['created'] ), $reset ); } // Set rating headers $response->headers->set( 'X-Rate-Limit-Limit', $this->max ); $response->headers->set( 'X-Rate-Limit-Reset', $reset ); $response->headers->set( 'X-Rate-Limit-Remaining', $remaining ); // Check if the current key is allowed to pass if (0 > $remaining) { // Rewrite remaining headers $response->headers->set( 'X-Rate-Limit-Remaining', 0 ); // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } else { // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } $this->next->call(); } в состоянии public function call() { $response = $this->app->response; $request = $this->app->request; if ($max = $this->app->config('rate.limit')) { $this->max = $max; } // Activate on given root URL only if (preg_match('|^' . $this->root . '.*|', $this->app->request->getResourceUri())) { // Use API key from the current user as ID if ($key = $this->app->user['apikey']) { $data = $this->fetch($key); if (false === $data) { // First time or previous perion expired, // initialize and save a new entry $remaining = ($this->max -1); $reset = 3600; $this->save( $key, array( 'remaining' => $remaining, 'created' => time() ), $reset ); } else { // Take the current entry and update it $remaining = (--$data['remaining'] >= 0) ? $data['remaining'] : -1; $reset = (($data['created'] + 3600) - time()); $this->save( $key, array( 'remaining' => $remaining, 'created' => $data['created'] ), $reset ); } // Set rating headers $response->headers->set( 'X-Rate-Limit-Limit', $this->max ); $response->headers->set( 'X-Rate-Limit-Reset', $reset ); $response->headers->set( 'X-Rate-Limit-Remaining', $remaining ); // Check if the current key is allowed to pass if (0 > $remaining) { // Rewrite remaining headers $response->headers->set( 'X-Rate-Limit-Remaining', 0 ); // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } else { // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } $this->next->call(); } в состоянии public function call() { $response = $this->app->response; $request = $this->app->request; if ($max = $this->app->config('rate.limit')) { $this->max = $max; } // Activate on given root URL only if (preg_match('|^' . $this->root . '.*|', $this->app->request->getResourceUri())) { // Use API key from the current user as ID if ($key = $this->app->user['apikey']) { $data = $this->fetch($key); if (false === $data) { // First time or previous perion expired, // initialize and save a new entry $remaining = ($this->max -1); $reset = 3600; $this->save( $key, array( 'remaining' => $remaining, 'created' => time() ), $reset ); } else { // Take the current entry and update it $remaining = (--$data['remaining'] >= 0) ? $data['remaining'] : -1; $reset = (($data['created'] + 3600) - time()); $this->save( $key, array( 'remaining' => $remaining, 'created' => $data['created'] ), $reset ); } // Set rating headers $response->headers->set( 'X-Rate-Limit-Limit', $this->max ); $response->headers->set( 'X-Rate-Limit-Reset', $reset ); $response->headers->set( 'X-Rate-Limit-Remaining', $remaining ); // Check if the current key is allowed to pass if (0 > $remaining) { // Rewrite remaining headers $response->headers->set( 'X-Rate-Limit-Remaining', 0 ); // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } else { // Exits with status "429 Too Many Requests" (see doc below) $this->fail(); } } $this->next->call(); } 

Мы допускаем rate.limit корневого пути и, подобно промежуточному программному обеспечению кеша, мы можем установить другие параметры, такие как rate.limit из конфигурации нашего приложения. Это промежуточное ПО использует контекст $app->user созданный уровнем аутентификации; ключ API пользователя используется в качестве ключа для кэша APC. Если мы не находим данные для данного ключа, мы генерируем их: я сохраняю оставшиеся вызовы, метку времени создания значения и даю ему TTL часа. Если в APC есть данные, я пересчитываю оставшиеся вызовы и сохраняю обновленные значения.

Затем я устанавливаю заголовки X-Rate-Limit–* (это соглашение, а не стандарт), и если у пользователя не осталось оставшихся вызовов, я сбрасываю X-Rate-Limit-Remaining на ноль и завершаю работу с 429 Too Many Requests Код статуса 429 Too Many Requests . Здесь есть небольшой обходной путь: я не могу использовать метод Slim $app->halt() для вывода ошибки, потому что версии Apache до 2.4 не поддерживают код состояния 429 и преобразуют его молча в ошибку 500 . Так что промежуточное ПО использует свой собственный метод fail() :

 protected function fail() { header('HTTP/1.1 429 Too Many Requests', false, 429); // Write the remaining headers foreach ($this->app->response->headers as $key => $value) { header($key . ': ' . $value); } exit; } 

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

Куда мы отправимся отсюда?

Мы рассмотрели много вещей здесь, и у нас есть наш базовый API, который учитывает общие рекомендации, но есть еще много улучшений, которые мы можем добавить. Например:

  • использовать более надежную ORM / модель для доступа к данным
  • использовать отдельную библиотеку проверки, которая внедряется в модель
  • использовать внедрение зависимостей, чтобы использовать преимущества других механизмов хранения ключей / значений вместо APC
  • построить сервис обнаружения и игровую площадку с Swagger и аналогичными инструментами
  • создать слой тестового набора с Codeception

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