Компания
345,38
рейтинг
27 августа 2015 в 18:14

Разработка → 15 тривиальных фактов о правильной работе с протоколом HTTP

Внимание! Реклама! Пост оплачен Капитаном Очевидность!

Ниже под катом вы найдёте 15 пунктов, описывающих правильную организацию ресурсов, доступных по протоколу HTTP — веб-сайтов, «ручек» бэкенда, API и прочая. «Правильный» здесь означает «соответствующий рекомендациям и спецификациям». Большая часть ниженаписанного почти дословно переведена из официальных стандартов, рекомендаций и best practices от IETF и W3C.



Вы не найдёте здесь абсолютно ничего неочевидного. Нет, серьёзно, каждый веб-разработчик теоретически эти 15 пунктов должен освоить где-то в районе junior developer-а и/или второго-третьего курса университета.

Однако на практике оказывается, что великое множество веб-разработчиков эти азы таки не усвоило. Читаешь документацию к иным API и рыдаешь. Уверен, что каждый читатель таки найдёт в этом списке что-то новое для себя.

1. URL идентифицирует ресурс — некоторую разделяемую сущность. Файл — ресурс. Ручка, которая что-то ищет — ресурс. Вызов метода — не ресурс. Если вы хотите шарахнуть из пушки по Луне, то вот так делать не надо:
GET /?method=шарахнуть&to=Луна

Заведите ресурс «шарахалка», и тогда у вас всё будет логично:
POST /шарахалка/?to=Луна


Почему POST, а не GET? Читай ниже.

2. URL состоит из схемы (протокола), хоста, пути (path), запроса (query) и фрагмента. Путь используется для организации иерархических ресурсов, запрос — для неиерархических ресурсов и для параметров операции. Фрагмент идентифицирует подчинённый ресурс, не имеющий прямого URL.

Scheme      Host                 Path               Query      Fragment
  ↓           ↓                    ↓                  ↓            ↓
http://nyashnye-kotiki.xxx/breeds/maine-coon/?deliver_to=Moscow#photo

Если на вашем сайте «Няшные котики» есть каталог по породам, то его вполне логично организовать в виде частей path, поскольку каждый котик принадлежит ровно к одной породе. А вот доставлять одного котика можно в несколько городов, поэтому фильтр «с доставкой в город N» следует организовать через query.

3. Обращение по HTTP состоит из применения метода (глагола) к URL. Результатом такого применения должно быть — сюрприз-сюрприз! — то, что в глаголе написано. То есть GET возвращает представление ресурса, DELETE удаляет и т.п.

4. Методы GET, HEAD, OPTIONS — безопасные. Предполагается, что вызов этих методов состояния ресурса не изменяет. Поэтому многие сетевые агенты — такие, например, как префетчер ссылок в браузере или мессенджере — считают себя вправе по таким ссылкам ходить без явного волеизъявления пользователя. ИЧСХ, никаких стандартов не нарушают.

5. По умолчанию методы GET и HEAD кэшируются, OPTIONS, POST, PUT, PATCH, DELETE — нет. Поэтому если вы шарахнули по Луне методом POST, вы можете быть (почти) уверены, что этот запрос выполнится. Если вы шарахаете методом GET, какой-нибудь промежуточный прокси может ВНЕЗАПНО отдать вам ответ из кэша, и шарах в реальности не произойдёт.



6. Операции GET, PUT, DELETE симметричны. PUT кладёт нечто по URL-у (создавая новый ресурс или перезаписывая старый), GET по этому URL-у возвращает представление того, что положил PUT, DELETE удаляет ресурс.
Метод HEAD синонимичен по семантике методу GET, но не возвращает тело ответа, а только его заголовки (метаинформацию о ресурсе).

7. POST используется в том случае, если у вас нет URL, к которому вы хотите применить операцию. Например, если пользователь пишет новое сообщение в тредик на форуме, он может сам вычислить его id и сделать:
PUT /threads/php-rulezz/messages/100500

Если клиенту генерировать id не разрешено, ему придётся делать POST на ресурс уровнем выше по иерархии:
POST /threads/php-rulezz/messages

И этот ресурс сам создаст новое сообщение.
Обратите внимание, если вы по ошибке или вследствие сетевых проблем повторите POST запрос — создастся второе сообщение в треде, идентичное первому. PUT вы можете делать хоть 100500 раз, результат не изменится. Это свойство называется идемпотентностью.
Ладно создание постов на форуме. Вот если вы делаете тяжёлую и дорогую операцию по пользовательскому запросу — очень рекомендуется выполнять для этого идемпотентный запрос. А то может получиться как на картинке:

Разумеется, использование идемпотентного PUT порождает свои проблемы — в частности, как разрешать конфликты. Придётся больше программировать, зато результат будет более надёжным и безопасным.

8. PUT может использоваться как для создания новых ресурсов, так и для обновления старых. Однако в случае использования PUT для перезаписи предполагается, что в теле запроса передаётся закодированный ресурс целиком. Если же вы хотите модифицировать ресурс, т.е. изменить его внутреннее представление без полной перезаписи, то для этого был придуман метод PATCH. Этот метод некэшируемый, небезопасный и неидемпотентный.

9. Коды ответа нужны в первую очередь для того, чтобы клиент мог понять, что ему делать дальше. 3хх говорит, что для успешного выполнения запроса нужно выполнить дополнительное действие. 4хх говорит, что клиент, составляя запрос, сделал что-то неправильно и, обычно, о том, что умолять бесполезно — повторное выполнение запроса всё равно выкинет ошибку. В 4хх крайне рекомендуется включать информацию о том, что конкретно клиент сделал не так. 5хх говорит о том, что клиент всё сделал правильно — проблема на стороне сервера.

Обычно при успешном выполнении операции сервер отвечает на GET — 200, на PUT — 201 Created (если ресурс создан) или 200 (ресурс обновлён), на DELETE — 204 (операция успешна, возвращать нечего), на POST — 200 или 201 (во втором случае в заголовке, обычно Location, указывается URL созданного ресурса).

10. Работая с HTTP-статусами, не наступите на популярные грабли:
  • статус 401 Unauthorized обязан сопровождаться заголовком WWW-Authenticate и, таким образом, применим только тогда, когда клиент аутентифицируется посредством HTTP-аутентификации; во всех остальных случаях следует использовать 403 Forbidden;
  • статусы 3xx — это не только редиректы; они показывают, что клиент должен выполнить дополнительное действие, иначе запрос не может считаться успешным; например, по статусу 304 Not Modified клиент должен взять актуальную версию ресурса из кэша;
  • статус 404, как ни странно, один из немногих 4xx статусов, которые клиент имеет право повторять — он означает, что ресурса сейчас нет, но вполне возможно, что он появится; вообще 404 — статус неопределённости, который используется, если сервер не хочет раскрывать механику ошибки; для того, чтобы индицировать клиенту, что без дополнительных действий с его стороны ресурс не появится, следует использовать 410 Gone (ресурс был удалён) либо общий статус 400.



11. Существует особый подкласс URL-ов, которые кодируют в себе и ресурс, и действие над ним. В англоязычной литературе их принято называть Capability URLs. Классический пример такого URL — ссылки на восстановление паролей, а также всевозможные «секретные» прямые ссылки на всяческие ресурсы.

12. Поскольку основная опасность при работе с Capability URL — возможность их утечки, следует максимально закрыть возможности случайно такой URL найти или перехватить:
  • для генерации секретных частей URL должен использоваться сильный генератор случайных строк (например, UUID 4), исключающий возможности найти Capability URL перебором; разумеется, URL не должен генерироваться детерминированным способом типа md5(username) и такие URL нельзя пропускать через сокращатели ссылок;
  • Capability URLs должны работать только по HTTPS;
  • страницы, доступные через Capability URL, должны быть закрыты wildcard-ом от индексации роботами.


13. Должны быть предусмотрены меры минимизации возможного ущерба:
пользователь, создавший Capability URL (например, расшаривший документ), должен иметь возможность сделать обратную операцию, т.е. отозвать URL;
Capability URLs должны протухать со временем; чем опаснее предоставляемый доступ, тем короче должен быть срок жизни URL.


14. Наконец, сами «секретные» страницы должны быть защищены от сливания данных сторонним агентам:
  • на них не должно быть никаких third-party скриптов и картинок, желательно — на уровне CSP;
  • на них не должно быть ссылок на third-party сайты; если они необходимы, то нужно скрывать referrer, например, через rel=«noreferrer»;
  • вообще желательно через Referrer Policy настроить скрытие referrer-а;
  • желательно сразу после захода пользователя через History API менять URL в адресной строке браузера, чтобы его нельзя было подсмотреть через плечо;
  • если ссылка предполагает какое-то действие (например, смену пароля), то на секретной странице должна быть форма (кнопка, скрипт), которую требуется отослать, чтобы действие осуществить, причём эта форма должно быть подписана CSRF-токеном (иначе префетчер браузера / почтового клиента / мессенджера сможет восстановить пароль за юзера).


15. Всё описанное выше существует в стандартах исключительно в форме рекомендации, и принудить кого-либо к строгому исполнению этих рекомендаций нельзя. Я уже не первый раз рассказываю про всю эту тривию, и часто слышу в ответ «да плевать я на всё это хотел, придумали какой-то ненужной ерунды; как у меня работали все сервисы только на GET, так и дальше будут, мучайтесь со своими PUT-ами и DELETE-ми сами».

Разумеется, вы вольны писать свой сервис сами. Но имейте, пожалуйста, в виду, что между вашим сервером и вашим клиентом, даже если они стоят физически рядышком в одном ДЦ, есть огромное множество других сетевых агентов — браузеров, прокси, роутеров, имплементаций HTTP-протокола в разных языках программирования и разных ОС, DPI-оборудование провайдеров и так далее. Все эти агенты плюс-минус имплементируют протокол HTTP с оглядкой на RFC.

Если вдруг клиентский браузер запрефетчит GET-ссылку и шарахнет по Луне — это будет ваша вина, бесполезно писать производителю. Если у вас деньги переводятся GET-запросом, а имплементация HTTP протокола в вашем языке программирования, не дождавшись ответа от соседнего роутера, решит повторить запрос и проведёт транзакцию дважды — это будет опять ваша вина.

Но даже не это главное. Допустим, ваши HTTP-пакеты гуляют в строго контролируемой среде. Как вы собираетесь объяснять другим разработчикам, какие рекомендации вы нарушили и почему? Как ваш коллега должен понять, что вот этот GET-запрос повторять нельзя, а статус 400 вовсе не означает клиентскую ошибку? Отступая от рекомендаций, вы, фактически, каждый раз создаёте какой-то свой диалект HTTP с собственной семантикой. Не забудьте его хотя бы задокументировать ;)

Список литературы:

(В разработке последнего документа ваш покорный слуга принимал определённое участие.)
Автор: @forgotten
Яндекс
рейтинг 345,38

Комментарии (113)

  • +1
    Лаконично и понятно, спасибо за статью. Довольно часто приходится сталкиваться с мифом/немифом о том, что вообще не стоит использовать операции отличные от GET/POST/PUT/DELETE, мол не по стандарту это, с отсылкой как раз к промежуточным узлам которые могу отказаться не в курсе, и программистам на принимающей стороне, которые тоже могут быть не готовы, а в некоторых случаях — и вовсе не иметь инструментов для обработки таких запросов. В итоге вся логика которая в статье выкидывается на PATCH, например, запихивается в хитрый PUT. И так далее. В принципе, вся статья как бы намекает на ваше отношение к этому, но тем не менее — выскажитесь пожалуйста )
    • +8
      В 2015 году промежуточных узлов, не умеющих PUT/PATCH/DELETE, кажется, не осталось. Я, по крайней мере, не сталкивался.

      В любом случае, есть стандартный механизм обхода таких ограничений: использовать GET/POST (первый в случае не изменяющих состояние ресурса операций, второй — для изменяющих), а нужный реально метод передавать заголовком X-HTTP-Method.
      • –7
        В 2015 году большинство сервисов пишутся на HTTPS.
  • +9
    Тема для холиваров на каждый день :)
    По-сути на http хорошо ложится более-менее стандартный CRUD, вопросы начинаются с более сложными вещами.

    Как вы обычно делаете (отображаете в HTTP API) query сущностей? А есть query возвращает данные по нескольким сущностям сразу? POST или GET для query? Odata, свой DSL или jquery format для параметров запроса? :)

    Вы предлагаете «query» задавать как параметр запроса к сущности (../etntity/?query) т.е. в самом URL path глаголов быть не должно? Нам, вот, часто нужны глаголы и засовываем мы их именно в path (entity/copy, entity/prefetch, entity/lock). От части это связно с выбранной библиотекой HTTP: так удобнее писать роутеры — они биндятся на конкрутный path, а вот разделять по query param пришлось бы внутри каждого роута, что неудобно. Как и на чем вы пишете роутинг? И насколько разнообразны потребители вашего API в плане платформ\языков? У нас service'ы на scala, апотребители C#, C++, python, javascript и typescript и у каждого свои претензии что «что-то» делать недобно (например дописывать ID в середину path). :)

    И в общем, в каких случаях вы позволяете себе отойти от следования рекомендациям по формированию пары method + URL?
    • +8
      > Тема для холиваров на каждый день :)

      Не совсем.
      Про REST-архитектуру действительно можно много холиварить, но пост-то не про неё, а про то, что нужно понимать смысл используемых сущностей — чем PUT отличается от POST, 400 от 500, ресурс от вызова метода. Здесь, в общем, разногласий быть не может: нравится тебе эта архитектура или нет, но Сеть работает именно так.

      > Как вы обычно делаете (отображаете в HTTP API) query сущностей? А есть query возвращает данные по нескольким сущностям сразу? POST или GET для query? Odata, свой DSL или jquery format для параметров запроса? :)

      > Вы предлагаете «query» задавать как параметр запроса к сущности (../etntity/?query) т.е. в самом URL path глаголов быть не должно? Нам, вот, часто нужны глаголы и засовываем мы их именно в path (entity/copy, entity/prefetch, entity/lock).

      Вообще, в парадигме REST ответ на каждый из ваших запросов такой: создавайте специальный ресурс для каждого специфического требования.
      Нужна мультиплексация? Значит, сделайте ресурс multiplexer.
      Хотите избежать глагола в path? Сделайте ресурс: не GET /entity/prefetch, а GET /prefetcher/entity. И биндинги писать проще станет.
      И так везде, где стандартной номенклатуры вам не хватает.
      Я не могу дать универсальной рекомендации типа «всегда кладите query в query». Нужно смотреть на семантику конкретной операции и укладывать в HTTP-термины.

      > И в общем, в каких случаях вы позволяете себе отойти от следования рекомендациям по формированию пары method + URL?

      В целом, я вовсе не являюсь фанатом REST-архитектуры, многие вещи в неё ложатся плохо. Я категорически против смешения французского с нижегородским: если отходить от рекомендаций RFC — то полностью. Скажем, переходить на JSON-RPC или, прости господи, SOAP.

      В реальности же постоянно наблюдаются адские кадавры, в которых перемешано всё, что можно.
      • +1
        Немного обидно за SOAP, за что вы его так? :) Свои задачи он решает достаточно хорошо
        • +1
          Его просто почти никто не умеет правильно готовить. Когда появляется задача «синтегрироваться с вон теми ребятами, у них SOAP», то в 9 случаях из 10 не получится просто взять клиентскую SOAP-библиотеку и синтегрироваться. Обязательно что-то сделано… не так (вон, ниже привели пример про fault). И нужно наворачивать слои костылей. В общем, SOAP очень хорошо ассоциируется с болью и страданием.

          Впрочем, если быть справедливыми, HTTP REST тоже мало кто умеет правильно готовить (с теми же вытекающими последствиями). Собственно, поэтому, видимо, и написали этот капитана пост.
          • 0
            Логично, что при интеграции, если кто-то не соблюдает спецификацию, то вероятно где-то будет боль :)
          • 0
            Его просто почти никто не умеет правильно готовить.
            Похоже, не неумеют, а ленятся. И это, в виду повсеместности, говорит уже не о людях, а о протоколе, в частности, об удобстве его использования. Вот я это у вас прочитал, и тут же возникла ассоциация с программированием на Java без IDE: раз для правильности и красивости нужно вручную набивать «многабукаф», то никто не захочет делать это дотошно и правильно, а предпочтут как-то «срезать углы» и делать менее трудозатратно.
  • +3
    Такой вопрос: у нас есть метод, возвращающий текущее время (как пример). Логично, что он должен быть GET'ом, но при этом кешировать его явно не надо, ибо глупо. Решение в явном запрете кеширования со стороны сервера, или есть что-то умнее с учётом кеширования шарахалок по луне?
    • 0
      В общем-то, можно придумать много решений, зависит от того, насколько важно всегда получать значение в обход кэша.

      Вполне валидно в этой ситуации использовать POST, например, т.к. это метод с максимально широкой семантикой.
      Можно обязать клиента приписывать к запросу время по его собственным часам и/или просто рандомное значение, тоже никто не запрещает.
      На практике заголовков запрета кэширования от сервера обычно достаточно.
      • +1
        POST — несемантично, рандомное значение — костыль. Но концепцию понял, волшебства тут нет :)
      • +1
        Извините, я не специально, но только сейчас заметил, что у вас кешируется время на yandex.ru/internet :)

        Скриншот

        • +2
          Да, всё точно, ваш ответ про время закешировался, обновил браузер и получил
          Скриншот


          Идёт запрос на yandex.ru/internet/api/v0/datetime в ответе нет параметров кеширования. Мой вопрос оказался очень к месту, хотя был задан раньше, чем я увидел проблему у вас. :)
          • –1
            Забавно.
            Передам разработчикам )
            • 0
              А еще, у вас там же по нидерландскому айпишнику определяет Реутов, исправьте, пожалуйста :)
    • 0
      Мы как-то для подобных случаев (у нас не должны были кешироваться динамические картинки) просто добавляли к таким URL параметр со случайным значением который ни на что не влиял.
      • +1
        Но тем не менее они все равно будут кешироваться забивая кеш бесполезными копиями…
        т.е. перенесли проблему с больной головы на здоровую.
        • +3
          Ну если добавить еще заголовки запрета кэширования, то тот кто будет кэшировать (браузер, прокси и пр.) — ССЗБ.
    • 0
      Еще есть вариант с сохранением результата запроса на сервере или кодирование его в урле для последующей отдачи уже по другому ресурсу. К примеру:
      1 запрос
      > POST /current_timestamp/
      < 201 created
      < Location: /timestamp/1440751720/

      2 запрос
      > GET /timestamp/1440751720/?format=с
      < 2015-08-28T10:48:40+02:00
      • +6
        А это уже бредом попахивает. Конечно, семантичненько вышло, но накручивание могучей логики ради красоты семантики это уж слишком.
    • +1
      В таких случаях предполагается использование заголовка Last-Modified.
  • +7
    Это был самый полезный Капитан Очевидность которого я когда-либо видел.
    • +1
      Стараемся ;)
  • 0
    Прокси-сервера не очень охотно поддерживают что-то кроме get и post, особенно какие-нибудь корпоративные всеограничивающие.
  • +4
    10 пункт насилует логику. В заголовке написано что все что дальше — заблуждения. Т.е. в итоге получается что:
    * 401 Unauthorized не обязан сопровождаться заголовком WWW-Authenticate (хотя выделение болдом намекает на обратное);
    * статусы 3xx — это только редиректы;
    * статус 404 клиент не имеет права повторять (кстати предложение сформулировано вообще неправильно: клиент имеет право повторять запрос а не статус).
    • +1
      Зашел написать этот комментарий.
    • +1
      Переформулировал.
  • –15
    Где-то минимум трети написанного — субъективщина. Начиная с первого пункта и далее. Чем вам не нравится метод в запросе? Почему не /Луна/?who=шарахалка?

    Якорь ещё зачем-то фрагментом обозвали. И про сортировку по породам — тоже спорный вопрос, многие интернет-магазины уже отошли от этой концепции нулевых и ссылаются на один и тот же монитор и как /monitors/HP/model, и как /HP/monitors/lcd/model, фильтры и теги вместо жесткой каталогизированной структуры.
    • +13
      en.wikipedia.org/wiki/Fragment_identifier
      tools.ietf.org/html/rfc3986#section-3.5

      В RFC нет ни одного упоминания слова anchor.
    • +7
      Не стоит брать за основу своих доводов некорректно написанные ядра интернет-магазинов. За разные запросы, возвращающие одинаковые сущности, можно и с поисковой выдачи улететь.
    • +11
      > Где-то минимум трети написанного — субъективщина.

      К рекомендациям RFC и IETF можно много претензий предъявлять, но уж точно не в плане субъективности.

      > Почему не /Луна/?who=шарахалка?

      Потому что ресурс — то, над чем выполняется действие. Стреляя из пушки, вы выполняете действие над пушкой, а не над Луной. К тому же, вряд ли у вас есть ресурс «Луна».

      > Якорь ещё зачем-то фрагментом обозвали.

      Это не я, это Тим Бёрнерс-Ли
      tools.ietf.org/html/rfc3986#section-3.5

      > И про сортировку по породам — тоже спорный вопрос, многие интернет-магазины уже отошли от этой концепции нулевых и ссылаются на один и тот же монитор и как /monitors/HP/model, и как /HP/monitors/lcd/model

      Тем самым нарушая самый базовый принцип архитектуры Web из возможных.

      Although there are benefits (such as naming flexibility) to URI aliases, there are also costs. URI aliases are harmful when they divide the Web of related resources. [...] Good practice: Avoiding URI aliases. A URI owner SHOULD NOT associate arbitrarily different URIs with the same resource.

      www.w3.org/TR/webarch/#uri-aliases
      • 0
        Теги/фильтры из путей кыш! Тип товара и производителя — тоже, ибо бывают комбинированные типы товара, бывает мультибрендовые товары, и если в пути есть бренд или тип товара, такие товары в него честно не положить. Так?
        То есть, GET shop.example.com/catalog/article наше всё, а теги — идут в характеристики товара, и в URL им не место?
        • +1
          Зависит от конретной стуктуры каталога.
          Общий вариант — отдельная страница товара типа /goods/{name}, и отдельный поисковый ресурс типа /search?tag=lcd, который умеет фильтровать по признакам (ну или несколько таких ручек под каждый тип фильтра).
  • +3
    А если переделать фразу с Get на Post:
    «да плевать я на всё это хотел, придумали какой-то ненужной ерунды; как у меня работали все сервисы только на POST, так и дальше будут, мучайтесь со своими PUT-ами и DELETE-ми сами».
    То что в ней не так?
    • 0
      и это тоже крайность
    • +7
      Да практически всё.
      — Нет кэширования.
      — В условиях плохого соединения запросы повторять нельзя.
      — При нажатии Back или Refresh браузер будет показывать сообщение «Подтвердите повторную отправку формы».
      И так далее.
      • 0
        Если я делаю REST API для SPA или для мобильного приложения, то мне как раз не нужно кеширование.
        Надеяться, что кто-то повторит мой запрос тоже как-то странно, для этого я делаю отдельный обработчик, хотя, тут, может и стоит заиспользовать GET.
        Ну а проблема Back или Refresh для REST API не актуальна.
        На счёт Get/Post понятно, но зачем усложнять REST API всякими PUT и DELETE, если достаточно POST и GET.
        • 0
          Вот как раз мобильное приложение — это то место, где *необходимо* использовать идемпотентные методы, поскольку мобильная сеть крайне ненадёжна, и любой запрос может отвалиться по таймауту (и при этом успешно выполниться на сервере).

          Ну и как-то странно отказывать от кэширования в мобильном о_О
          • 0
            Зачем в мобильном REST API использовать PUT, DELETE, CREATE и т.п. если можно обойтись POST и GET?
            В чём реальный профит?
            • 0
              Приблизительно в том же, в чем вообще профит от хорошей архитектуры — проще, понятнее, расширяемее.
              • +1
                «Хорошая архитектура» — слишком абстрактное понятие. Я предпочитаю конкретные вещи. Вроде того, что ответили выше про то, что GET-кешируемый и повторяемый. Ок, это может помочь достучаться до сервера. Хотя логику кеширования и обработки таймаутов и ошибок всё равно нужно писать на клиенте.

                Вот, например, я реализовал публичный REST API, и мне надо чтобы клиенты могли им просто воспользоваться по простой доке и не трогали меня. По мне, так лучше предложить им 1 или 2 типа запроса, чем 5.
                Как-то предложить людям постить JSON запрос на /FunctionName проще, чем заставлять их выставлять X-Header, тип запроса, а данные либо пихать в query, либо в body и прочее.
                Kлиенты же потом прийдут мозг выносить: «почему мой запрос не проходит» из-за этой расширяемости архитектуры.
                • +1
                  Вроде того, что ответили выше про то, что GET-кешируемый и повторяемый.

                  PUT тоже (может быть) повторяемый. И это очень удобно, когда вам надо создавать объект по нестабильному соединению.

                  Хотя логику кеширования и обработки таймаутов и ошибок всё равно нужно писать на клиенте.

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

                  По мне, так лучше предложить им 1 или 2 типа запроса, чем 5.

                  Эти «типы запросов» потом где-то выражать. У вас есть выразительное средство, но вы им не пользуетесь.

                  Вот, например, я реализовал публичный REST API, [...] Как-то предложить людям постить JSON запрос на /FunctionName проще

                  Просто это больше не REST, вот и все.

                  Есть такая штука — семантика. Вот вы ее нарушаете. Человеку, который привык к нормальным REST API будет сложно понять, что у вас происходит, и как этим пользоваться. Это как в SOAP вместо fault возвращать обычный ответ с кодом ошибки.
                • 0
                  мне надо чтобы клиенты могли им просто воспользоваться по простой доке и не трогали меня
                  Как-то год работал с CMS, оптимизированной по такому принципу — было жутко неудобно и медленно.
    • 0
      Кстати, меня как-то на одном собеседовании спрашивали, зачем нужен GET, когда есть POST, который по идее должен делать всё то же самое. Сошлись на том, что из-за кэширования.
  • +11
  • +1
    Вот меня уже давно мучает вопрос по custom http codes. При ошибках нам всегда нужно приложению отдавать конкретный номер ошибки, сейчас отдаем более подходящий стандартный http статус, а в теле ответа уже код конкретной ошибки. Боюсь за неадекватное поведение разных прокси у клиентов. Но такое дублирование разных кодов, сначала в заголовках, а потом в теле мне не совсем нравится. Есть у кого-то идеи по этому поводу или даже опыт работы с пользовательскими статусами?
    • +6
      Чтобы ответить на ваш вопрос, требуется конкретика. Почему вас не устраивает стандартная номенклатура http-кодов?

      Если у вашего ресурса есть какая-то своя внутренняя номенклатура ошибок, то никто не мешает отвечать статусом 400 или 500 (в зависимости от того, клиентская ли ошибка) и отдавать в теле ответе, скажем, JSON с описанием произошедшей ошибки (в т.ч. внутренним кодом). Однако в такой ситуации я бы рекомендовал работать по RPC-протоколам, т.е. использовать HTTP как транспорт и отвечать строго 200.
      • 0
        Не хотел приводить конкретный пример, что бы не обсуждать частный случай. Но представьте, у нас есть игра, и некий ресурс, который возвращает актуальный статус игры: Фаза1, 2… 10. Хотелось бы, например, возвращать коды 251, 252… 260 соответственно. Тогда даже простым HEAD запросом можно уже знать состояние. Или другая ситуация с ошибкой валидации запроса: 471 — неверный параметр «А», 472 — конфликт значения «Б», и т.д.

        никто не мешает отвечать статусом 400 или 500 (в зависимости от того, клиентская ли ошибка) и отдавать в теле ответе, скажем, JSON

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

        Но к сожалению на данный момент нет общепринятого промежутка для пользовательских статусов, например 45X-49X;55X-59X;7XX-9XX;, или другой способ: конкретный номер через точку:400.ХХХ.

        • +5
          Наверное что-то не уловил, но почему не использовать X-Status?
          • 0
            Да, это действительно отличный вариант! Не хочу писать это слово, «но» это уже из области пользовательских заголовков. А работа лишь с http статусом была бы предпочтительнее.
            • +7
              Почему? Ведь HTTP-статус — это «транспортная» часть, статус доступа. А Вы хотите отдавать логическую часть приложения, смешать два уровня.
              • 0
                Согласен, так оно и есть. Но на данный момент, мне кажется, это вовсе не два изолированных уровня, особенно в контексте API ресурсов, ведь за частую, как раз приложение решает или это Bad Request, или Conflict, или что-то там ещё. Выходит эти уровни уже давно смешаны. Поэтому пора бы уже сделать окончательное смешивание.
                • +6
                  torkve совершенно прав, так делать ни в коем случае не надо.
                  HTTP статусы должны индицировать статус самого обращения по HTTP. Если HTTP — просто транспорт до некоторого программного endpoint-а, то номенклатура HTTP-статусов должна описывать строго транспортную часть. 200 — запрос дошел и обработан endpoint-ом (независимо от успешности и состояния такой обработки), 4xx — запрос сформирован неправильно и до endpoint-а не дошёл, 500 — ошибка сети или веб-сервера (т.е. обработчика HTTP-запросов, не самого endpoint-а).
                  • 0
                    Неужели никто никогда не меняет напрямую, или косвенно статус код ответа в самом приложении? Если «нет», то тут вы меня удивите, а если «да», то речи о разделении транспорта и приложения уже быть не может. Или мы как-то друг друга не верно понимаем? Или ещё пример: Если endpoint по фазе луны определил что запрос не верный, неужели статус не измените на 400?
                    • 0
                      > Неужели никто никогда не меняет напрямую, или косвенно статус код ответа в самом приложении?

                      Ммм, это как?

                      > Если endpoint по фазе луны определил что запрос не верный, неужели статус не измените на 400?

                      Нет.
                      HTTP статусы — для ошибок уровня протокола HTTP.
                      Вас же не удивляет, что TCP не знает про ошибки HTTP, и статус его пакетов «ок», даже если в них передаётся закодированная 500-ая ошибка HTTP? Так и в вашем случае. HTTP отработал валидно — статус 200.
                      • 0
                        > Неужели никто никогда не меняет напрямую, или косвенно статус код ответа в самом приложении?
                        Ммм, это как?

                        Response.StatusCode = HttpStatusCode.BadRequest;
                        // или через exception
                        throw new HttpBadRequest(message);
                        // или ещё другими 100500 способами.
                        

                        > Если endpoint по фазе луны определил что запрос не верный, неужели статус не измените на 400?
                        Нет.
                        Интересно, как же по вашему в этом случае приложение должно получить и отреагировать на ошибку (если мы говори о RESTful запросах и json формате)? lair упоминает о адекватном использовании существующих статусов. И действительно, все так делают, вы наверное первый от кого я слышу, что нельзя в приложении менять статус на 400, например.

                        lair, «адекватно-не-адекватно» это в любом случае «использование» в целях приложения, и я к тому и веду, что раз мы уже используем статусы, то чисто из мышления программиста у меня возник вопрос, ведь это абсолютно не DRY менять http-status, и ещё! дополнительно! передавать конкретный статус или в кастомном заголовке, или же где-то в теле ответа. И клиент тоже соответственно должен реагировать на http статус, a потом ещё на конкретный статус ответа.
                        • 0
                          Здесь какое-то недопонимание.

                          Если у вас REST-приложение, оно *обязано* отвечать HTTP-статусами, соответствующими типу ошибки.

                          Если у вас не-REST (JSON-RPC, SOAP, whatever) приложение, то good practice — отвечать не-200 статусами только в случае проблем самого HTTP-протокола (сетевые ошибки, веб-сервер упал). Все ошибки внутреннего состояния сервиса кодируются в соответствующий (JSON, XML) формат и возвращаются с HTTP-статусом 200.
                          • 0
                            Читал-читал ваш диалог, и окончательно запутался с REST и кодами ответа.

                            При запросе, например, профиля пользователя по id возможны варианты:

                            1. Пользователь найден, статус ответа 200, в теле ответа профиль в формате xml. Семантика xml описывается в доке на API.
                            2. Пользователь удален, статус 410, тело ответа пустое
                            3. Такой пользователь никогда не был зарегистрирован, статус 404, тело ответа пустое
                            4. Этот профиль запрашивающему смотреть нельзя, ответ 403
                            5. Пользователь в отпуске, отвечаем 303 и id заместителя

                            По-моему, это ерунда какая-то?
                            • 0
                              Если вы про REST, то никакой особой ерунды (разве что последнее вызывает у меня сомнения — во-первых, должен быть не ID, а Location, а во-вторых, что важнее, я не уверен, что семантика 303 совпадает с семантикой «замещения»).
                              • 0
                                Ерунда тут в том, что таким подходом можно описать только работу с чем-то, очень похожим на файловую систему: ссылки, каталоги, права доступа, вот это все, а содержимое файлов — просто блоб. Можно, конечно, притвориться, что наши сервисы (как профили пользователей в моем примере) — это такие хитрые ресурсы, но тогда от REST в такой схеме ничего не остается.
                                • +1
                                  Зачем притворяться? Профили — и правда ресурсы, а то, что вы называете «файловой системой» — всего лишь иерархически организованные ресурсы с перекрестными ссылками. И это вполне понятное человеку структурирование домена.

                                  Кстати, почему же содержимое — просто блоб? Нет, отнюдь, это гипермедиа.
                          • 0
                            Если у вас не-REST (JSON-RPC, SOAP, whatever) приложение, то good practice — отвечать не-200 статусами только в случае проблем самого HTTP-протокола

                            А где можно почитать про эти good practice?
                            • 0
                              Ммм, выше, например?
                        • 0
                          ведь это абсолютно не DRY менять http-status, и ещё! дополнительно! передавать конкретный статус или в кастомном заголовке, или же где-то в теле ответа.

                          Почему же? Это типичная ситуация статуса и сабстатуса. Один клиент будет разбирать тело, другой — нет, но оба они получат ошибочный статус верхнего уровня и не будут считать сообщение успешным.
                  • 0
                    Выше я имел ввиду именно RESTful подход, не RPC, SOAP
                  • 0
                    Вообще, это вы странное сейчас описываете. Так (разделяя транспорт и бизнес) действительно делали в SOAP, а вот в REST так не делают. REST признает HTTP-протокол и использует его статусы для описания своего состояния.

                    Это не повод, конечно, заводить собственные статусы, но вот адекватно использовать существующие — например, 500 при любой серверной ошибке, которую мы согласились разглашать, или 400 в случае ошибки валидации, или 409, если изменилось состояние объекта для модификации — вполне разумно и адекватно.
                    • 0
                      Не очень понимаю, о чём вы. REST — это как раз предложенная идеологами HTTP архитектура веб-приложений, полностью на HTTP основанная. Разумеется, REST-сервис должен полностью и строго следовать описанным в стандарте принципам.

                      Я пишу ровно о том, что правильно или делать приложение в REST-парадигме, полностью следуя семантике HTTP; или делать приложение в другой архитектуре, например, SOAP или JSON-RPC, но тогда ни в коем случае не нужно смешивать статусы высокоуровневых операций с низкоуровневыми. HTTP 500 и HTTP 200 передаются через TCP совершенно одинаково, никто не пытается HTTP-статусы спустить на уровень TCP. Так же и ваше приложение поверх HTTP не должно использовать HTTP-статусы для индицирования своего высокоуровневого состояния.
                      • 0
                        Окей, признаю, не отследил момент, когда вы предложили отказаться от REST и перейти на RPC-over-HTTP. В этом случае, действительно, вы правы (другое дело, что я не могу найти достаточно мотивирующих факторов для такого перехода, но это тема другого обсуждения).
                        • 0
                          а где вы работаете, если не секрет? :)
                    • 0
                      Поскольку REST признает HTTP-протокол и использует его состояние, то он не исключает использование и собственных заголовков, ведь HTTP не запрещает этого делать (ну кроме префикса X- который записали в старевший).

                      Конечно приложение должно максимально использовать существующие статусы, но если логика приложения требует своих, то ни что не мешает их добавить. Просто отражать они должны нужные состояние приложения, а не соединения.
                  • 0
                    Вопрос с подковыркой. Где в приведенных примерах 3хх?
                    Я к тому, что endpoint как именно конечно точка htpp взаимодействия может и сам отвечать статусами.
                    • 0
                      3xx в этой ситуации возможен, кажется, только в случае переезда endpoint-а за другой URL
                      • 0
                        На вскидку 304. Веб сервер без участия endpoint не знает, можно ли так ответить (мы же говорим про приложение, т.е. запросы динамические и это не статика в ФС в которую веб сервер может сходить и сам).
                        • 0
                          Как правильно должно выглядеть такое взаимодействие:

                          клиент: POST /resource/
                          { «method»: «getNyashnyKotikNextPhoto», «ifModifiedSince»: <таймстэмп> }
                          сервер: 200 OK
                          в теле либо { «notModified»: true }, либо фотка няшного котика

                          Неправильно:
                          клиент: GET /resource/?method=getNyashnyKotikNextPhoto
                          If-Modified-Since: <таймстэмп>
                          Сервер: 200 OK или 304 Not Modified

                          Почему так неправильно: потому что ручка resource обладает внутренней семантикой, неизвестной протоколу HTTP. В данном примере — сервер не только отдаёт/не отдаёт картинку, но и смещает внутренний указатель (getNextPhoto).

                          Соответственно, возможны практически любые спецэффекты. Например, где-то прокси может закэшировать ответ, и фоточки котиков не будут листаться.

                          Я специально подобрал такой пример, конечно. Но, если у вашей ручки нет внутренней семантики, не ложащейся в HTTP — значит, можно строго по HTTP и работать. А если она есть, то на подобные грабли вы обязательно и наступите.
                      • 0
                        Ну и 1хх тоже остались охвачены. 102 без бэкнда никуда.
  • +1
    Мне так не ясен один вопрос. Если HTTP имеет методы GET, POST, PUT, DELETE и другие, почему же в самом HTML в теге FORM могут присутствовать только два метода: GET и POST?
    • 0
      легаси?
    • +6
      Вероятно, потому, что HTTP и HTML — разные вещи, и их даже разрабатывают три разные организации.
      • 0
        Вот кстати да, из html все эти навороты http не доступны фактически, то есть они нужны лишь небольшому подмножеству ваятелей собственных клиент-серверных API, не имеющих отношения у браузеру, но почему-то желающих пользоваться именно http и ничем сверх него. Странные это люди, кажись…
        • +3
          Говорят, XML HTTP Request бывает в браузерах. А в некоторых даже таки Fetch.
  • 0
    Отличная статья, спасибо кэпу :)

    Но у меня есть вопрос: где посмотреть реальный пример, где все эти методы реализованы и правила соблюдаются?
    Мне вот, например, не доводилось использовать такой API :)
    • 0
      У GitHub'а весьма недурной API, я думаю, что там выдержаны как минимум почти все пожелания из этого поста (кроме Capability URL, но они не к API относятся, а к веб-интерфейсу): developer.github.com/v3
      • 0
        Спасибо, посмотрю. Как обычно, все под носом, но не заметно :)
    • +2
      REST API Диска, например, почти полностью по стандарту.
      tech.yandex.ru/disk/poligon/#!//v1/disk/resources
      • +1
        Было бы круто чтобы API Яндекс.Директ и Яндекс.Маркет также можно было показать в пример к этой статье :)
    • +5
      Видимо API какого-то из сервисов Яндекса.
    • 0
      Здесь? КМК хабрахабр использует бОльшую часть из этой статьи =)
  • –4
    Опять полное непонимание, что такое URL/URI/URN… бррр
    И это в Яндексе?
    • +6
      Что, простите?
  • 0
    Последний абзац можно отнести к любым стандартам и соглашениям — они для того и есть, чтоб все понимали друг друга.
  • +1
    Советы полезные — спору нет, но все же вброшу немного.
  • 0
    Оформить бы эти рекомендации в виде онтолигии… Или, хотя бы, таблички.
    • +1
      Дерзайте.
  • +2
    сильный генератор случайных строк (например, UUID)

    Это вы так пошутили? UUID не является криптографически стойким рандомом, от слова «вообще»

    Теорию, почему так — читайте в вики. А чтобы убедиться на практике — сделайте:
    SELECT CONCAT(UUID(), '|', UUID())

    И получите что-то вроде:

    f24bdc1a-5082-11e5-b94b-001d92633d17
    f24bdc47-5082-11e5-b94b-001d92633d17

    А повторный вызов минут через пять даст:

    2a3b5871-5083-11e5-b94b-001d92633d17
    2a3b589e-5083-11e5-b94b-001d92633d17

    Более того, имея один UUID() с нужного хоста и примерное время прошедшее с момента генерации «искомого» — диапазон значений «искомого» можно определить очень легко, т.к. функция обратима.

    Для тех, кто не понял, практическая реализация атаки:
    1. Делаем восстановление пароля «на себя» — запоминаем время запроса
    2. Делаем восстановление пароля на «цель» — запоминаем время
    3. Вычисляем примерное значение для цели по разности времени между запросами
    4. Брутфорсим по значению п.3 +- #ffff


    Резюме: НИКОГДА не используйте uuid как секретную строку!

    Ну, и раз сказал «как не надо» — в качестве дополнения, простой способ получить нормальный криптостойкий секрет на ПХП (с переводом в шестнадцатеричную строку)

    bin2hex( openssl_random_pseudo_bytes( 16 ) )
    • 0
      получите что-то вроде:

      f24bdc1a-5082-11e5-b94b-001d92633d17
      f24bdc47-5082-11e5-b94b-001d92633d17


      Это конкретно ваша реализация GUID так себя ведет. Сравните:

      CE46945F-61F2-4D52-96F1-5F57D99EC519
      31FB8849-CC81-460B-B873-58CEA0E7443E
      04298C79-8B33-4C44-8314-CA6A7F27ED4D
      813F0486-3C04-45D9-8D63-B5131DC75708
      


      (это четыре последовательных вызова генерации GUID на Windows, конкретно — newid() в MS SQL)

      Функция, конечно, все равно не годится для сильной криптографии, но вот реально предсказать ее значения за разумное время вам будет непросто. А ведь еще может быть не один хост.
      • +3
        К сожалению, вы не правы. Значения только выглядят рандомными, но таковыми не являются.
        Хотя реализация GUID в Windows проприетарна, тем не менее, есть исследования показывающие ее уязвимость.

        Сама по себе природа UUID такова, что генерируемые значения не будут иметь криптографически-нормального распределения, т.к. uuid должен обеспечивать уникальность, а не случайность.
        А это совершенно разные вещи!
        • 0
          Не прав в чем? Я сказал, что они случайные? Я сказал, что они имеют распределение, неотличимые от случайного?

          Я всего лишь сказал, что предсказать значения этой функции за разумное время непросто. Да, несомненно, атаковать ее проще, чем криптографически сильную, но дальше вопрос баланса (как обычно).

          (BTW, вполне возможно генерить криптографически сильные (122 бита из 128) GUID-ы, удовлетворяющие четвертой версии стандарта)
          • +3
            Вы написали:
            Функция, конечно, все равно не годится для сильной криптографии, но вот реально предсказать ее значения за разумное время вам будет непросто.


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

            Для тех, кому неохота вникать в статью целиком, приведу несколько ключевых моментов:

            Сейчас мы уже знаем, из какого теста сделан Uuid-генератор и с уверенностью можем сказать — испытания на backward security он не выдерживает. Полное обновление состояния у него возникает спустя 4·106 байт выхода (8 экземпляров RC4 * 500 000 байт), поэтому злоумышленник, сумевший заполучить «слепок» всех регистров и S-блоков UuidCreate в определённый момент времени, способен предугадать до 250 000 будущих Uuid-значений. Более того, источником энтропии генератора выступает ГПСЧ Windows в лице SystemFunction036, что, с учётом предсказуемости последнего [8], увеличивает длину скомпрометированного потока данных до запредельных 256·106 байт!


            Вы спросите, зачем потребовалось вместо источников энтропии ОС использовать суррогат, получаемый из ГПСЧ Windows? Начну издалека. Во-первых, никто в здравом уме на стойкость UuidCreate больших надежд не возлагал (а те, кто возлагал, просто обязаны дочитать эту статью до конца). Сами демиурги вполне внятно обозначили свою позицию в RFC4122[1]: «Do not assume that UUIDs are hard to guess; they should not be used as security capabilities (identifiers whose mere possession grants access), for example. A predictable random number source will exacerbate the situation». Так что перед разработчиками никогда не стояло цели создать безопасный Uuid-генератор. К сожалению, планету Земля все ещё топчут гоблины (не только топчут, но и карты экспресс-оплаты штампуют!), которые к подобным предостережениям относятся без должного трепета.


            Как видите, даже в стандарте, четко сказано — что uuid нельзя использовать как «секрет» — так как ее значения «угадываемые»

            Я вот прямо даже не знаю, что еще можно добавить, если уж разработчики стандарта однозначно пишут, что "should not be used as security capabilities (identifiers whose mere possession grants access)"…
            • 0
              Однако, приведенная по ссылке работа четко показывает, что это просто.

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

              Впрочем, это не так критично. Для «честных» capability urls действительно лучше использовать криптографически сильные GUID.
              • +1
                Сколько — можно предполагать с очень высокой точностью. Посмотрите пример атаки-же… Вы сами формируете оба запроса, «эталонный» и «хакерский».

                Время между ними — минимально и контролируемо. Если атакуемая система не какой-нить фейсбук, с его миллионами запросов в секунду — то с большой долей вероятности, речь идет максимум о десятках. Более того, несколько запросов «на себя» позволят еще и вычислить примерный «темп» генерации.

                Не забывайте, главная-то проблема в том, что вы в любой момент можете «попросить» генератор дать новое значение. И, таким образом, исследовать поведение генератора.
                • 0
                  Вы сами формируете оба запроса, «эталонный» и «хакерский».

                  Вот только (а) вы на сервере не единственный пользователь (б) генератор гуидов — если люди не заморачивались с безопасностью — один на всю систему, а это значит, что он используется в каждой операции, а не только в атакуемой

                  • 0
                    А ещё (ц) вы должны знать, какая реализация используется. Мы вот используем gen_random_uuid() из модуля pgcrypto для PostgreSQL. Хоть в исходниках там и используются псевдослучайные числа (не иначе, как для производительности), но для целей генерации Capability URLs этого должно быть более, чем достаточно.
    • 0
      Речь идёт о UUID версии 4. Исправил в тексте, спасибо.
      • 0
        Как-бы формально вы правы.

        Но я боюсь, большинство не поймет / не обратит внимания на разницу. Тем более, что UUID 4 просто недоступен в большинстве популярных окружений. Поправьте меня, если это не так.

        А еще лучше — приведите примеры практической реализации. Думаю, это будет полезно всем, так как тема на самом деле «больная». Ну очень уж часто я встречаю генерацию «секретов» с использованием uuid, к сожалению…
  • +1
    У меня вот другой взгляд на шараханье по Луне. Из REST и соответственно HTTP мы знаем, что кошерно выполнять операции CRUD над ресурсами. Когда мы выполняем
    POST /шарахалка/?to=Луна

    можно подумать что мы то ли изменяем состояние шарахалки то ли создаем новую шарахалку.

    Я бы делал либо так
    POST /шарахалка/выстрелы/?to=Луна

    ну или может еще так
    POST /луна/ущерб?weapon=шарахалка


    Т.е. всегда фокусироваться на том, что мы создаем или меняем, и использовать это в качестве ресурса а не инструмент или фабрику, с помощью которых это делается.
  • 0
    forgotten давно хочется почитать то же самое, но с учётом современных реалий — HTTPS. А то все про долбаное «старьё» в виде ответа на GET кеширующим прокси пишут и пишут всё. Хочется вычеркнуть из поста 80%, не относящееся к HTTPS. Кстати, тема для отдельного поста…
    • +2
      Во-первых, HTTPS исключает кэширование на стороне промежуточных сетевых агентов. Но вот конечный клиент и имеющиеся на его стороне агенты, будь то браузеры/месенджеры/иные приложения (и плагины/аддоны к ним!) или используемый разработчиком фреймворк, имеют прямой доступ к данным, и все указанные спецэффекты пронаблюдать довольно тривиально.

      Во-вторых, про 80% вы, кхм, несколько преувеличиваете. Неидемпотетные методы останутся таковыми независимо от секьюрности протокола.
      • 0
        Но вот конечный клиент и имеющиеся на его стороне агенты,

        Аналогично и на стороне поставщика данных: HTTPS может начинаться с внешней границы, на которой стоит простая железка с большим кэшом.
        • 0
          Да, и это тоже правда. Далеко не все гоняют трафик внутри ДЦ по HTTPS, часто внутренние прокси по HTTP работают.
      • 0
        Пожалуй, с 80% я перегибаю, но вот если речь идёт о том, что вы можете контролировать (nginx и другие reverse-proxy) и параметры кеширования в браузере — то всё-таки отличия существенные. Если у тебя https и куки солёные, ответ от своего сервера получишь всегда, независимо от метода.
  • –2
    Самое удивительное в этой статье то, что в блоге Яндекса кто-то использует матюки для большей доходчивости :)
    Но придраться не к чему — на то он и Капитан, чтобы использовать армейскую лексику (хотя бы чуть-чуть).

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

Самое читаемое Разработка