Русский
Русский
English
Статистика
Реклама

Http

Парсинг сайта Умного Голосования и новый API на сайте ЦИК

20.09.2020 20:22:28 | Автор: admin
image

13 сентября 2020 года в России прошёл единый день голосования. В некоторых регионах оппозицией была применена стратегия Умного Голосования, заключающаяся в том, что оппозиционно настроенные избиратели голосуют за единого кандидата, имеющего наивысшие шансы победить представителя от властей.

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

На сайте Умного Голосования нельзя получить список поддержанных кандидатов, указав, например, город и округ. Если кто-то захочет собрать данные по региону, ему предстоит монотонная работа по подбору адресов для каждого округа.

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

В итоге получилась вот такая сводная таблица. В данной статье я расскажу, как был получен приведённый набор данных, как собиралась информация с сайтов Умного Голосования и нового веб-сервиса ЦИК.

image


Сайт Умного Голосования



Для начала посмотрим, какие данные мы можем извлечь с сайта Умного Голосования. На главной странице сайта есть поле для ввода адреса регистрации пользователя. При вводе строки появляется список предложенных адресов в следующем формате:

image


При выборе одного из предложенных адресов, мы перемещаемся на страницу избирательного участка, к которому прикреплён выбранный адрес:

image


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

image


В данном случае мы видим выборы губернатора, для которых УмГ не указало кандидата от оппозиции. Связанно это с тем, что выборы губернаторов проходят в два тура и не имеет значения, за кого из оппозиционных кандидатов проголосуют избиратели.
Также мы видим сразу трёх кандидатов, за которых предлагают проголосовать на выборах в городской парламент. Связанно это с тем, что на выборах в Сочи многомандатные округа.
На всех остальных выборных кампаниях, задействованных УмГ в этом году, были только одномандатные округа.

Заглянем в код страницы и обнаружим, что все описанные данные, собраны в удобном JSON-формате. В элементе с id="__NEXT_DATA__", который используется для отрисовки страницы, есть информация об избирательном участке, о соответствующих выборных кампаниях и кандидатах:

Содержимое __NEXT_DATA__ элемента
{   "props":{      "pageProps":{         "id":"440384",         "settings":{            "id":1,            "share_photo":"/ganimed-media/share_photo/smartvote_sharepic_1200x628.jpg",            "video_on_main_page":"https://youtu.be/w8gapDGwWMY",            "fake_mode":false,            "title_share":"Объединяемся, чтобы победить Единую Россию",            "text_share":"Мы разные, но у нас одна политика  мы против монополии Единой России. Всё остальное  математика.",            "telegram_bot_link":"https://tlinks.run/smartvotebot",            "viber_bot_link":"viber://public?id=smartvote",            "facebook_bot_link":"https://facebook.com/umnoegolosovanie/",            "alice_link":null,            "vk_bot_link":null         },         "serverData":{            "commission":{               "id":440384,               "number":"4317",               "address":"354340, Краснодарский край, город Сочи, Адлерский район, улица Богдана Хмельницкого, 24",               "descr":"здание средней школы  49 им. Н.И. Кондратенко",               "lat":"43.425923",               "lon":"39.920152",               "region_id":26,               "region_intid":"135637827259064320000372513"            },            "campaigns":[               {                  "id":26,                  "code":"krasnodar-gub-2020",                  "title":"Выборы губернатора Краснодарского края",                  "is_regional":true,                  "ready_date":null,                  "district":{                     "id":458,                     "code":"oik-0",                     "name":"0",                     "leaflet":""                  },                  "candidates":[                     {                        "id":998,                        "name":"Кондратьева Вениамина Ивановича",                        "share_image":"/elections-api-media/share/26/998.png",                        "anticandidate":true,                        "self_nominated":false,                        "has_won":false,                        "has_second_round":false,                        "party":{                           "title":"Единая Россия",                           "antiparty":true                        }                     }                  ]               },               {                  "id":28,                  "code":"krasnodar-sochi-gorduma-2020",                  "title":"Выборы в городское собрание Сочи",                  "is_regional":false,                  "ready_date":null,                  "district":{                     "id":526,                     "code":"oik-2",                     "name":"2",                     "leaflet":"/elections-api-media/28/526-1334-1335-5385.pdf"                  },                  "candidates":[                     {                        "id":1334,                        "name":"Киров Сабир Рафаилович",                        "share_image":"/elections-api-media/share/28/1334.png",                        "anticandidate":false,                        "self_nominated":true,                        "has_won":false,                        "has_second_round":false,                        "party":null                     },                     {                        "id":1335,                        "name":"Мукаелян Марине Айковна",                        "share_image":"/elections-api-media/share/28/1335.png",                        "anticandidate":false,                        "self_nominated":true,                        "has_won":false,                        "has_second_round":false,                        "party":null                     },                     {                        "id":5385,                        "name":"Рябцев Виктор Александрович",                        "share_image":"/elections-api-media/share/28/5385.png",                        "anticandidate":false,                        "self_nominated":false,                        "has_won":false,                        "has_second_round":false,                        "party":{                           "title":"КПРФ",                           "antiparty":false                        }                     }                  ]               }            ]         },         "error":null,         "currentUrl":"https://votesmart.appspot.com/candidates/440384"      }   },   "page":"/candidates/[id]",   "query":{      "id":"440384"   },   "buildId":"U8hjaoxZw8TINu-DU_Ixw",   "runtimeConfig":{      "HOST":"https://votesmart.appspot.com"   },   "isFallback":false,   "customServer":true,   "gip":true}



Для избирательного участка указан номер (number) соответствующей УИК и её идентификатор в базе данных сайта УмГ. Id = 440834 соответствует номеру, который содержится в URL-адресе страницы (/candidates/440834).

Можем ли мы, зная номер УИК и регион, вычислить идентификатор комиссии на сайте УмГ? Я не смог найти очевидную зависимость, так как идентификаторы распределены достаточно хаотично:
Сочи, УИК 4512 -> id = 440834
Сочи, УИК 4513 -> id = 441403
Сочи, УИК 4514 -> id = 1781216

Каким образом собрать список отражений номеров УИК в id страниц? Перебирать и проверять всевозможные идентификаторы от 1 до 2000000 звучит крайне неэффективно, большинство из этих идентификаторов нерабочие.

Но, если у нас есть список адресов, мы можем относительно легко собрать список соответствующих избирательных участков. При вводе строки на начальном экране, с сервера возвращается перечень подходящих адресов вместе с соответствующими идентификаторами комиссий:

Поиск участка по адресу
https://votesmart.appspot.com/api/v1/cik/addresses?query=ADDRESS

  • ADDRESS адрес, желательно в формате Субъект, город, улица, дом. Также желательно без сокращений ул., д., так как парсер на сервере плохо с ними справляется

Пример запроса:
https://votesmart.appspot.com/api/v1/cik/addresses?query=Смоленск ленина

Результат запроса
{   "suggestions":[      {         "value":"Смоленская область, город Смоленск, Промышленный район, Ленина улица",         "data":{            "fullname":"Смоленская область, город Смоленск, Промышленный район, Ленина улица",            "level":"7",            "region_id":69,            "commission_id":null,            "intid":"138474570115456000000347353",            "path":"135637827259064320000359815,135637827259064320000359819,135637827259064320000359820,138474570115456000000347353",            "snippet":"Смоленская область, город <em>Смоленск</em>, Промышленный район, <em>Ленина</em> улица",            "score":118.84238         }      },      {         "value":"Смоленская область, город Смоленск, Ленинский район, Ленина улица, 12А",         "data":{            "fullname":"Смоленская область, город Смоленск, Ленинский район, Ленина улица, 12А",            "level":"8",            "region_id":69,            "commission_id":1124357,            "intid":"135659820348349440000359937",            "path":"135637827259064320000359815,135637827259064320000359819,135637827259064320000359822,135659820348349440000359708,135659820348349440000359937",            "snippet":"Смоленская область, город <em>Смоленск</em>, Ленинский район, <em>Ленина</em> улица, 12А",            "score":115.14931         }      },...   ]}



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

На каждый избирательный округ приходится в среднем от 2 до 8 участков. Даже не смотря на то, что адрес избирательного участка, в редких случаях, может не соответствовать округу к которому он принадлежит, я выдвинул следующую гипотезу: перебрав адреса УИК на сайте УмГ, можно собрать информацию о каждом округе.

В дальнейшем, при помощи данной гипотезы мне удалось собралось информацию почти по всем избирательным округам. Из-за неоднородности формата адресов в базе данных избирательных комиссий, лишь адреса 10 округов из 1100 мне пришлось подбирать вручную.

В интернете можно найти регулярно обновляющуюся базу данных избирательных комиссий РФ, содержащую информацию об адресах и даже составах УИК. Но для большей актуальности и надежности данных (а также по причине того, что меня не устраивал формат определенного поля) я решил собрать список адресов сам, ведь, как оказалось, на сайте ЦИК имеется весь нужный для этого функционал.

Новый веб-сервиса ЦИК. Методы API


ГАС Выборы автоматизированная система, разработанная в 1995 году, предназначенная для подготовки и проведения выборов и референдумов в РФ.

Если вы когда-либо интересовались ходом выборной кампании, то наверняка сталкивались с данным сайтом, на котором публикуется основная информация из системы ГАС Выборы, в том числе ход подсчёта голосов, ещё до утверждения результатов выборов:

image

И если раньше для извлечения результатов выборов датамайнеры пользовались этим сайтом, в дни проведения Голосования по поправкам в Конституцию на сайте внезапно появилась капча. Капча очень настойчивая, появляется при переходе на каждую страницу сайта:

image


Как вы сами можете визуально оценить, капча конечно очень простая, и наверняка кто-то уже нашел способы её обходить. Я же, вместо того чтобы заняться машинным обучением, обратился к новому разделу на сайте ЦИК, о котором пока мало кто знает: Цифровые сервисы

image

Данный раздел появился как раз во время Голосования по поправкам и содержит в себе несколько веб-сервисов, которые через POST-запросы общаются с внутренним API для получения данных из системы ГАС Выборы. Пользователь Хабра уже обратил внимание на данный функционал. Рассмотрим же его подробнее.

Далее приведено описание основных запросов нового API, которые использовались в данном проекте:

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


Информация об УИК
http://cikrf.ru/iservices/voter-services/committee/subjcode/SUBJECT_CODE/num/COMMITTEE_NUM


Пример запроса:
http://cikrf.ru/iservices/voter-services/committee/subjcode/01/num/2

Результат запроса
{   "vrn":"4014001117979",   "name":"Участковая избирательная комиссия 2",   "subjCode":"01",   "numKsa":"01T001",   "vid":"5",   "address":{      "address":"385200, Республика Адыгея, городской округ Адыгейск, город Адыгейск, проспект имени В.И.Ленина, 16",      "descr":"здание МБОУ СОШ1",      "phone":"8-87772-9-23-72",      "lat":"44.882893",      "lon":"39.187187"   },   "votingAddress":{      "address":"385200, Республика Адыгея, городской округ Адыгейск, город Адыгейск, проспект имени В.И.Ленина, 16",      "descr":"здание МБОУ СОШ1",      "phone":"8-87772-9-23-72",      "lat":"44.882893",      "lon":"39.187187"   }}




Информация о выборных кампаниях на участке
http://cikrf.ru/iservices/voter-services/vibory/committee/COMMITTEE_VRN

  • COMMITTEE_VRN идентификатор УИК

Пример запроса:
http://cikrf.ru/iservices/voter-services/vibory/committee/4544028162533

Результат запроса
[   {      "vrn":"100100163596966",      "date":"2020-07-01",      "name":"Общероссийское голосование по вопросу одобрения изменений в Конституцию Российской Федерации",      "subjCode":"0",      "pronetvd":null,      "vidvibref":"0"   },   {      "vrn":"25420001876696",      "date":"2020-09-13",      "name":"Выборы депутатов Законодательного Собрания Новосибирской области седьмого созыва",      "subjCode":"54",      "pronetvd":"0",      "vidvibref":"2"   },   {      "vrn":"4544220183446",      "date":"2020-09-13",      "name":"Выборы депутатов Совета депутатов города Новосибирска седьмого созыва ",      "subjCode":"54",      "pronetvd":null,      "vidvibref":"2"   }]




Перечень округов выборной кампании
http://cikrf.ru/iservices/sgo-visual-rest/vibory/CAMPAIGN_VRN/tvd

  • CAMPAIGN_VRN идентификатор выборной кампании

Пример запроса:
http://cikrf.ru/iservices/sgo-visual-rest/vibory/457422069597/tvd

Результат запроса
{   "_embedded":{      "tvdDtoList":[         {            "vrn":457422069601,            "namtvd":"Муниципальная избирательная комиссия города Орла",            "namik":"Муниципальная избирательная комиссия города Орла",            "numtvd":"0",            "vidtvd":"ROOT",            "_links":{               "results":{                  "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/457422069597/results/457422069601/proportion"               }            }         },         {            "vrn":457422069602,            "namik":"Окружная избирательная комиссия  1",            "numtvd":"1",            "vidtvd":"OIK",            "_links":{               "results":{                  "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/457422069597/results/457422069602/major"               }            }         },         ...      ]   },   "_links":{      "self":{         "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/457422069597/tvd"      }   }}


NUMTVD номер округа. Нулевой номер обычно отвечает за результаты по единому округу. Например, если проходят выборы по смешанной системе, нулевой избирательный округ отвечает за голосование по пропорциональной системе. Остальные округа одномандатные, либо многомандатные.

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



Список кандидатов, участвующих в выборной кампании
http://cikrf.ru/iservices/sgo-visual-rest/vibory/CAMPAIGN_VRN/candidates/?page=PAGE_NUM&numokr=NUMTVD

  • CAMPAIGN_VRN идентификатор выборной кампании
  • PAGE_NUM номер страницы списка
  • NUMTVD номер округа (необязательный параметр)

Пример запроса:
http://cikrf.ru/iservices/sgo-visual-rest/vibory/4674220125616/candidates/?page=1&numokr=11

Результат запроса
{   "_embedded":{      "candidateDtoList":[         ...         {            "index":50,            "vrn":4674020270868,            "fio":"Трофименко Владимир Карпович",            "datroj":"23.04.1964 00:00:00",            "vidvig":"выдвинут",            "registr":"зарегистрирован",            "vrnio":4674220132098,            "namio":"Региональное отделение Политической партии \"Российская партия пенсионеров за социальную справедливость\" в Смоленской области",            "numokr":11,            "tekstat2":"1",            "_links":{               "self":{                  "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/4674220125616/candidates/4674020270868"               }            }         },         {            "index":56,            "vrn":4674020269642,            "fio":"Божедомов Евгений Эдуардович",            "datroj":"15.02.1986 00:00:00",            "vidvig":"выдвинут",            "registr":"отказ в регистрации",            "namio":"Самовыдвижение",            "numokr":11,            "tekstat2":"1",            "_links":{               "self":{                  "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/4674220125616/candidates/4674020269642"               }            }         },         {            "index":105,            "vrn":4674020271181,            "fio":"Трифоненко Владислав Андреевич",            "datroj":"15.07.1994 00:00:00",            "vidvig":"выдвинут",            "registr":"зарегистрирован",            "vrnio":4674220134054,            "namio":"Смоленское городское отделение политической партии \"КОММУНИСТИЧЕСКАЯ ПАРТИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ\"",            "numokr":11,            "tekstat2":"1",            "_links":{               "self":{                  "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/4674220125616/candidates/4674020271181"               }            }         },         ...               ]   },   "_links":{      "self":{         "href":"http://cikrf.ru/iservices/sgo-visual-rest/vibory/4674220125616/candidates?page=1&numokr=11"      }   },   "page":{      "size":20,      "totalElements":9,      "totalPages":1,      "number":1   }}


Структура page содержит общее количество страниц, по ней можно определить когда вы достигните последней страницы (либо по пустому списку, вернувшемуся с сервера).



API содержит и другие методы, в основном чтобы узнать дополнительную информацию о выборах/кандидатах. Если понадобится, вы легко можете отследить нужные запросы. А теперь, можно приступить к выгрузке данных.

Выгрузка данных с сайта ЦИК


Прежде чем приступить к скачиванию нужных данных, нужно было составить список выборных кампаний, которые мы задействуем в проекте. Дело в том, что Умное Голосование проходило не везде, а именно на выборах:
в законодательные собрания регионов,
в городские советы региональных центров,
в городские советы крупных городов (с населением больше 200 тысяч человек)
(А также довыборы в Госдуму по 4 округам).
// Леонид Волков

Довыборы в Госдуму я решил проигнорировать, из-за незначительности этих данных. Составить перечень выборов в местные советы помогла статья в Википедии о дне голосования, ведь в ней как раз были перечислены выборы в крупных городах.

Обратившись к товарищу, я попросил его вручную составить список URL-адресов для соответствующих выборных кампаний, взяв их с главной страницы классической системы ЦИК. Дело в том, что в URL-адресе содержится идентификаторы региона и кампании, которые как раз понадобятся нам для дальнейшего парсинга.

vybory.izbirkom.ru/region/izbirkom?action=show&vrn=21120001136916&
region=11&prver=1&pronetvd=1


В итоге, список состоял из 43 выборных кампаний. Всего в Единый день голосования прошло более 9000 отдельных выборных кампаний в органы разного уровня.

Теперь, имея на руках список выборов и перечисленные ранее методы API, скачать данные не составило никакого труда. Написав скрипт на python, делая обычные запросы про помощи requests модуля, я сохранил данные о кандидатах и избирательных участках в исходном JSON-формате.

Главное, что стоит учесть при скачивании информации об избирательных участках: недостаточно перебирать всевозможные номера начиная с 1, до тех пор пока сервер не вернет пустое значение. Дело в том, что нумерация УИК в регионе может прерываться, и идти, например, в таком виде:
...1001 1016, 1101 1136, 1138 ...
либо:
0 700, 900 1002, 1004...
Чтобы определить максимальный номер УИК в регионе и не делать лишние запросы, я собирал данные следующим образом: пробовал выгрузить данные по первым 1000 номерам, а затем проверял если i+1,i+5,i+100,i+500,i+1000 номера соответствуют какому-либо УИКу (в случае чего продолжал скачивание).

Также, рекомендую сохранять номер УИК, по которому вы скачали данные об участке. Дело в том, что возвращаемые данные не содержат номер УИК, а только название в виде: Участковая Избирательная Комиссия 100. Процесс получения исходного номера УИК, с которым мне позже пришлось столкнуться, привёл к кратковременным багам и фрустрации. Как оказалось, нумерация в названии УИК в некоторых регионах имеет разный формат.

К примеру, в Удмуртии в названии УИК была следующая нумерация: 1/01, 1/02, 1/03, в Липецкой области: 01-01, 01-02, 01-03. В Оренбургской области я столкнулся с настоящей экзотикой: это был единственный регион, где ряд избирательных комиссий были названы в честь кого-то. Например Участковая избирательная комиссия 1696 имени Братьев Пустовитовых

Выгрузка данных с сайта Умного Голосования


Теперь, по каждому собранному адресу УИК мы собираемся скачать данные о голосовании с сайта УмГ. Перед этим стоит учесть несколько особенностей (о которых я узнал уже в процессе):

Во первых, надо учесть что адреса в базе данных ЦИК имеют различный формат, порой даже в отдельных областях регионов. Мне пришлось убирать сокращения д., г. и ул., так как сайт Умного Голосования совсем не справлялся с поиском адресов по таким запросам. Ещё рекомендую убирать почтовый индекс из адреса, а также, встречающийся иногда префикс Российская Федерация.

Во вторых, сайт УмГ имеет жёсткую защиту от DDoS атак, и даже если вы сделаете сотню запросов с интервалом в 0.3 секунды ваш IP получит бан. Можно было бы использовать набор из платных прокси, но лично я просто воспользовался бесплатными прокси и чередовал запросы со своего и стороннего IP. Чтоб уж точно не получить бан, между запросами был интервал примерно в 0.7 секунд. В итоге, скачивание всех данных заняло примерно сутки.

С использованием запросов из первой главы, алгоритм получился следующим:
  1. Форматируем адрес УИК
  2. Делаем запрос на список подходящих адресов
  3. Получаем список, содержащий идентификаторы страниц сайта
  4. Проверяем если уже скачали данные об участке по данному идентификатору
  5. Загружаем HTML-страницу сайта по данному идентификатором
  6. Извлекаем элемент __NEXT_DATA__ и сохраняем данные в JSON-формате


Парсинг страницы происходил при помощи библиотеки beautifulsoup4.

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

Это не беда, ведь для каждого округа, нам достаточно найти хоть одну соответствующую страницу на сайте.

Для валидации полноты данных мы пишем простой скрипт, который проверяет если в скачанном с сайта УмГ набора данных содержится информация о каждом избирательном округе. Если чего-то не хватает пополняем набор вручную. Опять же, таких исключительных ситуаций было менее 10 на 1100 округов.

Объединение данных с сайтов УмГ и ЦИК


На данном этапе, мы собираем удобную структуру данных, с информацией о каждом кандидате по округам: идентификатор кандидата, ФИО, партия, метка с информацией о том, подержан ли он УмГ.

Пример собранного набора данных о кандидатах
{    "33": [        {            "name": "Бекенева Любовь Александровна",            "vrn": 4444032121758,            "birthdate": "05.05.1958 00:00:00",            "party": "ЕР",            "smart_vote": 0        },        {            "name": "Крохичев Павел Александрович",            "vrn": 4444032122449,            "birthdate": "16.11.1977 00:00:00",            "party": "КПРФ",            "smart_vote": 0        },        {            "name": "Ростовцев Михаил Павлович",            "vrn": 4444032122782,            "birthdate": "27.02.1996 00:00:00",            "party": "ЛДПР",            "smart_vote": 0        },        {            "name": "Морозов Максим Сергеевич",            "vrn": 4444032123815,            "birthdate": "20.11.1991 00:00:00",            "party": "Яблоко",            "smart_vote": 1        },        {            "name": "Захарова Алина Сергеевна",            "vrn": 4444032124060,            "birthdate": "21.07.1996 00:00:00",            "party": "КПКР",            "smart_vote": 0        },        {            "name": "Афанасов Александр Николаевич",            "vrn": 4444032123597,            "birthdate": "21.05.1974 00:00:00",            "party": "СР",            "smart_vote": 0        }    ],    ...}



Алгоритм достаточно прямолинейный:
  1. По массиву данных с сайта УмГ создаем список поддержанных кандидатов для каждого округа
  2. По массиву данных с сайта ЦИК создаем отфильтрованный список допущенных кандидатов для каждого округа
  3. В каждом округе по ФИО вычисляем соответствие Кандидат-УмГКандидат-ЦИК

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

Во первых, есть шанс что в одном округе будут кандидаты с полностью совпадающими ФИО. Благо, среди 5000 кандидатов, такая ситуация была лишь в одном случае, причём ни один из кандидатов не был поддержан УмГ.

Во вторых, надо учесть, что в базе данных сайта ЦИКа могут быть ошибки. Самая частая ошибка: переносы строк и лишние пробелы в ФИО. Также, при сборе данных об итогах голосования попадалась ситуация, при которых буква ё в фамилии заменялась на е.

В третьих, надо учитывать актуальность данных. Данные на сайте ЦИКа и УмГ изменялись и обновлялись вплоть до субботы: каких-то кандидатов снимали/восстанавливали, в каких-то округах менялась поддержка УмГ.
Для валидации списков УмГ был написан простой скрипт, который делает по одному запросу на округ (ведь собранный нами набор данных теперь позволяет однозначно определить страницу, посвященную каждому округу) и проверяет соответствуют ли имена тем, что мы получали ранее.

Интересной задачей была идентификация партий по названию их отделений. Данный пункт можно было бы пропустить, но я решил заняться этим для унификации информации. Проблема заключается в том, что у кандидатов от одной партии может различаться её название в базе ЦИК. Например, в случае КПРФ встречалось более 40 вариантов:

Ивановское городское (местное) отделение Политической партии "Коммунистическая партия Российской Федерации"
Ямало-Ненецкое ОО ПП "КПРФ"
ЧОО ПП КПРФ
КАЛУЖСКОЕ РЕГИОНАЛЬНОЕ ОТДЕЛЕНИЕ политической партии "КОММУНИСТИЧЕСКАЯ ПАРТИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ"
...


Ситуация превращается в интересную задачу по синтаксическому анализу, когда партий 25 штук и почти у каждой разное написание каждом регионе. Благо, про помощи моего товарища, который помогал мне со всей ручной работой, мы составили список ключевых слов, по которым однозначно определяется партия кандидата.

Выгрузка результатов выборов с сайта ЦИК


Собранного набора данных хватило для достижения первоначальной цели проекта мы составили списки кандидатов УМГ-2020 для каждого избирательного округа. Но если есть техническая возможность получить результаты выборов, почему бы не воспользоваться ею?


Результаты выборов в округе
http://cikrf.ru/iservices/sgo-visual-rest/vibory/CAMPAIGN_VRN/results/DISTRICT_VRN/major

  • CAMPAIGN_VRN идентификатор выборной кампании
  • DISTRICT_VRN идентификатор округа

Пример запроса:
http://cikrf.ru/iservices/sgo-visual-rest/vibory/457422069597/results/457422069602/major

Результат запроса
{   "report":{      "tvd":"",      "date_sign":"none",      "vrnvibref":"457422069597",      "line":[         {            "txt":"число избирателей на момент окончания голосования",            "kolza":"8488",            "index":"1"         },         {            "txt":"число бюллетеней, полученных участковой комиссией",            "kolza":"6700",            "index":"2"         },         ...         {            "txt":"число недействительных бюллетеней",            "kolza":"65",            "index":"9"         },         {            "txt":"число действительных бюллетеней",            "kolza":"1948",            "index":"10"         },         ...         {            "delimetr":"1"         },         {            "txt":"Авдеев Максим Юрьевич",            "numsved":"1",            "kolza":"112",            "index":"11",            "namio":"ПАРТИЯ ПЕНСИОНЕРОВ в Орловской области",            "perza":"5.56",            "numsvreestr":"4574030258379"         },         {            "txt":"Жуков Александр Александрович",            "numsved":"2",            "kolza":"186",            "index":"12",            "namio":"Орловское региональное отделение Партии СПРАВЕДЛИВАЯ РОССИЯ",            "perza":"9.24",            "numsvreestr":"4574030258723"         },         {            "txt":"Жуков Родион Вячеславович",            "numsved":"3",            "kolza":"54",            "index":"13",            "namio":"Самовыдвижение",            "perza":"2.68",            "numsvreestr":"4574030258555"         },         ...      ],      "data_gol":"13.09.2020 00:00:00",      "is_uik":"0",      "type":"423",      "version":"0",      "sgo_version":"5.6.0",      "isplann":"0",      "podpisano":"1",      "versions":{         "ver":{            "current":"true",            "content":"0"         }      },      "vibory":"Выборы депутатов Орловского городского Совета народных депутатов шестого созыва",      "repforms":"1",      "generation_time":"14.09.2020 07:59:21",      "nazv":"Результаты выборов по одномандатному (многомандатному) округу",      "datepodp":"14.09.2020 05:44:00"   }}


Как видите, результаты возвращаются в виде протокола областной комиссии. В каждом регионе различается формат протокола и количество вступительных строк в нём, поэтому надо проводить внимательную валидацию извлечённых вами данных.



Когда в ГАС Выборы начали публиковать предварительные результаты, я столкнулся с небольшим разочарованием. Оказалось, что через API можно получить данные только по тем результатам, которые официально утвердили. С предварительными результатами всё ещё можно ознакомиться на старом сайте избиркома, но нельзя через новые веб-сервисы.

Спустя сутки были известны результаты по 50%, а к концу недели были подведены итоги почти всех выборов, некоторые регионы всё ещё отказывались утверждать результаты. На момент написания статьи, прошло уже 7 дней, а результаты выборов в Тамбове всё ещё не утверждены. К тому же, в некоторых округах происходит пересчёт голосов, из-за чего эти результаты также недоступны через API.

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

Мне же надоело ждать когда в ~30 округах из 1100 утвердят выборы, поэтому я написал скрипт, при помощи selenium библиотеки, который выгружает данные с классического сайта избиркома и просит меня вручную решить капчу при каждом запросе. С таким небольшим числом запросов, вручную решать капчу не занимает много времени.

В результате, данные об итогах голосования я собрал в следующую структуру:

Пример результатов голосования в округе
{..."33": {        "candidate_total": {            "4444032121758": 880,            "4444032122449": 236,            "4444032122782": 143,            "4444032123597": 152,            "4444032123815": 149,            "4444032124060": 72        },        "is_final": 1,        "non_valid_votes": 132,        "registered_voters": 6928,        "valid_votes": 1632    },...}



Для каждого округа я сохранил суммарное число избирателей в списках (для подсчёта явки), число действительных и недействительных бюллетеней. В структуре содержится словарь: Идентификатор кандидата -> Набранное им число голосов.

Публикация итогов УмГ-2020


Во первых, собранные данные в JSON-формате я опубликовал на GitHub. Данные будут обновляться, пока результаты не утвердят во всех округах.

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

Вдаваться в подробности не буду, никаких сложностей (кроме изучения Google Sheets API) возникнуть не должно. Очень помогла данная статья, в которой подробно рассказано взаимодействие с Google Sheets API на Python.

image

В итоге получилась такая таблица, в которой собраны:



Послесловие


Идея данного мини-проекта возникла за 3 дня до дня голосования и лично я доволен тем, как успел изучить и реализовать всё в кратчайшие сроки (хотя код получился ужасным).

Я не собираюсь делать какие-либо выводы об итогах стратегии Умного Голосования, я лишь предоставил инструменты для любителей электоральной статистики. Уверен, среди вас найдутся таковые и скоро мы увидим замечательные исследования, с интересными графиками и диаграммами :)

Подробнее..

Перевод HTTPWTF. Необычное в обычном протоколе

20.04.2021 12:16:22 | Автор: admin

Прим. перев.: эту статью написал автор Open Source-утилиты HTTP Toolkit, предназначенной для исследования и модификации HTTP(S)-трафика для нужд отладки и тестирования. В материале собраны примечательные особенности стандарта HTTP, которые долгие годы живут вместе с нами, однако не каждый догадывается об их существовании.

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

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

No-cache на самом деле означает кэшируй

Кэширование никогда не было легким занятием, но кэш-заголовки HTTP в этом смысле особенно преуспели. Худшие примеры no-cache и private. Как вы думаете, что делает приведенный ниже HTTP-заголовок ответа?

Cache-Control: private, no-cache

Нигде не храни этот ответ, так? Ха-ха-ха, а вот и нет!

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

В частности, no-cache означает, что контент обязательно кэшируется, но всякий раз, когда браузер или CDN хотят его использовать, они должны отправить запрос с If-Match или If-Modified-Since, спросив сначала у сервера, актуален ли кэш. Между тем private означает, что контент можно кэшировать, но только в браузерах конечных пользователей, а не в CDN или на прокси-серверах.

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

Если послать ответ с заголовком Cache-Control: no-store, его никто не будет кэшировать, и каждый раз он будет поступать прямо с сервера. Единственный нюанс связан с тем, что клиент уже может хранить ответ в кэше в этом случае, он не будет удален. Чтобы удалить существующий кэш, добавьте к заголовку max-age=0.

Примечательно, что Twitter уже наступал на эти грабли. Они использовали Pragma: no-cache (устаревшую версию того же самого заголовка) вместо Cache-Control: no-store, в результате чего личные сообщения (DM) пользователей оставались в кэшах их браузеров. В случае личного компьютера это не проблема, но если к ПК имеют доступ несколько пользователей, или вы воспользовались публичной машиной, то ваши личные сообщения остались на жестком диске в незашифрованном и доступном для чтения виде. Упс.

HTTP Trailers

Вероятно, вы уже знаете об HTTP-заголовках (headers). HTTP-сообщение начинается с первой строки, которая содержит метод и URL (для запросов) или код состояния/сообщение (для ответов), затем идет ряд пар ключ/значение для метаданных, называемых заголовками (headers), а затем идет тело (body).

Но знаете ли вы, что trailer'ы позволяют добавлять метаданные после тела сообщения?

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

Они применяются в некоторых API-протоколах вроде gRPC и больше всего подходят для метаданных о самом ответе. Например, с помощью trailer'ов можно включать метаданные Server-Timing, чтобы дать клиенту метрики о производительности сервера во время запроса. В этом случае они будут добавляться после полной готовности ответа. Trailer'ы особенно полезны в случае затяжных ответов, например, чтобы включить метаданные о конечном статусе после продолжительного HTTP-потока.

Они редко используются, но все же приятно, что такой инструмент есть, и он работает. Правда, есть несколько требований:

  • Для trailer'ов в ответе сервера клиент должен объявить об их поддержке с помощью заголовка TE: trailers в первоначальном запросе.

  • Заголовки исходного запроса должны включать поля trailer'ов, которые будут использоваться впоследствии: Trailer: <field names>.

  • Некоторые заголовки нельзя использовать в trailer'ах, в том числе Content-Length, Cache-Control, Authorization, Host и другие стандартные заголовки, которые необходимы для парсинга, аутентификации или маршрутизации запросов.

Для отправки трейлеров в HTTP/1.1 также потребуется кодировка chunked. В свою очередь, HTTP/2 использует отдельные фреймы для тела и заголовков, так что в этом нет необходимости.

Полный ответ с trailer'ами по HTTP/1.1 может выглядеть следующим образом:

HTTP/1.1 200 OKTransfer-Encoding: chunkedTrailer: My-Trailer-Field[...chunked response body...]My-Trailer-Field: some-extra-metadata

Коды HTTP 1XX

Знаете ли вы, что HTTP-запрос может получать несколько кодов состояния ответа? Сервер может отправлять неограниченное число кодов 1ХХ перед конечным статусом (200, 404 или любым другим). Они выполняют функцию промежуточных ответов и могут включать свои собственные независимые заголовки.

Семейство 1ХХ включает в себя следующие коды: 100, 101, 102, и 103. Они редко используются, но незаменимы в некоторых нишевых сценариях:

HTTP 100

HTTP 100 это ответ сервера о том, что запрос на данный момент в порядке и клиент может продолжать.

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

Он становится полезен в случае, если запрос включает заголовок Expect: 100-continue. Этот заголовок сообщает серверу, что клиент ожидает код 100 и что полное тело запроса не будет отправлено, пока этот код не получен.

Отправка Expect: 100-continue позволяет серверу решить, следует ли получать все тело сообщения (что может занять продолжительное время и съесть массу трафика). Если URL-адреса и заголовков достаточно для того, чтобы отправить ответ (например, отклонить загрузку файла), HTTP 100 быстрый и эффективный способ сделать это. Если сервер действительно хочет получить полное тело, он отправляет промежуточный ответ 100, после чего клиент продолжает пересылку. После завершения процесса передачи запрос обрабатывается как обычный.

HTTP 101

HTTP 101 используется для переключения протоколов. Он означает: Я послал тебе URL и заголовки, а теперь хочу сделать с этим соединением нечто совершенно другое. А именно переключиться на совершенно другой протокол.

Этот код преимущественно используется для организации веб-сокета. Клиент посылает запрос, содержащий следующие два заголовка:

Connection: upgradeUpgrade: websocket

Если сервер согласен, он посылает следующий ответ:

HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: upgrade

После этого обе стороны переходят с HTTP на обмен raw-данными веб-сокета по данному соединению.

Статус 101 также используется для перехода с HTTP/1.1 на HTTP/2 на том же соединении. Также его можно использовать для переключения HTTP-соединений на любые другие протоколы на основе TCP.

Следует отметить, что HTTP/2 не поддерживает данный статус: в нем иной механизм согласования протоколов и абсолютно другой подход к организации веб-сокетов (который практически нигде не поддерживается в настоящее время веб-сокеты всегда базируются на HTTP/1.1).

HTTP 102

HTTP 102 сообщает клиенту, что сервер все еще обрабатывает запрос и ответ скоро будет готов. От ситуации с кодом 100 он отличается тем, что запрос в данном случае был получен полностью и сервер его обрабатывает, а клиент просто ждет.

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

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

HTTP 103

В отличие от остальных кодов семейства, HTTP 103 новый (и модный) статус, предназначенный для частичной замены push-функционала серверов в HTTP/2 (который в настоящее время удаляется из Chrome).

В рамках HTTP 103 сервер может отправить некоторые заголовки заранее до того, как полностью обработает запрос и отправит его. В первую очередь он предназначен для доставки заголовков со ссылками, таких как Link: </style.css>; rel=preload; as=style, тем самым давая клиенту знать о дополнительном контенте (вроде таблиц стилей, JS-скриптов и изображений в запрашиваемых веб-страницах), который можно начать загружать одновременно с полным ответом.

Когда сервер получает запрос, обработка которого занимает некоторое время, он часто не может полностью отправить заголовки ответа до окончания его подготовки. HTTP 103 позволяет серверу незамедлительно передать клиенту список ресурсов для загрузки, не дожидаясь окончания обработки запроса.

Referer

HTTP-заголовок Referer сообщает серверу, с какой страницы был осуществлен переход или какой URL-адрес вызвал загрузку ресурса. Этот заголовок используется практически повсеместно, хотя у него и есть некоторые проблемы с конфиденциальностью.

Примечательная черта referer его неправильное написание. Он появился на заре Интернета, и тогдашняя проверка орфографии Unix не смогла отличить referer от referrer (правильного написания). К моменту, когда на это обратили внимание, заголовок активно использовался в инфраструктуре и инструментах по всему миру, так что изменить его уже не представлялось возможным, и теперь нам приходится жить с заголовком, написанным с ошибкой.

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

Чтобы жизнь не казалась медом, новые заголовки конфиденциальности/безопасности, связанные с этим, такие как Referrer-Policy, используют правильное написание.

Случайный UUID веб-сокетов

XKCD в тему. Комментарий в коде гласит: Получено подбрасыванием кости. Это гарантированно случайное значениеXKCD в тему. Комментарий в коде гласит: Получено подбрасыванием кости. Это гарантированно случайное значение

Ранее рассказывалось о том, как запросы HTTP 101 используются для организации веб-сокетов. Полный такой запрос может выглядеть так:

GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: upgradeSec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==Sec-WebSocket-Protocol: chat, superchatSec-WebSocket-Version: 13Origin: http://example.com

а ответ, запускающий соединение по веб-сокету, следующим образом:

HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: upgradeSec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=Sec-WebSocket-Protocol: chat

Особый интерес здесь вызывает ключSec-WebSocket-Accept. Он предотвращает случайное использование кэширующими прокси websocket-ответов, которые те не понимают, требуя, чтобы ответ включал заголовок, соответствующий заголовку клиента. А именно:

  • Сервер получает от клиента ключ веб-сокета, закодированный в base64;

  • Сервер добавляет к нему UUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11;

  • Сервер хэширует полученную строку, кодирует хэш в base64 и отправляет его обратно.

Это очень странно. Один неизменный случайный UUID, который используется в организации каждого веб-сокета? Добавление строк к строкам, закодированным в base64, без их предварительного декодирования, с последующим кодированием результата в base64?

Смысл в том, чтобы сделать процесс достаточно уникальным и неслучайным, гарантируя, что обе стороны осознанно и намеренно запускают соединение по веб-сокету. Это предотвращает случайное использование кэшированных данных сервером или прокси, и гарантирует, что клиент не был вовлечен в организацию websocket-соединения обманным путем.

Подобный подход отлично работает. Он широко применяется, а его реализация проста и доступна (что отлично), но все же странно, что каждое websocket-соединение в мире основано на одном магическом UUID.

Веб-сокеты и CORS

Коль скоро речь зашла о веб-сокетах: знали ли вы, что они игнорируют все политики CORS и single-origin, которые обычно применяются к HTTP-запросам?

CORS гарантирует, что JavaScript на a.com не может считывать данные с b.com, если только последний явно не разрешает это в своих заголовках ответа.

Это важно по многим причинам. Например, актуально для серверов в локальной сети (публичная веб-страница не должна иметь доступа к маршрутизатору) и состояния браузера (запросы от одного домена не должны иметь доступа к файлам cookies от другого).

К сожалению, веб-сокеты полностью игнорируют CORS, вместо этого предполагая, что все websocket-серверы достаточно современны и продвинуты, чтобы самостоятельно проверять заголовок Origin. Но серверы, как правило, этого не умеют, а многие разработчики понятия об этом не имели, пока я им не рассказал.

Это открывает целый новых мир любопытных уязвимостей, обобщенных в этой прекрасной статье.

Короче говоря, при использовании WebSocket API, проверяйте заголовок Origin и/или используйте токены CSRF, прежде чем доверять входящим соединениям.

Заголовки X-*

Давным-давно (в 1982-м) в RFC было заявлено, что использование префикса X- для заголовков сообщений отличный способ отличить кастомные расширения от стандартизированных имен.

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

Паттерн распространен до сих пор часто его можно встретить в HTTP-запросах:

  • X-Shenanigans: none встречается в каждом ответе от API Twilio. Понятия не имею, почему, но приятно знать, что на этот раз никаких махинаций точно не будет.

  • X-Clacks-Overhead: GNU Terry Pratchett дань уважения Терри Пратчетту; название заимствовано из серии книг писателя Плоский мир.

  • X-Requested-With: XMLHttpRequest добавляется различными JS-фреймворками, включая jQuery, чтобы четко отличать AJAX-запросы от запросов ресурсов (они не могут включать кастомные заголовки вроде этого).

  • X-Recruiting: <сообщение-приманка для потенциального сотрудника> многие компании используют подобные заголовки в попытке привлечь специалистов, которые настолько увлечены процессом, что читают заголовки HTTP.

  • X-Powered-By: <фреймворк> рекламирует фреймворк, используемый сервером (или соответствующую технологию). Как правило, это плохая затея.

  • X-Http-Method-Override указывает метод, который по какой-либо причине не может использоваться в качестве метода для запроса (обычно это связано с ограничениями клиента/сети). Плохая идея в наши дни, однако она до сих пор популярна и многие фреймворки ее поддерживают.

  • X-Forwarded-For: <ip> используется многими прокси-серверами и балансировщиками нагрузки для включения исходного IP-адреса запроса в upstream-запросы.

Каждый из этих заголовков по-своему странен и прекрасен, но сам подход нельзя назвать хорошим, поэтому в новых RFC (2011) его применение формально не рекомендуется.

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

Это крайне неприятно, и некоторые формальные стандарты уже пострадали от этого:

  • Почти все веб-формы в Интернете пересылают данные, используя излишне мудреный и пространный заголовок Content-Type: application/x-www-form-url-encoded.

  • В RFC к HTTP от 1997 года в разделе, где определяются правила парсинга для content-encoding, предписывается, что все реализации считали x-gzip и x-compress эквивалентами gzip и compress соответственно.

  • Стандартным заголовком для настройки фреймов на веб-странице теперь навсегда останетсяX-Frame-Options вместо Frame-Options.

  • Также у нас теперь есть X-Content-Type-Options, X-DNS-Prefetch-Control, X-XSS-Protection и различные заголовки X-Forwarded-* от CDN/прокси. Все они широко используются и уже формально или фактически стали стандартными заголовками для повсеместного применения.

Если необходимо использовать кастомный заголовок, достаточно выбрать имя, не занятое каким-либо стандартом. Чтобы избежать любых возможных коллизий, рассмотрите возможность сделать его уникальным, включив в некое пространство имен (namespacing).


Стандартизация непростое занятие, и HTTP полон нелепых сюрпризов и странных деталей (стоит только пристально на него посмотреть). Жду ваших мыслей/замечаний в Twitter.

P.S. от переводчика

Подробнее..

Как скачать файл порциями?

08.07.2020 00:21:16 | Автор: admin

Иногда требуется скачивать файл порциями. Причины бывают разные, например слишком большой объем файла, ширина канала не достаточна или сервер ограничивает объем данных для скачивания.

В этой статье опишу каким образом реализовать скачивание файла небольшими порциями на языке Java по протоколу HTTP.

Об HTTP


Для таких целей HTTP предоставляет заголовок Range для запроса. В котором указывается диапазон байтов для скачивания. Заголовок Range относится только к телу запроса, заголовки сюда не входят.

Спецификация определяет следующие форматы указания значений заголовка:

Range: bytes=first-byte-pos "-" [last-byte-pos]


first-byte-pos начальное смещение байта с которого необходимо начать (продолжить) скачивание, оно должно быть больше либо равно 0, и меньше либо равно last-byte-pos;

last-byte-pos конечное смещение байта до которого необходимо скачать файл, оно должно быть больше либо равно first-byte-pos и при этом меньше либо равно скачиваемому размеру файла минус один (потому что это смещение, то есть индекс в массиве байтов).

Примеры


Исключительно по указанному диапазону

bytes=0-255

bytes=256-512

Скачать начиная с позиции first-byte-pos до конца

Range: bytes=first-byte-pos "-"

bytes=512-

Скачать last-byte-pos с конца

Range: bytes="-"last-byte-pos

bytes=-32

На подобный запрос сервер в ответ пришлёт два возможных статуса


  • 206 Partial Content файл успешно скачан частично;
  • 416 Range Not Satisfiable неудовлетворительный диапазон для скачивания.

Конечно же ответов может быть больше. В контексте статьи они нас не интересуют.

И заголовок Content-Range в котором указан запрошенный диапазон и общий размер.

Content-Range: bytes 256-512/1024

Этот заголовок сообщает что пришёл ответ на запрос с 256-512 позиции в массиве байтов из 1024 байтов.

Реализация на Java 14


В качестве HTTP клиента возьмем стандартный из JDK, доступный с Java 11 java.net.http.HttpClient.

Для реализации логики выполнения запроса по порциям, напишем класс обёртку art.aukhatov.http.WebClient.

Опишем интерфейс этого класса

  • byte[] download(String uri, int chunkSize) скачивает файл по указанным порциям байтов;
  • Response download(String uri, int firstBytePos, int lastBytePos) скачивает файл по указанному диапазону.

В случае если переданный URI не валидный, то метод бросает исключение java.net.URISyntaxException. Исключение java.io.IOException бросается если какая-либо неожиданная ошибка с вводом/выводом.

Классы WebClient и Response


package art.aukhatov.http;import java.io.BufferedInputStream;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.time.Duration;public class WebClient {     private final HttpClient httpClient;     public WebClient() {         this.httpClient = HttpClient.newBuilder()              .connectTimeout(Duration.ofSeconds(10))              .build();     }     public static class Response {          final BufferedInputStream inputStream;          final int status;          final HttpHeaders headers;          public Response(BufferedInputStream inputStream, int status, HttpHeaders headers) {               this.inputStream = inputStream;               this.status = status;               this.headers = headers;          }     }}

В качестве представления ответа опишем nested class WebClient.Response с полями BufferedInputStream, HTTP Status, HTTP Header. Эти данные необходимы для формирования результирующего массива байтов и понимания продолжать скачивать или нет.

Метод Response download(final String uri, int firstBytePos, int lastBytePos)


import java.io.BufferedInputStream;import java.io.IOException;import java.io.InputStream;import java.net.URI;import java.net.URISyntaxException;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.net.http.HttpRequest;import java.net.http.HttpResponse;private static final String HEADER_RANGE = "Range";private static final String RANGE_FORMAT = "bytes=%d-%d";public Response download(final String uri, int firstBytePos, int lastBytePos)    throws URISyntaxException, IOException, InterruptedException {    HttpRequest request = HttpRequest            .newBuilder(new URI(uri))            .header(HEADER_RANGE, format(RANGE_FORMAT, firstBytePos, lastBytePos))            .GET()            .version(HttpClient.Version.HTTP_2)            .build();    HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());    return new Response(new BufferedInputStream(response.body()), response.statusCode(), response.headers());}

Этот метод скачивает указанный диапазон данных. Но прежде чем начать нам нужно знать сколько всего данных нам надо ожидать. Для этого необходимо сделать запрос без получения контента. Воспользуемся методом HEAD.

Метод long contentLength(final String uri)


import java.util.OptionalLong;private static final String HTTP_HEAD = "HEAD";private static final String HEADER_CONTENT_LENGTH = "content-length";private long contentLength(final String uri)throws URISyntaxException, IOException, InterruptedException {    HttpRequest headRequest = HttpRequest            .newBuilder(new URI(uri))            .method(HTTP_HEAD, HttpRequest.BodyPublishers.noBody())            .version(HttpClient.Version.HTTP_2)            .build();    HttpResponse<String> httpResponse = httpClient.send(headRequest, HttpResponse.BodyHandlers.ofString());    OptionalLong contentLength = httpResponse            .headers().firstValueAsLong(HEADER_CONTENT_LENGTH);    return contentLength.orElse(0L);}

Теперь у нас есть ожидаемая длина файла в байтах.

Метод byte[] download(final String uri, int chunkSize)


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

Определим размер файла

final int expectedLength = (int) contentLength(uri);

Начальное смещение

int firstBytePos = 0;

Конечное смещение

int lastBytePos = chunkSize - 1;

Данные

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

byte[] downloadedBytes = new byte[expectedLength];

Размер скачанных данных

Дополнительно к самому массиву необходимо определять суммарно сколько данных скачано.

Поэтому эту длину будем считать отдельно.

int downloadedLength = 0;

Цикл скачивания


Условие цикла простое: продолжаем скачивать пока не достигнем ожидаемого размера. После того как успешно скачали очередную порцию данных, необходимо его прочитать и сохранить в результирующий массив, воспользуемся системным методом копирования массива System.arraycopy(). Затем нужно увеличить количество прочитанных данных и следующий диапазон скачиваемых данных. При увеличении диапазона нужно быть осторожнее, нельзя выходить за пределы. Поэтому будем брать минимальное значение из Math.min(lastBytePos + chunkSize, expectedLength - 1).

private static final int HTTP_PARTIAL_CONTENT = 206;while (downloadedLength < expectedLength) {        Response response;        try {            response = download(uri, firstBytePos, lastBytePos);        }        try (response.inputStream) {            byte[] chunkedBytes = response.inputStream.readAllBytes();            downloadedLength += chunkedBytes.length;            if (isPartial(response)) {                System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length);                firstBytePos = lastBytePos + 1;                lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1);            }        }    }    return downloadedBytes;}private boolean isPartial(Response response) {    return response.status == HTTP_PARTIAL_CONTENT;}

На вид всё хорошо. Что не так?

Когда при скачивании или чтении что-то пойдет не так, броситься I/O исключение и скачивание прекратиться. Отсутствуют fallback. Давайте напишем простой fallback ввиде количества совершенных попыток.

Определим поле для веб-клиента содержащий максимальное количество допустимых попыток скачивания файла.

private int maxAttempts;public int maxAttempts() {    return maxAttempts;}public void setMaxAttempts(int maxAttempts) {    this.maxAttempts = maxAttempts;}

Будем ловить отдельно каждое исключение и инкрементировать локальный счетчик попыток. Цикл скачивания должен остановиться если количество совершенных попыток превышает допустимое. Поэтому дополним условие цикла.

private static final int DEFAULT_MAX_ATTEMPTS = 3;int attempts = 1;while (downloadedLength < expectedLength && attempts < maxAttempts) {    Response response;    try {        response = download(uri, firstBytePos, lastBytePos);    } catch (IOException e) {        attempts++;        continue;    }    try (response.inputStream) {        byte[] chunkedBytes = response.inputStream.readAllBytes();        downloadedLength += chunkedBytes.length;        if (isPartial(response)) {            System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length);            firstBytePos = lastBytePos + 1;            lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1);        }    } catch (IOException e) {        attempts++;        continue;    }    attempts = 1;}

Дополним метод еще логами. Окончательный вариант выглядит так:

package art.aukhatov.http;import java.io.BufferedInputStream;import java.io.IOException;import java.io.InputStream;import java.net.URI;import java.net.URISyntaxException;import java.net.http.HttpClient;import java.net.http.HttpHeaders;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.time.Duration;import java.util.OptionalLong;import static java.lang.String.format;import static java.lang.System.err;import static java.lang.System.out;public class WebClient {private static final String HEADER_RANGE = "Range";private static final String RANGE_FORMAT = "bytes=%d-%d";private static final String HEADER_CONTENT_LENGTH = "content-length";private static final String HTTP_HEAD = "HEAD";private static final int DEFAULT_MAX_ATTEMPTS = 3;private static final int HTTP_PARTIAL_CONTENT = 206;private final HttpClient httpClient;private int maxAttempts;public WebClient() {this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();this.maxAttempts = DEFAULT_MAX_ATTEMPTS;}public WebClient(HttpClient httpClient) {this.httpClient = httpClient;}private long contentLength(final String uri)throws URISyntaxException, IOException, InterruptedException {HttpRequest headRequest = HttpRequest.newBuilder(new URI(uri)).method(HTTP_HEAD, HttpRequest.BodyPublishers.noBody()).version(HttpClient.Version.HTTP_2).build();HttpResponse<String> httpResponse = httpClient.send(headRequest, HttpResponse.BodyHandlers.ofString());OptionalLong contentLength = httpResponse.headers().firstValueAsLong(HEADER_CONTENT_LENGTH);return contentLength.orElse(0L);}public Response download(final String uri, int firstBytePos, int lastBytePos)throws URISyntaxException, IOException, InterruptedException {HttpRequest request = HttpRequest.newBuilder(new URI(uri)).header(HEADER_RANGE, format(RANGE_FORMAT, firstBytePos, lastBytePos)).GET().version(HttpClient.Version.HTTP_2).build();HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());return new Response(new BufferedInputStream(response.body()), response.statusCode(), response.headers());}public byte[] download(final String uri, int chunkSize)throws URISyntaxException, IOException, InterruptedException {final int expectedLength = (int) contentLength(uri);int firstBytePos = 0;int lastBytePos = chunkSize - 1;byte[] downloadedBytes = new byte[expectedLength];int downloadedLength = 0;int attempts = 1;while (downloadedLength < expectedLength && attempts < maxAttempts) {Response response;try {response = download(uri, firstBytePos, lastBytePos);} catch (IOException e) {attempts++;err.println(format("I/O error has occurred. %s", e));out.println(format("Going to do %d attempt", attempts));continue;}try (response.inputStream) {byte[] chunkedBytes = response.inputStream.readAllBytes();downloadedLength += chunkedBytes.length;if (isPartial(response)) {System.arraycopy(chunkedBytes, 0, downloadedBytes, firstBytePos, chunkedBytes.length);firstBytePos = lastBytePos + 1;lastBytePos = Math.min(lastBytePos + chunkSize, expectedLength - 1);}} catch (IOException e) {attempts++;err.println(format("I/O error has occurred. %s", e));out.println(format("Going to do %d attempt", attempts));continue;}attempts = 1; // reset attempts counter}if (attempts >= maxAttempts) {err.println("A file could not be downloaded. Number of attempts are exceeded.");}return downloadedBytes;}private boolean isPartial(Response response) {return response.status == HTTP_PARTIAL_CONTENT;}public int maxAttempts() {return maxAttempts;}public void setMaxAttempts(int maxAttempts) {this.maxAttempts = maxAttempts;}public static class Response {final BufferedInputStream inputStream;final int status;final HttpHeaders headers;public Response(BufferedInputStream inputStream, int status, HttpHeaders headers) {this.inputStream = inputStream;this.status = status;this.headers = headers;}}}

Тестирование


Теперь можем написать тест на Junit 5 для проверки скачивания файла. Для примера возьмем рандомный файл в Интернете из доступных без аутентификации: file-examples.com/wp-content/uploads/2017/10/file-example_PDF_1MB.pdf

Сохраним файл во временную директорию. И проверим размер файла.

class WebClientTest {@Testvoid downloadByChunk() throws IOException, URISyntaxException, InterruptedException {WebClient fd = new WebClient();byte[] data = fd.download("https://file-examples.com/wp-content/uploads/2017/10/file-example_PDF_1MB.pdf", 262_144);final String downloadedFilePath = System.getProperty("java.io.tmpdir") + "sample.pdf";System.out.println("File has downloaded to " + downloadedFilePath);Path path = Paths.get(downloadedFilePath);try (OutputStream outputStream = Files.newOutputStream(path)) {outputStream.write(data);outputStream.flush();assertEquals(1_042_157, Files.readAllBytes(Paths.get(downloadedFilePath)).length);Files.delete(path);}}}

Заключение


В этой статье было рассмотрено каким образом реализовать скачивание файла заранее заданными порциями. Для большей гибкости можно подумать о динамическом размере порций, который расширяется и сужается в зависимости от поведения сервера. Также до конца не покрыты возможные исключения, которые можно обработать иначе. Например ошибка 401 Unauthorized или 500 Internal Server Error.

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

Полезные материалы для разработчика

19.03.2021 12:22:16 | Автор: admin

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

Выпускнику и преподавателю Computer Science Center, Равилю Галееву, пришла идея собрать такие инструменты и технологии в один курс и познакомить студентов с ними. За пример такого курса были взяты The Missing Semester of Your CS Education от MIT, Software Carpentry и cs50.

В этом посте мы собрали видеолекции курса Практический минимум и материалы к занятиям. Благодарим Равиля за подборку!

Содержание

Введение в Linux

Командная строка Linux

Система контроля версий git

Языки разметки и XML

Регулярные выражения

Взаимодействие с сетью

Протокол HTTP

Контейнеризация

Архитектура приложений

Тестирование приложений

Опасность в приложениях

Билд-системы

Кодировки, даты, локали

Дебаг

Набор в Computer Science Center 2021

Введение в Linux

  • Буквально пара слов о том, что такое ядро

  • Набор исторических фактов (от Unix к Linux)

  • Файловая система

  • Пользователи

  • Файлы

  • Процессы

  • Unix way

Слайды

Статьи

Wikipedia History of Unix

Книги

Видео

Курсы

Командная строка Linux

  • bash как REPL

  • Unix way

  • Шебанг

  • make

Слайды

Статьи

Книги

Ian Miell Learn Bash the Hard Way

Видео

Слайды/Презентации

Bash-скрипты из реального мира

Система контроля версий git

  • git

    • commit

    • branch

    • merge

  • git flow

  • github

Слайды

Статьи

Книги

  • Scott Chacon and Ben Straub Pro Git

Видео

Потренироваться

Языки разметки и XML

  • groff

  • LaTex

  • XML, JSON, YAML

  • Markdown, AsciiDoc

  • GraphViz, PlantUML

Слайды

Статьи

Книги

К. В. Воронцов LATEX в примерах

Видео

Слайды и другие материалы

Markdown cheatsheets

Разное

Регулярные выражения

  • Регулярки

  • grep

  • sed

  • awk

Слайды

Статьи

Видео

Слайды и другие материалы

Взаимодействие с сетью

  • Разбираемся как работает посылка пакетов

  • Рассматриваем простейшие утилиты работы с сетью

  • Знакомимся с DNS, CDN, VPN и другими словами на три буквы

  • Пишем сервер на сокетах

Слайды

Материалы

Протокол HTTP

  • HTTP

  • REST

Слайды

Статьи

Видео

Разное

Контейнеризация

  • chroot

  • Docker

  • Docker compose

Слайды

Статьи

Видео

Курсы

Разное

Архитектура приложений

  • ООП

  • Паттерны

  • Многослойная архитектура

Слайды

Статьи

Книги

Курсы

Видео

Тестирование приложений

  • Тестирование

  • Логгирование

Слайды 1

Слайды 2

Статьи

Видео

Опасность в приложениях

  • Хеширование, контрольные суммы

  • Авторизация vs Аутентификация; JWT

  • Обмен ключами Диффи-Хеллман

  • RSA

  • TLS

  • Двухфакторная аутентификация

Слайды

Статьи

Видео

Книги

Билд-системы

  • от make к TravisCI

  • dockerhub

Слайды

Статьи

Видео

Разное

Anatomy of a Continuous Integration and Delivery (CICD) Pipeline

Кодировки, даты, локали

Разбираемся, почему /dev/random печатает краказябры

Слайды

Статьи

Видео

Дебаг

  • Исключения

  • Дебаг

Слайды

Статьи

Книги

Видео

Курсы

Кирилл Кринкин Основы программирования для Linux

Разное


Делитесь в комментариях своими рекомендациями материалов, которые пригодились вам.

Набор в Computer Science Center 2021

Помимо лекций и материалов студентам CS центра на курсе доступны домашние задания. Если вы хотите проходить ещё больше курсов по разным направлениям, приходите поступать.

CS центр это вечерние курсы по математике и программированию. Занятия проходят в Санкт-Петербурге и в Новосибирске. Жители других городов могут поступить на обучение в удалённом формате.

Чтобы поступить:

заполните анкету на сайте до 10 апреля,

решите задания онлайн-теста до 11 апреля,

участвуйте в онлайн-экзамене в конце апреля-начале мая,

пройдите собеседование в мае-июне.

Для тех, кто успешно справится со вступительными испытаниями, занятия начнутся в сентябре. Будьте готовы тратить на учёбу хотя бы 15 часов в неделю в течение двух или трёх лет. Если вы увлечены компьютерными науками или программированием, хотите развиваться в этих областях, любите учиться, то осваивать курсы и работать над проектами будет интересно и полезно.

Задать вопросы про набор можно в телеграм канале или по почте info@compscicenter.ru.

Подробнее..

Изучаю Scala Часть 5 Http Requests

09.12.2020 04:19:27 | Автор: admin
Привет хабр! Продолжаю изучать Scala. Большинство бекендов так или иначе интегрированы с другими и делают HTTP запросы. Так как я на стек Cats и http4s ориентирован то буду рассматривать и изучать именно его. Сделаю запросы с куками, телом в json и в form, c файлом, с хедерами. Тут Hirrolot мне скорее всего минус поставит. Хочу сказать что может быть кому-то кто тоже изучает Scala будет полезна эта статья. Да и меня написание таких статей мотивирует изучать дальше. Люблю тебя малой. Расти большой не будь лапшой. Я уверен из тебя получится просто отличный инженер или даже может быть ученый в области IT. Давненько меня тут не было. В общем штормило у меня на личном фронте. С начала мы встречались обнимались и целовались с Марго. Потом мы расстались. Потом я переживал из-за этого. Потом работы навалилось. Вот так примерно у меня последние месяцы прошли. Взгрустнул, выпил и решил я написать сюда. И так, начнем.

Содержание



Ссылки


  1. Исходники
  2. Образы docker image
  3. Tapir
  4. Http4s
  5. Fs2
  6. Doobie
  7. ScalaTest
  8. ScalaCheck
  9. ScalaTestPlusScalaCheck


Тестовый контроллер который будет отвечать на наши запросы:
import cats.effect.{ContextShift, IO}import domain.todos.entities.Todoimport io.circe.generic.auto._import sttp.model.CookieWithMetaimport sttp.tapir.json.circe.jsonBodyimport sttp.tapir.{header, _}class TestController(implicit contextShift: ContextShift[IO]) extends ControllerBase {  private val baseTestEndpoint = baseEndpoint    .in("test")    .tag("Test")//Сюда мы будем делать наш запрос  private val postTest = baseTestEndpoint    .summary("Тестовый эндпойнт для запроска к самому себе")    .description("Возвращает тестовые данные")    .post    .in(header[String]("test_header"))    .in(jsonBody[List[Todo]])    .in(cookies)    .out(header[String]("test_header_out"))    .out(jsonBody[List[Todo]])    .out(setCookies)    .serverLogic(x => withStatus(IO {      (x._1 + x._3.map(c => c.name + "" + c.value).fold("")((a, b) => a + " " + b), x._2, List(CookieWithMeta(name = "test", value = "test_value")))    }))//Этот метод будет запускать наш запрос  private val runHttpRequestTes = baseTestEndpoint    .summary("Запускает тестовый запрос к самому себе")    .description("Запускает тестовый запрос к самому себе")    .get    .out(stringBody)    .serverLogic(_ => withStatus(runHttp()))  def runHttp(): IO[String] = {    ClientExamples.execute().as("Ok")  }  val endpoints = List(    postTest,    runHttpRequestTes  )}


Собственно сам запрос:
import cats.effect.{ContextShift, IO}import com.typesafe.scalalogging.StrictLoggingimport domain.todos.entities.Todoimport io.circe.generic.auto._import org.http4s.circe.CirceEntityCodec.circeEntityEncoderimport org.http4s.client.blaze._import org.http4s.client.middleware.Loggerimport org.http4s.headers._import org.http4s.{MediaType, Uri, _}import org.log4s._import java.time.Instantimport scala.concurrent.ExecutionContext.globalobject ClientExamples extends StrictLogging {  private[this] val logger = getLogger  def execute()(implicit contextShift: ContextShift[IO]) = {//Создаем клиент    BlazeClientBuilder[IO](global).resource.use { client =>      logger.warn("Start Request")//Оборачиваем его в мидлвар который будет логгировать запросы и ответы. //Указываем логгировать и боди и хедеры      val loggedClient = Logger[IO](true, true)(client)//Парсим адресс и небезопасным методом достаем результат      val uri = Uri.fromString("http://localhost:8080/api/v1/test").toOption.get//Создаем запрос. Указываем что это будет POST запрос по адресу что мы сформировали ранее      val request: Request[IO] = Request[IO](method = Method.POST, uri = uri)//Указываем что в json теле запроса передавать массив todo        .withEntity(List(Todo(1, "Test", 2, Instant.now())))//Указываем заголовки которые будут у запроса. Тут один наш кастомный.        .withHeaders(Accept(MediaType.application.json), Header(name = "test_header", value = "test_header_value"))//Указываем что с запросом будут оправляться куки с таким значением        .addCookie("test_cookie", "test_cookie_value")//Выполняем запрос      loggedClient.run(request).use(r => {        logger.warn("End Request")//Логгируем статус (200, 404, 500 и т.д)        logger.warn(r.status.toString())//Логгируем ответ        logger.warn(r.toString())//Пишем в логи хедеры ответа. Там в том числе есть Set-Cookie        logger.warn(r.headers.toString())//bodyText возвращает Stream[IO,String] и мы логгируем данные в нем//Можно десериализовать из этого json ответ сервера.        r.bodyText.map(t =>  logger.warn(t)).compile.drain      })    }  }}


В результате в логах увидим такой текст:
//Наш запрос 02:54:44.634 [ioapp-compute-7] INFO org.http4s.client.middleware.RequestLogger - HTTP/1.1 POST http://localhost:8080/api/v1/test Headers(Accept: application/json, test_header: test_header_value, Cookie: <REDACTED>) body="[{"id":1,"name":"Test","imageId":2,"created":"2020-12-08T23:54:44.627434500Z"}]"//Наш статус 02:54:44.641 [scala-execution-context-global-62] WARN appServices.ClientExamples - 200 OK//Наш ответ02:54:44.641 [scala-execution-context-global-62] WARN appServices.ClientExamples - Response(status=200, headers=Headers(test_header_out: test_header_value test_cookietest_cookie_value, Set-Cookie: <REDACTED>, Content-Type: application/json, Date: Tue, 08 Dec 2020 23:54:44 GMT, Content-Length: 79))//Хедеры нашего ответа02:54:44.641 [scala-execution-context-global-62] WARN appServices.ClientExamples - Headers(test_header_out: test_header_value test_cookietest_cookie_value, Set-Cookie: test=test_value, Content-Type: application/json, Date: Tue, 08 Dec 2020 23:54:44 GMT, Content-Length: 79)//Тело (json) нашего ответа сервера02:54:44.643 [ioapp-compute-6] WARN appServices.ClientExamples - [{"id":1,"name":"Test","imageId":2,"created":"2020-12-08T23:54:44.627434500Z"}]


Тут был специально показан запрос в максимально общем виде. Показано как установить куки, хедеры, тело запроса. Если нужно данные формы отправить или там файл то есть для этого пару способов. Сами данные методом .withEntity выставляются а вот объект формируется по другому
//Тут можно файл отправить через Part.fileData val data = Multipart(parts = Vector(Part.formData("age","18"):Part[IO]))//Или val data= UrlForm(("age","18"))//И создаем запрос  val request: Request[IO] = Request[IO](method = Method.POST, uri = uri)        .withEntity(data)
Подробнее..

Рекомендации по запуску приложений в OpenShift Service Mesh

15.04.2021 12:10:33 | Автор: admin

В этом посте мы собрали советы и рекомендации, которые стоит изучить, прежде чем переносить свои приложения в сервисную сетку OpenShift Service Mesh (OSSM). Если вы никогда не сталкивались с сервисными сетками Service Mesh, то для начала можно глянуть страницу OSSM на сайте Red Hat и почитать о том, как система Istio реализована на платформе OpenShift.

Начав изучать Istio, вы скорее всего столкнетесь с приложением bookinfo, которое почти повсеместно используется в качеств наглядного пособия, или же с более продвинутым вариантом в виде приложения Travel Agency. Разбирая эти и другие примеры, вы сможете лучшее понять, как устроена mesh-сетка, и затем уже переносить в нее свои приложения

Сначала о главном

Начать стоит с официальная документация OpenShift Service Mesh 2.0 (OSSM), в ней можно найти массу полезных материалов, в том числе:

Когда дойдет до интеграции вашего приложения в mesh-сетку, надо будет копнуть поглубже и заглянуть в документацию по Istio. Также стоит ознакомиться с release notes соответствующих версий компонентов, входящих Red Hat OSSM.

Если еще не сделали это, то протестируйте свою mesh-сетку с помощью приложения-примера Bookinfo. Если все пройдет нормально, то в нее уже можно будет добавлять ваше приложение.

Первое, что надо сделать при добавлении в mesh-сетку своего приложения убедиться, что sidecarы проксей Envoy правильно внедрены в podы вашего приложения. В OSSM такое внедрение делается довольно просто и хорошо описывается в документации.

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

Выбор протоколов

Важно четко понимать, как Istio определяет, какие протоколы использует ваше приложение. Для этого изучите все, что связано с Protocol Selection и app and version labelsв разделе документации Pods and Services.

В противном случае скорее всего произойдет следующий казус. Допустим, вы внедряете в свое приложение sidecarы проксей Istio, загружаете его тестовым трафиком и идёте смотреть граф Kiali. И видите там совсем не то, что ожидали (рис. ниже). Почему? Потому что Kiali и Istio не смогли правильно определить, какие протоколы используют наши сервисы, и отобразили соединения между ними как TCP, а не HTTP.

На графе Kiali есть только TCP-соединенияНа графе Kiali есть только TCP-соединения

Istio должен точно знать, какой протокол используется. Если Istio не может определить протокол автоматически, то трактует трафик как обычный (plain) TCP. Если у вас какие-то другие протоколы, их надо вручную прописать в определениях служб Kubernetes Service вашего приложения. Подробнее об этом написано в документации, раздел Protocol Selection.

Чтобы вручную задать, какой протокол использует ваш сервис, надо соответствующим образом настроить объекты Kubernetes Service. В нашем случае в них по умолчанию отсутствовало значение параметра spec -> ports -> name. Если прописать "name: http" для сервисов A, B и C, то граф отобразит эти соединения как HTTP.

Kiali

Kiali это отличный инструмент для того, чтобы начать работать с OpenShift Service Mesh. Можно даже сказать, что именно на нем и надо сосредоточиться, когда вы начинаете работать с mesh-сеткой.

Kiali визуализирует метрики, генерируемые Istio, и помогает понять, что происходит в mesh-сетке. В качестве одной из первоочередных задач мы настоятельно рекомендуем изучить документацию Kiali.

Kiali не только пригодится для визуализации вашего приложения, но и поможет с созданием, проверкой и управлением конфигурациями mesh-сетки. Поначалу изучение конфигураций Istio может быть делом сложным, и Kiali тут сильно поможет.

В Kiali есть много полезных вещей, поэтому мы очень советуем изучить список ее возможностей и FAQ. Например, там есть следующие интересные вещи:

Другая важная вещь умение маркировать сервисы приложения с помощью меток (label). Istio, а следовательно и Kiali, требует, чтобы маркировка велась строго определенным образом, который поначалу отнюдь не кажется очевидным, особенно когда весь ваш опыт исчерпывается работой с приложением-примером Bookinfo, где все метки уже есть и всё прекрасно работает из коробки.

Развертывания с использованием меток app и version это важно, поскольку они добавляет контекстную информацию к метрикам и телеметрии, которые собираются Istio и затем используются в Kiali и Jaeger.

Istio может показать такие связи между внутренними и внешними сервисами, о существовании которых вы даже не догадывались. Это полезно, но иногда может раздражать при просмотре графа. К счастью, с графа всегда можно убрать эти Неизвестные узлы.

Еще одна полезная вещь в Kiali это то, что она может провести проверку вашей mesh-сетки, что особенно полезно, когда вы сами создаете конфигурации.

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

Jaeger-выборки

При первоначальном тестировании своего приложения в mesh-сетке вам, скорее всего, захочется, чтобы частота трассировки была больше 50%, желательно, 100%, чтобы отслеживать все тестовые запросы, проходящие через приложение. В этом случае Jaeger и Kiali быстрее наберут необходимые данные, а вам не придется долго ждать обновления информации.

Иначе говоря, нам надо, чтобы sample rate был равен 100% (тут есть соответствие: 10000 = 100%).

Для этого надо подредактировать объект ServiceMeshControlPlane (обычно называется basic-install) в вашем проекте Control Plane (обычно istio-system) и добавить или изменить там следующее значение:

spec: tracing:  sampling: 10000 # 100%

Понятно, что после запуска приложения в продакшн уже не надо мониторить каждый запрос и частоту выборки стоит понизить процентов до пяти или ниже.

Распространение заголовков контекста трассировки

Jaeger помогает убрать одну из проблем, которая возникает при переходе на микросервисную архитектуру, но для этого все сервисы вашего приложения должны правильно распространять заголовки трассировки (trace headers).

Очень полезно отслеживать, как запросы ходят через сервисы (или даже множество сервисов) в вашей mesh-сетке. OSSM может здесь помочь за счет сбора данных в форме spanов и трасс (trace). Просмотр трасс очень помогает понять сериализацию, параллелизм и источники задержек в вашем приложении. Вкратце, span это интервал от начала выполнения единицы работы до ее завершения (например, полная отработка запроса клиент-сервер). Трасса это путь, по которому запрос проходит, двигаясь по mesh-сети, или, другими словами, по мере того, как он передается от одного сервиса вашего приложения к другому. Подробнее об этом можно и нужно почитать в документации OSSM.

Обратите внимание, что в OSSM spanы (единицы работы) автоматически генерируются средствами Istio, а вот трассы нет. Поэтому чтобы распределенные трассы (distributed traces) были полностью просматриваемыми, разработчик должен изменить код так, чтобы любые существующие trace-заголовки правильно копировались при передаче запроса между сервисами. К счастью, вы не обязаны сами генерировать эти заголовки. Если изначально их нет, то они будут автоматически сгенерированы и добавлены первым Envoy-прокси, который встретится на пути запроса (обычно это прокси на ingress-шлюзе).

Вот список заголовков для распространения:

  • x-request-id

  • x-b3-traceid

  • x-b3-spanid

  • x-b3-parentspanid

  • x-b3-sampled

  • x-b3-flags

  • x-ot-span-context

Распространение заголовков может выполняться вручную или с использованием клиентских библиотек Jaeger, реализующих OpenTracing API.

Вот как делается ручное распространение trace-контекста на Java:

HttpHeaders upstreamHttpHeaders = new HttpHeaders();if (downstreamHttpHeaders.getHeader(headerName: "x-request-id") != null)   upstreamHttpHeaders.set("x-request-id", downstreamHttpHeaders.getHeader( headerName: "x-request-id"));

Примечание: это надо повторить для всех заголовков из списка выше.

Мастера Kiali и редактор YAML

Проверки

Ручная настройка yaml-манифестов дело утомительное, особенно если значения в одном yaml должны совпадать со значениями в другом, а еще ведь есть жесткие правила отступов. К счастью, в Kiali есть отличная функция, которая поможет при создании и проверке конфигураций.

Создание Istio-ресурсов с помощью Kiali-мастеров

Большую часть конфигураций, которые понадобятся вам в первое время, можно создать с помощью соответствующих мастеров Kiali, которые вызываются через меню Services.

YAML-редактор

Kiali имеет собственный редактор YAML для просмотра и редактирования конфигурационных ресурсов Istio напрямую, который также выявляет некорректные конфигурации.

Часто бывает так, что граф Kiali вдруг выявляет в вашем приложении неизвестные ранее (в том числе и разработчикам) коммуникационные пути. Другими словами, Kiali помогает найти и выявить все существующие пути во время тестирования вашего приложения. Это, конечно, полезно, но иногда может и раздражать. В этом случае их можно просто не отображать на графе, введя "node=unknown" в поле ввода над графом Kiali.

Уберите из кода шифрование коммуникаций

Если вы уже защитили соединения между своими сервисами и/или (скорее всего) используете TLS для внешних соединений, то при переводе приложения в mesh-сетку их надо будет в обязательном порядке выключить и переключиться на чистый HTTP без шифрования. А всем шифрованием теперь займутся Envoy-прокси.

Если ваши сервисы будут связываться с внешними сервисами по TLS, то Istio не сможет инспектировать трафик и Kiali будет отображать эти соединения только как TCP.

В общем, используйте для взаимодействия сервисов только HTTP, но не HTTPS.

Также про внешние сервисы надо поставить в известность и вашу mesh-сетку (см. ниже Настройка внешних сервисов).

Упростите код

Самое время пересмотреть код вашего приложения и убрать из него лишнее.

Одно из преимуществ Service Mesh состоит в том, что реализацию многих вещей по сервисам можно убрать из приложения и отдать на откуп платформе, что помогает разработчикам сосредоточиться на бизнес-логике и упростить код. Например, можно рассмотреть следующие доработки:

  • Как сказано выше, убрать HTTPS-шифрование.

  • Убрать всю логику обработки таймаутов и повторных попыток.

  • Убрать все ставшие ненужными библиотеки.

  • Помните, что mesh-сетка увеличивает количество пулов подключений. Если раньше два сервиса связывались напрямую, то теперь у каждого из них есть свой прокси-посредник. То есть фактически вместо одного пула подключений появляются три:

    1. От вашего первого сервиса к локальному для него sidecarу Envoy (расположены в одном и том же podе).

    2. От этого sidecarа к другому sidecarу Envoy, который обслуживает второй сервис и расположен в одном podе с этим сервисом.

    3. И наконец, от того другого sidecarа, собственно, ко второму сервису. Поэтому, возможно, стоит упростить конфигурацию, чтобы не раздувать пулы подключений. Подробнее об этом хорошо написано здесь.

Еще один плюс оптимизации кода это возможность уменьшить размер сервисов и (возможно) поднять их производительность, убрав из них те вещи, которые теперь реализуются на уровне mesh-сетки.

Объекты Service

Убедитесь, что все сервисы вашего приложения взаимодействуют друг с другом через имена объектов Kubernetes Service, а не через OpenShift Routes.

Просто проверьте, вдруг ваши разработчики используют OpenShift Routes (конечные точки ingress на кластере) для организации коммуникаций между сервисами в пределах одного кластера. Если эти сервисы должны входить в одну и ту же mesh-сетку, то разработчиков надо заставить поменять конфигурации/манифесты своих приложений, чтобы вместо конечных точек OpenShift Route использовались имена объектов Kubernetes Service.

Функции аварийного переключения (fallback)

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

Настройка внешних сервисов

Envoy-прокси могут работать не только внутри кластера, но и отправлять трафик за его пределы, если зарегистрировать внешние сервисы в mesh-сетке.

Скорее всего, ваше приложение общается с сервисами за пределами mesh-сетки. Поскольку это внешние по отношению mesh-сетке сервисы, то толку от нее здесь не будет, да? А вот и нет. Эти внешние сервисы можно прописать в Service Mesh и использовать часть её функций и для них.

Подробнее и с примерами можно почитать об этом в документации OSSM. Есть и подробный разбор, как визуализировать внешний трафик Istio в Kiali, и как использовать TLS originationдля зашифрованного egress-трафика.

Вот некоторые из функций Istio, которые можно использовать при работе с внешними сервисами:

  1. Шифрование (и простое, и Mutual TLS).

  2. Таймауты и повторы.

  3. Circuit breakerы.

  4. Маршрутизация трафика.

Заключение

С OpenShift Service Mesh вы можете лучше понять, как устроена ваша mesh-сетка, сделать ее более просматриваемой, что, в свою очередь, помогает поднять общий уровень сложности микросервисной архитектуры. Бонусом идет возможность реализовать больше функций и возможностей на уровне самой платформе OpenShift, а не кодировать их на уровне отдельных приложений, что облегчает жизнь разработчикам. Еще один плюс реализация вещей, которые раньше казались неподъемными, например, канареечное развертывание, A/B-тестирование и т.п. Кроме того, вы получаете целостный подход к управлению микросервисными приложениями на всех своих кластерах OpenShift, что хорошо с точки зрения преемственности людей и непрерывности процессов. В конечном итоге, это поможет перейти от монолитных приложений к распределенной микросервисной архитектуре и работать в большей степени на уровне конфигураций, чем кода.

Подробнее..

Отказоустойчивый кластер с балансировкой нагрузки с помощью keepalived

24.10.2020 20:17:25 | Автор: admin

Сегодня я расскажу о том, как быстро собрать отказоустойчивый кластер с балансировкой нагрузки с помощью keepalived на примере DNS-серверов.


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


Чаще всего такие задачи приходится делать с HTTP-серверами. Я сегодня покажу сборку кластера на примере DNS, потому что опробовал технологию именно на нем, но с минимальными изменениями то же самое можно сделать и с серверами, работающими по TLS или HTTP.


Лирическое отступление о проблемах DNS


В принципе, DNS задуман так, чтобы необходимости городить огород с кластерами не возникало. В каждой зоне можно прописать множество NS-записей, в каждой сети можно раздать список DNS-серверов с помощью DHCP. DNS-сервера умеют реплицировать зоны, поэтому они хорошо масштабируются. Однако на практике это плохо работает. Когда я попробовал добавить 2-й DNS-сервер, то обнаружил, что


  1. Половина пользователей сидят со статическими настроенными адресами DNS-серверов. Ставят себе 4 восьмерки в качестве primary и локальный DNS в качестве secondary.
  2. Многие Linux-сервера не умеют из коробки корректно обновлять настройки DNS. Там такой зоопарк из glibc, nsswitch.conf, resolv.conf, NetworkManager, resolvconf, systemd-resolved, hosts, dhclient.conf и т. п., которые конфликтуют между собой, что рассчитывать на автоматическое обновление по DHCP просто не приходится.
  3. Windows шлет запросы одновременно на все сервера, но обязательно дожидается ответа или таймаута от 1-го
  4. Linux сначала обращается к 1-му DNS в списке и только в случае ошибки переходит к следующему.

Если долгое время в сети используется DNS-сервер с определенным IP, то он оказывается прописан в десятках разных мест. Например:


  • Директива resolver в nginx.conf
  • daemon.json в docker
  • В настройках docker-контейнеров
  • В конфигах модных нынче систем вроде kubernetes или openshift во внутренних файлах на каждой ноде и еще там же в конфигах dnsmasq.
  • В конфигах почтовых серверов.
  • В настройках VPN-серверов.

Никакими плейбуками и DHCP-декларациями это все обновить просто нереально. Поэтому было бы очень хорошо, если бы собираемый кластер можно было повесить на 1 уже существующий IP. Тогда для пользователей ничего не изменится, и софт перенастраивать не понадобится.


Поэтому я выбрал решение с keepalived и протоколом VRRP.


Подготовка


Собирая кластер из нескольких серверов, желательно уметь их различать на стороне клиента, чтобы не бегать искать по логам серверов, какой запрос, куда попал. Я для этого в bind9 завел вот такую зону.


/var/named/zones/load.balance.zone
$ORIGIN load.balance.$TTL 1hload.balance. 86400 IN SOA ns.mydomain.ru. dnsmaster.mydoamin.ru. (     1603065098 3600 1800 604800 30)load.balance. IN NS ns.mydomain.ru.health IN TXT =nameserver-1=

На 1-м сервере ресурсная запись TXT для health.load.balance содержит текст =nameserver-1=, на 2-м сервере =nameserver-2=, и т. д. Таким образом, отправляя запрос в кластер, я по ответу могу определить, какой сервер мне ответил, что очень удобно для отладки.


Если у вас HTTP-сервер, то поместите эту информацию в HTTP-заголовок. Например, для nginx я использую вот такую директиву


add_header serega-trace "$hostname" always;

Убедитесь, что ваши сетевые файерволлы не блокируют IP-трафик протокола 112 по адресу 224.0.0.18. Этот адрес будут использовать ваши сервера, чтобы договариваться между собой о том, кто из них MASTER, а кто BACKUP.


Открыть VRRP в iptables
iptables -t filter -I INPUT -p vrrp -d 224.0.0.18 -j ACCEPTiptables -t filter -I OUTPUT -p vrrp -d 224.0.0.18 -j ACCEPT

При организации DNS нужно выделить мастер-сервер, на который не будут идти клиентские запросы. DNS-мастер используется только для управления. Он реплицирует свои зоны на slave-сервера, которые уже видны пользователям, и именно на них идет нагрузка. Далее речь будет идти о кластеризации именно DNS-slave.


Уровень 1 (Easy)


Если вам просто достаточно резервирования на случай аварии, то рекомендую рассмотреть самый быстрый и простой вариант. 2 одинаковых сервера разделяют между собой общий виртуальный IP (далее буду называть его VIP). У кого в данный момент в сетевом интерфейсе прописан VIP, тот сервер и работает. 2-й сервер следит за мастером (имеется в виду мастер VRRP, а не DNS), и как только обнаруживает, что он перестал вещать, сразу объявляет мастером себя и поднимает VIP на своем сетевом интерфейсе.


Меня приятно удивило то, как быстро удалось поднять кластер по такому варианту. Несмотря на то, что раньше я никогда не имел дело с keepalived, уже через час все работало. Так что если вам нужно решить задачу быстро, то уровень 1 вам подойдет в самый раз.


Сбор информации


Для успешного развертывания вам понадобится собрать следующую информацию. Здесь я привожу значения для примера. У вас эти значения должны быть свои.


Параметр Возможное значение Описание
vip 10.2.1.5 виртуальный IP, на который шлют запросы клиенты
dev0 eth0 1-й сетевой интерфейс на узлах кластера
ip01 10.2.1.2 IP 1-го узла кластера на 1-м сетевом интерфейсе
ip02 10.2.1.3 IP 2-го узла кластера на 1-м сетевом интерфейсе
net0 10.2.1.0/24 подсеть, которой принадлежат ip01 и ip02

Установите keepalived и snmpd. SNMP ставить необязательно, но мне кажется, что, если серверов с виртуальными IP будет много, он будет полезен.


setenforce 0 # если вдруг у вас selinuxdnf install -y keepalived nmap-ncat net-snmp net-snmp-utilssystemctl enable keepalivedsystemctl enable snmpd

Netcat нужен для диагностики и healthcheck-ов.


В файл /etc/sysconfig/keepavlied добавьте опцию -x. Она нужна для взаимодействия keepalived с snmpd. Если вы не собираетесь поднимать SNMP, то этот шаг можете пропустить.


Отредактируйте файл /etc/snmp/snmpd.conf следующим образом:


/etc/snmp/snmpd.conf
master agentxrocommunity public

В /etc/keepalived положите вот такой скрипт keepalived-notify.sh:


/etc/keepalived/keepalived-notify.sh
#!/bin/shumask -S u=rwx,g=rx,o=rxexec echo "[$(date -Iseconds)]" "$0" "$@" >>"/var/run/keepalived.$1.$2.state"

Этот скрипт будет вызываться демоном keepalived при изменении состояния кластера. Он запишет в каталог /var/run статусный файл, который удобно использовать для диагностики.


Отредактируйте основной конфигурационный файл keepalived.conf следующим образом:


global_defs {    enable_script_security}vrrp_script myhealth {    script "/bin/nc -z -w 2 127.0.0.1 53"    interval 10    user nobody}vrrp_instance VI_1 {    state BACKUP    interface eth0    virtual_router_id 5    priority 100    advert_int 1    nopreempt    notify /etc/keepalived/keepalived-notify.sh root    authentication {        auth_type PASS        auth_pass KPSjXfRG    }    virtual_ipaddress {        10.2.1.5    }    track_script {        myhealth    }}

Блок global_defs содержит единственную необходимую настройку enable_script_security, которая по умолчанию отключена.


Блок vrrp_script описывает скрипт, который демон keepalived будет использовать для определения работоспособности своего сервера. Если этот скрипт вернет ошибку, то демон перейдет в состояние FAIL, и не будет претендовать на роль MASTER. В этом же блоке описывается периодичность выполнения healthchek-ов и указывается пользователь, от имени которого запускается скрипт. В нашем случае используется утилита netcat, которая устанавливает соединение c локальным DNS-сервером по TCP-порту 53. Можно использовать разные проверки, например, прозвонить UDP-порт 53 утилитой dig.


В блоке VRRP_INSTANCE задаются настройки 1 экземпляра сервера с виртуальным IP.


  • state задает начальное состояние сервера BACKUP или MASTER. В режиме nopreempt единственное допустимое значение BACKUP.
  • interface указывает, на каком сетевом интерфейсе будет поднят VIP
  • virtual_router_id уникальный идентификатор роутера VRRP. Возможные значения от 1 до 255. У всех узлов кластера это значение должно быть одинаковым. Рекомендуется в качестве router_id использовать последний байт VIP, чтобы не запутаться, когда у вас таких виртуальных адресов будет много.
  • priority задает приоритет данного экземпляра при выборе мастера. Мастером назначается сервер, у которого значение параметра priority выше. Если у нескольких серверов priority одинаковый, то мастер будет выбран случайным образом.
  • advert_int определяет, с какой периодичностью мастер должен сообщать остальным о себе. Если по истечению данного периода сервера не получат от мастера широковещательное уведомление, то они инициируют выборы нового мастера.
  • nopreempt означает, что если мастер пропал из сети, и был выбран новый мастер с меньшим приоритетом, то по возвращении старшего мастера, он останется в состоянии BACKUP. Т. е. если вы перезагрузили мастер, то он больше мастером не станет, пока новый мастер не отвалится. Если вы предпочитаете, чтобы мастером был какой-то конкретный сервер, то замените настройку nopreempt на preempt_delay.
  • notify задает хук-скрипт, который будет вызываться при каждом изменении состояния сервера, и имя пользователя, от имени которого данный скрипт будет выполняться.
  • authentication задает пароль, длиной до 8 символов, который будет защищать кластер от случайных коллизий с другими серверами в локальной сети.
  • virtual_ipaddress задает VIP
  • track_script указывает на описание скрипта, осуществляющего healthcheck.

Выполните указанные настройки на обоих серверах кластера и запустите сервисы:


systemctl start snmpdsystemctl start keepalived

Проверьте логи и статусные файлы на наличие ошибок.


journalctl -u snmpdjournalctl -u keepalivedtail /var/run/keepalived.INSTANCE.VI_1.state

Если у вас используется selinux, не забудьте по данным audit.log обновить политики и вернуть enforcing mode командой set enforce 1.


Проверка


Если в логах ошибок нет, можно проверять. Поскольку мы кластеризовали DNS, то будем использовать dig. Для проверки HTTP-сервера подойдет curl.


Запустите на клиенте такой скрипт


while true; do    dig -4 +short +notcp +norecurse +tries=1 +timeout=1 \        -q health.load.balance. -t txt @10.2.1.5;     sleep 1;done

Этот скрипт будет раз в секунду выдавать ресурсную запись health.load.balance/IN/TXT, которая содержит идентификатор сервера. В нашей конфигурации это будет тот сервер, который сейчас VRRP-мастер.


Зайдите на этот сервер и убедитесь, что в файле /var/run/keepalived.INSTANCE.VI_1.state в последней строке указано MASTER.


Перезапустите на мастере сервис keepalived. Если у вас все сделано правильно, то мастер поменяется. На 1-м сервере в файле keepalived.INSTANCE.VI_1.state появятся строки STOP и BACKUP, на втором сервере в этом же файле появится строка MASTER, а клиентский скрипт станет выдавать ресурсную запись с идентификатором нового мастера.


[2020-10-13T10:48:00+00:00] /etc/keepalived/keepalived-notify.sh INSTANCE VI_1 BACKUP 100[2020-10-13T11:26:29+00:00] /etc/keepalived/keepalived-notify.sh INSTANCE VI_1 MASTER 100

"=nameserver-1=""=nameserver-1=""=nameserver-1=""=nameserver-2=""=nameserver-2="

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


Если у вас все получилось, значит вы успешно прошли 1-й уровень кластеризации. Он обладает следующими плюсами:


  • очень легко и быстро настраивается,
  • практически невозможно что-либо сломать,
  • на всех узлах кластера одинаковая конфигурация, что сильно сокращает трудоемкость администрирования.

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


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


Уровень 2 (c балансировкой нагрузки)


Чтобы задействовать все сервера кластера, нужно научить VRRP-мастер, на сетевом интерфейсе которого прописан VIP, не только принимать трафик для своих локальных сервисов, но и направлять часть трафика на остальные сервера. Демон keepalived как раз умеет это делать.


Отредактируйте конфиг keepalived.conf, приведя его к следующему виду:


/etc/keepalived/keepalived.conf
global_defs {    enable_script_security}vrrp_instance VI_1 {    state BACKUP    interface eth0    virtual_router_id 5    priority 100    advert_int 1    nopreempt    notify /etc/keepalived/keepalived-notify.sh root    authentication {        auth_type PASS        auth_pass KPSjXfRG    }    virtual_ipaddress {        10.2.1.5    }}virtual_server 10.2.1.5 53 {    protocol UDP    delay_loop 10    lvs_sched rr    lvs_method NAT    real_server 10.2.1.2 53 {        DNS_CHECK {            type txt            name health.load.balance.        }    }    real_server 10.2.1.3 53 {        DNS_CHECK {            type txt            name health.load.balance.        }    }}virtual_server 10.2.1.5 53 {    protocol TCP    delay_loop 10    lvs_sched rr    lvs_method NAT    real_server 10.2.1.2 53 {        TCP_CHECK {            connect_timeout 3        }    }    real_server 10.2.1.3 53 {        TCP_CHECK {            connect_timeout 3        }    }}

Начало конфигурации аналогично предыдущему варианту без балансировки, только из блока vrrp_instance исчез track_script, соответственно за ненадобностью был удален блок vrrp_script.


Главное отличие в новой конфигурации заключается в блоках virtual_server. Для DNS требуется 2 виртуальных сервера, для 53-го порта TCP и для 53-го порта UDP. В случае HTTP-сервера аналогично потребуются сервера для 80-го и 443-го портов TCP.


Каждый виртуальный сервер идентифицируется 3 значениями: IP, порт и протокол. IP и порт через пробел указываются в заголовке блока virtual_server, а протокол определяется параметром protocol внутри блока. Допустимые протоколы TCP и UDP. В случае DNS как раз нужны оба.


Параметр delay_loop задает периодичность, с которой балансировщик опрашивает бэкенды для определения их состояния.


Самые важные параметры в виртуальном сервере это lvs_sched и lvs_method. lvs_sched задает алгоритм, по которому балансировщик определяет, куда отправить очередной IP-пакет. lvs_method задает механизм, который использует балансировщик для направления пакетов в выбранный пункт назначения.


В приведенном примере lvs_sched равен rr, что означает round robin, т. е. балансировка равномерно по очереди. lvs_method используется NAT. Кроме NAT доступны также механизмы DR (direct routing) и TUN (tunneling). На мой взгляд, NAT единственный рабочий вариант. Остальные методы работают только в очень специфических условиях.


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


После такой настройки keepalived работает следующим образом:


  1. Принимает IP-пакет с адресом получателя равным VIP.
  2. Выбирает real_server, куда необходимо направить пакет, анализируя протокол, порт, lvs_sched и результаты healthcheck-ов.
  3. Заменяет в IP-пакете адрес получателя на IP-адрес выбранного реального сервера и отправляет его дальше.

Только для работы этого недостаточно. Выбранный реальный сервер отправит ответный IP-пакет, в котором адрес отправителя будет равен его собственному IP-адресу. Клиент дропнет такой пакет, потому что он отправлял запрос на VIP и ожидает ответы от VIP, а не с IP-адреса реального сервера.


Чтобы решить эту проблему, нужно заставить реальный сервер отправить ответ не на IP клиента, а на IP мастера, чтобы тот выполнил обратную подстановку реального IP на виртуальный.


Для этого понадобится itpables и некоторые настройки ядра.


dnf -y install iptables iptables-servicessystemctl enable iptablessystemctl start iptables

echo "net.ipv4.ip_forward=1" >>/etc/sysctl.d/99-sysctl.confecho "net.ipv4.vs.conntrack=1" >>/etc/sysctl.d/99-sysctl.confsysctl -w net.ipv4.ip_forward=1sysctl -w net.ipv4.vs.conntrack=1

Добавьте в iptables следующее правило:


iptables -t nat -I POSTROUTING 1 -d 10.2.1.0/24 -j SNAT --to-source 10.2.1.5service iptables save

Действие SNAT означает, что после маршрутизации в IP-пакете IP-адрес источника будет заменен на IP-адрес to-source. Вместо SNAT можно также использовать действие MASQUERADE, которое делает то же самое, только определяет исходящий IP автоматически.


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


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


"=nameserver-1=""=nameserver-2=""=nameserver-1=""=nameserver-2=""=nameserver-1="

Поскольку в балансировщике задан алгоритм round robin, то сервера отвечают строго по очереди друг за другом.


Остановите named на 2-м сервере, и получите:


"=nameserver-1=""=nameserver-1=""=nameserver-1=""=nameserver-1=""=nameserver-1="

Снова запустите на 2-м сервере named, и на клиенте снова начнется чередование ответов.


Не забудьте про корректировку политик selinux, о которой рассказано выше.


Поздравляю. Только что мы с вами прошли 2-й уровень кластеризации.


Теперь наш кластер не только отказоустойчивый, но и масштабируемый.


Тем не менее, данная конструкция имеет и большой минус. Из-за трансляции адресов на подчиненные сервера запросы приходят с VIP, и невозможно по логам определить, какой запрос от какого клиента пришел. Возможность вычислить клиента по IP очень полезна для решения различных проблем. Поэтому, мой совет не останавливаться на 2-м уровне и, по возможности, перейти на 3-й.


Уровень 3 (Expert)


3-й уровень кластеризации подразумевает, что вы уже находитесь на 2-м, и все у вас работает как надо. Необходимо только обеспечить проброс клиентского IP-адреса до реальных серверов.


Принцип проброса следующий: все пакеты, приходящие на сервер, содержат IP реального клиента, а ответы на них отправляются на VIP. Т. е. VIP для реального сервера является шлюзом по умолчанию. Причем на этот шлюз направляются даже те пакеты, получатель которых находится в локальной подсети.


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


Решение заключается в создании на сервере 2-го сетевого интерфейса. На 1-м сетевом интерфейсе будет производиться стандартный сетевой обмен, а на 2-м как раз и будет настроен VIP в качестве шлюза по умолчанию. Соответственно, у серверов вместе с дополнительным сетевым интерфейсом должен будет появиться и дополнительный IP-адрес.


Итак, добавьте на сервера кластера дополнительные сетевые интерфейсы и назначьте им IP-адреса.


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


Параметр Возможное значение Описание
vip 10.2.1.5 виртуальный IP, на который шлют запросы клиенты
dev0 eth0 1-й сетевой интерфейс на узлах кластера
dev1 eth1 2-й сетевой интерфейс на узлах кластера
ip01 10.2.1.2 IP 1-го узла кластера на 1-м сетевом интерфейсе
ip02 10.2.1.3 IP 2-го узла кластера на 1-м сетевом интерфейсе
ip11 10.2.1.6 IP 1-го узла кластера на 2-м сетевом интерфейсе
ip12 10.2.1.7 IP 2-го узла кластера на 2-м сетевом интерфейсе
net0 10.2.1.0/24 подсеть, которой принадлежат ip01 и ip02

Скорректируйте конфигурацию keepalived по указанному образцу.


/etc/keepalived/keepalived.conf
global_defs {    enable_script_security}vrrp_instance VI_1 {    state BACKUP    interface eth0    virtual_router_id 5    priority 100    advert_int 1    nopreempt    notify /etc/keepalived/keepalived-notify.sh root    authentication {        auth_type PASS        auth_pass KPSjXfRG    }    virtual_ipaddress {        10.2.1.5    }}virtual_server 10.2.1.5 53 {    protocol UDP    delay_loop 10    lvs_sched rr    lvs_method NAT    real_server 10.2.1.6 53 {        DNS_CHECK {            connect_ip 10.2.1.2            type txt            name health.load.balance.        }    }    real_server 10.2.1.7 53 {        DNS_CHECK {            connect_ip 10.2.1.3            type txt            name health.load.balance.        }    }}virtual_server 10.2.1.5 53 {    protocol TCP    delay_loop 10    lvs_sched rr    lvs_method NAT    real_server 10.2.1.6 53 {        TCP_CHECK {            connect_ip 10.2.1.2            connect_timeout 3        }    }    real_server 10.2.1.7 53 {        TCP_CHECK {            connect_ip 10.2.1.3            connect_timeout 3        }    }}

От предыдущей конфигурации новая отличается тем, что для реальных серверов указаны IP с дополнительных сетевых интерфейсов, но healthcheck-и отправляются на IP основных сетевых интерфейсов. Обратите внимание на этот момент. Все сервера кластера опрашивают друг друга, но на дополнительных сетевых интерфейсах будут настроены маршруты на VIP, поэтому узлы, которые сейчас в состоянии BACKUP, не смогут получить ответ от новых IP-адресов.


Теперь необходимо прописать правильные маршруты для дополнительных сетевых интерфейсов.


Добавьте в файл /etc/iproute2/rt_tables 2 новых таблицы маршрутизации. В примере ниже добавлены таблицы table0 и table1.


/etc/iproute2/rt_tables
## reserved values#255     local254     main253     default0       unspec## local##1      inr.ruhep20      table021      table1

По документации к NetworkManager и CentOS 8 статические маршруты и правила следует помещать в файлы /etc/syconfig/network-scripts/route-eth0 и rule-eth0. На многих моих серверах именно так и сделано. Только почему-то на серверах, поднятых из одного и того же образа, формат этих файлов оказался разным. На большинстве серверов route-eth0 выглядит так:


route-eth0 здорового человека
192.168.1.0/24 via 192.168.1.1172.10.1.0/24 via 172.10.1.1

но почему-то на моих серверах DNS эти же файлы содержат вот это:


route-eth0 курильщика
ADDRESS0=192.168.1.0NETMASK0=255.255.255.0GATEWAY0=192.168.1.1ADDRESS1=172.10.1.0NETMASK1=255.255.255.0GATEWAY1=172.10.1.1

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


Поскольку сохранить маршруты в специальных системных файлах не удалось, пришлось сделать костыль.


/etc/keepalived/routes.sh
#!/bin/sh# https://tldp.org/HOWTO/Adv-Routing-HOWTO/lartc.rpdb.multiple-links.htmlvip="10.2.1.5"dev0="eth0"ip0="10.2.1.2" # "10.2.1.3"dev1="eth1"ip1="10.2.1.6" # "10.2.1.7"ip route add 10.2.1.0/24 dev "$dev0" src "$ip0" table table0ip route add default via 10.2.1.1 table table0ip rule add from "$ip0" table table0ip route add "$vip/32" dev "$dev1" src "$ip1"ip route add default via "$vip" table table1ip route add "$vip/32" dev "$dev1" src "$ip1" table table1ip rule add from "$ip1" table table1

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


Данный скрипт я добавил в автозапуск вместе с сервисом keepalived.


systemctl edit keepalived

[Service]ExecStartPre=/etc/keepalived/routes.sh

Не самое элегантное решение, но работает нормально, поэтому можете спокойно использовать, если не знаете, как можно сделать лучше.


Принципы задания правил маршрутизации описаны в Linux Advanced Routing & Traffic Control HOWTO. Идея заключается в том, что создается 2 независимые таблицы маршрутизации, в каждой свой шлюз по умолчанию. В зависимости от того, с какого сетевого интерфейса отправляется пакет, с помощью правил ip rule выбирается либо одна таблица, либо другая.


Удалите из iptables правило SNAT в цепочке POSTROUTING, добавленное при прохождении 2-го уровня. Сохраните состояние iptables.


iptables -t nat -D POSTROUTING -d 10.2.1.0/24 -j SNAT --to-source 10.2.1.5service iptables save

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


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


Сложность Масштабирование Единые настройки IP клиента
Уровень 1 Easy Нет Да Да
Уровень 2 Normal Да Да Нет
Уровень 3 Expert Да Нет Да
Подробнее..

Подключение к session в Java и Python. HttpURLConnection и CookieManager (Java). Requests(Python)

29.06.2020 22:07:32 | Автор: admin
Допустим, что нам надо подключиться к серверу, авторизоваться и поддерживать сессию. В браузере это выглядит следующим образом:
  1. На адрес http://localhost:8080/login отправляется пустой GET запрос.
  2. Сервер присылает формочку для заполнения логина и пароля, а также присылает Cookie вида JSESSIONID=094BC0A489335CF8EE58C8E7846FE49B.
  3. Заполнив логин и пароль, на сервер отправляется POST запрос с полученной ранее Cookie, со строкой в выходном потоке username=Fox&password=123. В Headers дополнительно указывается Content-Type: application/x-www-form-urlencoded.
  4. В ответ сервер нам присылает новую cookie c новым JSESSIONID=. Сразу же происходит переадресация на http://localhost:8080/ путём GET запроса с новой Cookie.
  5. Далее можно спокойно использовать остальное API сервера, передавая последнее Cookie в каждом запросе.


Рассмотрим, как это можно реализовать на Java и на Python.



Содержание:




Реализация на Python. Requests.



При выборе библиотеки для работы с сетью на Python большинство сайтов будет вам рекомендовать библиотеку requests , которая полностью оправдывает свой лозунг:
HTTP for Humans

Вся задача решается следующим скриптом:
import requestssession = requests.session()  #создаём сессиюurl = "http://localhost:8080/login"session.get(url)   #получаем cookiedata = {"username": "Fox", "password": "123"} response = session.post(url, data=data) #логинимся


Заметим, что махинации с Cookie и переадресацией происходят под капотом, прямо как в браузере. Так же можно отметить, что если завести ещё одну переменную session2, то можно держать активными сразу два подключения.

Реализация на Java, HttpURLConnection и CookieManager.



Поиски библиотеки для работы с сетью на Java приводят сразу к нескольким библиотекам. Например, java.net, Apache HttpClient и OkHttp3.

Я остановился на HttpURLConnection (java.net). Плюсами данной библиотеки является то, что это библиотека "из-под коробки", а так же, если надо написать приложение под android, на официальном сайте есть документация. Минусом является очень большой объём кода. (После Python это просто боль).

Итак, начнём. По документации для работы с сессиями можно использовать CookieManager:

CookieManager cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL);CookieHandler.setDefault(cookieManager);


Что нужно отметить, используя такой подход:
  • CookiePolicy.ACCEPT_ALL указывает, что надо работать со всеми cookie.
  • Переменная cookieManager далее нигде не будет использоваться. Она контролирует все подключения, и, если необходимо поддерживать несколько активных сессий, необходимо будет в этой одной переменной руками менять Cookie


Учтя это, можно записать и в одну строчку:
 CookieHandler.setDefault(new CookieManager(null, CookiePolicy.ACCEPT_ALL));


Пункт 1 и 2. Выполним GET запрос для получения первой Cookie
URL url = new URL("http://localhost:8080/login");HttpURLConnection con = (HttpURLConnection) url.openConnection();con.setRequestMethod("GET");BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));String inputLine;final StringBuilder content = new StringBuilder();while ((inputLine = in.readLine()) != null) {    content.append(inputLine);}


После этого наш cookieManager будет содержать Cookie с сервера и автоматически подставит её в следующий запрос.

Веселье начинается с POST запросом.
url = new URL("http://localhost:8080/login");con = (HttpURLConnection) url.openConnection();con.setRequestMethod("POST");


Нужно записать в Headers Content-Type: application/x-www-form-urlencoded.
Почему метод называется setRequestProperty, а не setHeaders (или addHeaders) при наличии метода getHeaderField, остаётся загадкой.
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");


Далее идёт код, который непонятно по каким причинам не засунут под капот библиотеки.
con.setDoOutput(true);

Нужна эта строчка кода для открытия исходящего потока. Забавно, что без этой строки мы получим следующее сообщение:
Exception in thread main java.net.ProtocolException: cannot write to a URLConnection if doOutput=false call setDoOutput(true)

Открываем исходящий поток и записываем туда логин и пароль:
final DataOutputStream out = new DataOutputStream(con.getOutputStream());out.writeBytes("username=Fox&password=123");out.flush();out.close();


Остаётся считать ответ с уже перенаправленного запроса.

Реализация на Java, HttpURLConnection без CookieManager.



Можно реализовать и без CookieManager и самому контролировать перемещение cookie.
Пункт 1 и 2. Вынимаем cookie.
URL url = new URL("http://localhost:8080/login");HttpURLConnection con = (HttpURLConnection) url.openConnection();con.setRequestMethod("GET");BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));String inputLine;final StringBuilder content = new StringBuilder();while ((inputLine = in.readLine()) != null) {    content.append(inputLine);String cookie = con.getHeaderField("Set-Cookie").split(";")[0];}


Далее отправляем POST запрос, только на этот раз вставив cookie и отключив автоматическое перенаправление, т.к. перед ним надо успеть вытащить новое cookie:

// создаём запросurl = new URL("http://localhost:8080/login");con = (HttpURLConnection) url.openConnection();con.setRequestMethod("POST");//указываем headers и cookiecon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");con.setRequestProperty("Cookie", cookie);//отключаем переадресациюcon.setInstanceFollowRedirects(false);//отправляем логин и парольcon.setDoOutput(true);final DataOutputStream out = new DataOutputStream(con.getOutputStream());out.writeBytes("username=Fox&password=123");out.flush();out.close();//считываем и получаем второе cookieBufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));String inputLine;final StringBuilder content = new StringBuilder();while ((inputLine = in.readLine()) != null) {    content.append(inputLine);String cookie2 = con.getHeaderField("Set-Cookie").split(";")[0];


Далее во все запросы просто добавляем следующую строку:
con.setRequestProperty("Cookie", cookie2);


Надеюсь было полезно. В комментариях приветствуются варианты попроще.
Подробнее..

Как работать с ошибками бизнес-логики через HTTP

09.03.2021 20:23:18 | Автор: admin

Почти все разработчики так или иначе постоянно работают с api по http, клиентские разработчики работают с api backend своего сайта или приложения, а бэкендеры "дергают" бэкенды других сервисов, как внутренних, так и внешних. И мне кажется, одна из самых главных вещей в хорошем API это формат передачи ошибок. Ведь если это сделано плохо/неудобно, то разработчик, использующий это API, скорее всего не обработает ошибки, а клиенты будут пользоваться молчаливо ломающимся продуктом.

За 7 лет я как поддерживал множество legacy API, так и разрабатывал c нуля. И я поработал, наверное, с большинством стратегий по возвращению ошибок, но каждая из них создавала дискомфорт в той или иной мере. В последнее время я нащупал оптимальный вариант, о котором и хочу рассказать, но с начала расскажу о двух наиболее популярных вариантах.

1: HTTP статусы

Если почитать апологетов REST, то для кодов ошибок надо использовать HTTP статусы, а текст ошибки отдавать в теле или в специальном заголовке. Например:

Success:

HTTP 200 GET /v1/user/1Body: { name: 'Вася' }

Error:

HTTP 404 GET /v1/user/1Body: 'Не найден пользователь'

Если у вас примитивная бизнес-логика или API из 5 url, то в принципе это нормальный подход. Однако как-только бизнес-логика станет сложнее, то начнется ряд проблем.

Http статусы предназначались для описания ошибок при передаче данных, а про логику вашего приложения никто не думал. Статусов явно не хватает для описания всего разнообразия ошибок в вашем проекте, да они и не были для этого предназначены. И тут начинается натягивание "совы на глобус": все начинают спорить, какой статус ошибки дать в том или ином случае. Пример: Есть API для task manager. Какой статус надо вернуть в случае, если пользователь хочет взять задачу, а ее уже взял в работу другой пользователь? Ссылка на http статусы. И таких проблемных примеров можно придумать много.

REST скорее концепция, чем формат общения из чего следует неоднозначность использования статусов. Разработчики используют статусы как им заблагорассудится. Например, некоторые API при отсутствии сущности возвращают 404 и текст ошибки, а некоторые 200 и пустое тело.

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

Когда бизнес-логика приложения усложняется, начинают делать как-то так:

HTTP 400 PUT /v1/task/1 { status: 'doing' }Body: { error_code: '12', error_message: 'Задача уже взята другим исполнителем' } 

Из-за ограниченности http статусов разработчики начинают вводить свои коды ошибок для каждого статуса и передавать их в теле ответа. Другими словами, пользователю API приходиться писать нечто подобное:

if (status === 200) {  // Success} else if (status === 500) {  // some code} else if (status === 400) {  if (body.error_code === 1) {    // some code  } else if (body.error_code === 2) {    // some code  } else {    // some code  }} else if (status === 404) {  // some code} else {  // some code}

Из-за этого ветвление клиентского кода начинает стремительно расти: множество http статусов и множество кодов в самом сообщении. Для каждого ошибочного http статуса необходимо проверить наличие кодов ошибок в теле сообщения. От комбинаторного взрыва начинает конкретно пухнуть башка! А значит обработку ошибок скорее всего сведут к сообщению типа Произошла ошибка или к молчаливому некорректному поведению.

Многие системы мониторинга сервисов привязываются к http статусам, но это не помогает в мониторинге, если статусы используются для описания ошибок бизнес логики. Например, у нас резкий всплеск ошибок 429 на графике. Это началась DDOS атака, или кто-то из разработчиков выбрал неудачный статус?

Итог: Начать с таким подходом легко и просто и для простого API это вполне подойдет. Но если логика стала сложнее, то использование статусов для описания того, что не укладывается в заданные рамки протокола http приводит к неоднозначности использования и последующим костылям для работы с ошибками. Или что еще хуже к формализму, что ведет к неприятному пользовательскому опыту.

2: На все 200

Есть другой подход, даже более старый, чем REST, а именно: на все ошибки связанные с бизнес-логикой возвращать 200, а уже в теле ответа есть информация об ошибке. Например:

Вариант 1:

Success:HTTP 200 GET /v1/user/1Body: { ok: true, data: { name: 'Вася' } }Error:HTTP 200 GET /v1/user/1Body: { ok: false, error: { code: 1, msg: 'Не найден пользователь' } }

Вариант 2:

Success:HTTP 200 GET /v1/user/1Body: { data: { name: 'Вася' }, error: null }Error:HTTP 200 GET /v1/user/1Body: { data: null, error: { code: 1, msg: 'Не найден пользователь' } }

На самом деле формат зависит от вас или от выбранной библиотеки для реализации коммуникации, например JSON-API.

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

module.exports = {  NOT_FOUND: 1,  VALIDATION: 2, // .}module.exports = {  NOT_FOUND: NOT_AUTHORIZED,  VALIDATION: VALIDATION, // .}

Клиентские разработчики просто основываясь на кодах ошибок могут создать классы/типы ошибок и притом не бояться, что сервер вернет один и тот же код для разных типов ошибок (из-за бедности http статусов).

Обработка ошибок становится менее ветвящейся, множество http статусов превратились в два: 200 и все остальные (ошибки транспорта).

if (status === 200) {  if (body.error) {    var error = body.error;    if (error.code === 1) {      // some code    } else if (error.code === 2) {      // some code    } else {      // some code    }  } else {    // Success  }} else {  // transport erros}

В некоторых случаях, если есть библиотека десериализации данных, она может взять часть работы на себя. Писать SDK вокруг такого подхода проще нежели вокруг той или иной имплементации REST, ведь реализация зависит от того, как это видел автор. Кроме того, теперь никто не вызовет случайное срабатывание alert в мониторинге из-за того, что выбрал неудачный код ошибки.

Но неудобства тоже есть:

  • Избыточность полей при передаче данных, т.е. нужно всегда передавать 2 поля: для данных и для ошибки. Это усложняет чтение логов и написание документации.

  • При использовании средств отладки (Chrome DevTools) или других подобных инструментов вы не сможете быстро найти ошибочные запросы бизнес логики, придется обязательно заглянуть в тело ответа (ведь всегда 200)

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

В некоторых случаях данный подход вырождается в RPC, то есть по сути вообще отказываются от использования url и шлют все на один url методом POST, а в теле сообщения передают все параметры. Мне кажется это не правильным, ведь url это прекрасный именованный namespace, зачем от этого отказываться, не понятно?! Кроме того, RPC создает проблемы:

  • нельзя кэшировать по http GET запросы, так как замешали чтение и запись в один метод POST

  • нельзя делать повторы для неудавшихся GET запросов (на backend) на реверс-прокси (например, nginx) по указанной выше причине

  • имеются проблемы с документированием swagger и ApiDoc не подходят, а удобных аналогов я не нашел

Итог: Для сложной бизнес-логики с большим количеством типов ошибок такой подход лучше, чем расплывчатый REST, не зря в проектах c разухабистой бизнес-логикой часто именно такой подход и используют.

3: Смешанный

Возьмем лучшее от двух миров. Мы выберем один http статус, например, 400 или 422 для всех ошибок бизнес-логики, а в теле ответа будем указывать код ошибки или строковую константу. Например:

Success:

HTTP 200 /v1/user/1Body: { name: 'Вася' }

Error:

HTTP 400 /v1/user/1Body: { error: { code: 1, msg: 'Не найден пользователь' } }

Коды:

  • 200 успех

  • 400 ошибка бизнес логики

  • остальное ошибки в транспорте

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

if (status === 200) {  // Success} else if (status === 400) {  if (body.error.code === 1) {    // some code  } else if (body.error.code === 2) {    // some code  } else {    // some code  }} else {  // transport erros}

Мы можем расширять объект ошибки для детализации проблемы, если хотим. С мониторингом все как во втором варианте, дописывать парсинг придется, но и риска стрельбы некорректными alert нету. Для документирования можем спокойно использовать Swagger и ApiDoc. При этом сохраняется удобство использования инструментов разработчика, таких как Chrome DevTools, Postman, Talend API.

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

Самое главное какой бы формат ошибок вы бы не выбрали лучше обговорить его заранее и следовать ему. Если эту вещь пустить на самотек, то очень скоро обработка ошибок в проекте станет невыносимо сложной для всех.

P.S. Иногда ошибки любят передавать массивом

{ error: [{ code: 1, msg: 'Не найден пользователь' }] }

Но это актуально в основном в двух случаях:

  • Когда наш API выступает в роли сервиса без фронтенда (нет сайта/приложения). Например, сервис платежей.

  • Когда в API есть url для загрузки какого-нибудь длинного отчета в котором может быть ошибка в каждой строке/колонке. И тогда для пользователя удобнее, чтобы ошибки в приложении сразу показывались все, а не по одной.

В противном случае нет особого смысла закладываться сразу на массив ошибок, потому что базовая валидация данных должна происходить на клиенте, зато код упрощается как на сервере, так и на клиенте. А user-experience хакеров, лезущих напрямую в наше API, не должен нас волновать?HTTP

Подробнее..

Основы Flutter для начинающих (Часть V)

04.06.2021 10:04:57 | Автор: admin

Наконец-то мы добрались до одной из самых важных тем, без которой идти дальше нет смысла.

План довольно простой: нам предстоит познакомиться с клиент-серверной архитектурой и реализовать получение списка постов.

В конце мы правильно организуем файлы наших страниц и вынесем элемент списка в отдельный файл.

Полетели!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5 (текущая статья) - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 - работа с формами, текстовые поля и создание поста.

  • Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 - немного о тестировании;

Client и Server

Модель Client / Server лежит в основе всего Интернета и является наиболее распространенной.

В чем её суть?

Сначала разберемся что такое клиент и сервер:

  • Клиент - пользовательское устройство, которое отправляет запросы за сервер и получает ответы. Это может быть смартфон, компьютер или MacBook.

  • Сервер - специальный компьютер, который содержит данные, необходимые для пользователя.

Вся модель сводиться к примитивному принципу: клиент отправил запрос, сервер принял его, обработал и передал ответ клиенту.

Для организации взаимодействия сервера и клиента используются специальные протоколы. На текущий момент одним из самых распространенных протоколов в сети Интернет является http / https (s означает защищенный, secure).

http / https позволяет передавать почти все известные форматы данных: картинки, видео, текст.

Мы будем работать с JSON форматом.

JSON - простой и понятный формат данных, а главное легковесный, т.к. передается только текст.

Пример JSON:

[  {    "userId": 1,    "id": 1,    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"  },  {    "userId": 1,    "id": 2,    "title": "qui est esse",    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"  },  ...]  

Здесь массив постов, который мы будем получать от сервера.

Обратите внимание: квадратные скобки указывает на массив данных, а фигурные на отдельный объект.

JSON позволяет создавать глубокую вложенность объектов и массивов:

{  "total_items" : 1  "result" : [  {  "id" : 1,  "name" : "Twillight Sparkle",  "pony_type" : "alicorn",  "friends" : [  "Starlight Glimmer", "Applejack", "Rarity", "Spike"  ]}  ]}

Понятие запроса

Для обмена данными клиент должен отправлять запросы на сервер.

Т.к. интернет в большинстве случаев использует http / https то запросы называются HTTP запросами.

Структура HTTP запроса:

  • URL - уникальный адрес в Интернете, который идентифицирует сервер и его конкретный ресурс, данные которого мы собираемся получить. В нашем случае URL выглядит следующим образом: https://jsonplaceholder.typicode.com/posts. (об структуре самого URL'а можно почитать в Википедии)

  • Метод, который определяет типа запроса. GET используется только для получения данных, POST позволяет клиенту добавить свои данные на сервер, DELETE - удалить их, PUT - изменить.

  • Данные запроса обычно называются телом запроса и используются совместно с POST, PUT и DELETE методами. Для GET метода в основном используются параметры самого URL'а. Выглядит это следующим образом: https://jsonplaceholder.typicode.com/posts/1 (здесь мы обращаемся к конкретному посту по его id = 1)

Запрос и вывод списка постов

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

Сначала убедимся, что мы указали его в pubspec.yaml файле:

# блок зависимостейdependencies:  flutter:    sdk: flutter  # подключение необходимых pub-пакетов  # используется для произвольного размещения  # компонентов в виде сетки  flutter_staggered_grid_view: ^0.4.0  # мы будем использовать MVC паттерн  mvc_pattern: ^7.0.0  # http предоставляет удобный интерфейс для создания# запросов и обработки ошибок  http: ^0.13.3

Переходим к созданию классов модели.

Для этого создайте файл post.dart в папке models:

// сначала создаем объект самого постаclass Post {  // все поля являются private  // это сделано для инкапсуляции данных  final int _userId;  final int _id;  final String _title;  final String _body;    // создаем getters для наших полей  // дабы только мы могли читать их  int get userId => _userId;  int get id => _id;  String get title => _title;  String get body => _body;  // Dart позволяет создавать конструкторы с разными именами  // В данном случае Post.fromJson(json) - это конструктор  // здесь мы принимаем JSON объект поста и извлекаем его поля  // обратите внимание, что dynamic переменная   // может иметь разные типы: String, int, double и т.д.  Post.fromJson(Map<String, dynamic> json) :    this._userId = json["userId"],    this._id = json["id"],    this._title = json["title"],    this._body = json["body"];}// PostList являются оберткой для массива постовclass PostList {  final List<Post> posts = [];  PostList.fromJson(List<dynamic> jsonItems) {    for (var jsonItem in jsonItems) {      posts.add(Post.fromJson(jsonItem));    }  }}// наше представление будет получать объекты// этого класса и определять конкретный его// подтипabstract class PostResult {}// указывает на успешный запросclass PostResultSuccess extends PostResult {  final PostList postList;  PostResultSuccess(this.postList);}// произошла ошибкаclass PostResultFailure extends PostResult {  final String error;  PostResultFailure(this.error);}// загрузка данныхclass PostResultLoading extends PostResult {  PostResultLoading();}

Одной из наиболее неприятных проблем является несоответствие типов.

Если взглянуть на JSON объект поста:

{  "userId": 1,  "id": 1,  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

То можно заметить, что userId и id являются целыми числами, а title и body строками, поэтому в конструкторе Post.fromJson(json) мы не замарачиваемся с привидением типов.

Пришло время создать Repository класс.

Для этого создадим новую папку data и в нем файл repository.dart:

import 'dart:convert';// импортируем http пакетimport 'package:http/http.dart' as http;import 'package:json_placeholder_app/models/post.dart';// мы ещё не раз будем использовать // константу SERVERconst String SERVER = "https://jsonplaceholder.typicode.com";class Repository {  // обработку ошибок мы сделаем в контроллере  // мы возвращаем Future объект, потому что  // fetchPhotos асинхронная функция  // асинхронные функции не блокируют UI  Future<PostList> fetchPosts() async {    // сначала создаем URL, по которому    // мы будем делать запрос    final url = Uri.parse("$SERVER/posts");    // делаем GET запрос    final response = await http.get(url);// проверяем статус ответаif (response.statusCode == 200) {  // если все ок то возвращаем посты  // json.decode парсит ответ   return PostList.fromJson(json.decode(response.body));} else {  // в противном случае говорим об ошибке  throw Exception("failed request");}  }}

Вы скажите: мы могли все запихнуть в контроллер, зачем создавать ещё один класс?

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

К тому же это не очень гибко. Вдруг нам нужно будет поменять URL адрес сервера.

Реализуем PostController:

import '../data/repository.dart';import '../models/post.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostController extends ControllerMVC {  // создаем наш репозиторий  final Repository repo = new Repository();  // конструктор нашего контроллера  PostController();    // первоначальное состояние - загрузка данных  PostResult currentState = PostResultLoading();  void init() async {    try {      // получаем данные из репозитория      final postList = await repo.fetchPosts();      // если все ок то обновляем состояние на успешное      setState(() => currentState = PostResultSuccess(postList));    } catch (error) {      // в противном случае произошла ошибка      setState(() => currentState = PostResultFailure("Нет интернета"));    }  }}

Заключительная часть: подключим наш контроллер к представлению и выведем посты:

import 'package:flutter/material.dart';import '../controllers/post_controller.dart';import '../models/post.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostListPage extends StatefulWidget {  @override  _PostListPageState createState() => _PostListPageState();}// не забываем расширяться от StateMVCclass _PostListPageState extends StateMVC {  // ссылка на наш контроллер  PostController _controller;  // передаем наш контроллер StateMVC конструктору и  // получаем на него ссылку  _PostListPageState() : super(PostController()) {    _controller = controller as PostController;  }  // после инициализации состояния  // мы запрашивает данные у сервера  @override  void initState() {    super.initState();    _controller.init();  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post List Page"),      ),      body: _buildContent()    );  }  Widget _buildContent() {    // первым делом получаем текущее состояние    final state = _controller.currentState;    if (state is PostResultLoading) {      // загрузка      return Center(        child: CircularProgressIndicator(),      );    } else if (state is PostResultFailure) {      // ошибка      return Center(        child: Text(          state.error,          textAlign: TextAlign.center,          style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)        ),      );    } else {      // отображаем список постов      final posts = (state as PostResultSuccess).postList.posts;      return Padding(        padding: EdgeInsets.all(10),        // ListView.builder создает элемент списка        // только когда он видим на экране        child: ListView.builder(          itemCount: posts.length,          itemBuilder: (context, index) {            return _buildPostItem(posts[index]);          },        ),      );    }  }  // элемент списка   Widget _buildPostItem(Post post) {    return Container(        decoration: BoxDecoration(            borderRadius: BorderRadius.all(Radius.circular(15)),            border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)        ),        margin: EdgeInsets.only(bottom: 10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Container(              decoration: BoxDecoration(                borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),                color: Theme.of(context).primaryColor,              ),              padding: EdgeInsets.all(10),              child: Text(                post.title,                textAlign: TextAlign.left,                style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),            ),            Container(              child: Text(                post.body,                style: Theme.of(context).textTheme.bodyText2,              ),              padding: EdgeInsets.all(10),            ),          ],        )    );  }}

Не пугайтесь если слишком много кода.

Все сразу освоить невозможно, поэтому не спешите)

Запуск

Попробуем запустить:

Вуаля! Теперь отключим интернет:

Все работает!

Небольшая заметка

Одним из важных принципов программирования является стремление к минимизации кода и его упрощению.

Файл post_list_page.dart содержит всего 110 строк кода, это не проблема. Но если бы он был в 10 или даже в 20 раз больше!

Какой ужас был бы на глазах у того, кто взглянул бы на него.

Лучшей практикой считается выносить повторяющие фрагменты кода в отдельные файлы.

Давайте попробуем вынести функцию Widget _buildItem(post) в другой файл.

Для этого создадим для каждой группы страниц свою папку:

Затем в папке post создадим новый файл post_list_item.dart:

import 'package:flutter/material.dart';import '../../models/post.dart';// элемент спискаclass PostListItem extends StatelessWidget {    final Post post;    // элемент списка отображает один пост  PostListItem(this.post);    Widget build(BuildContext context) {    return Container(        decoration: BoxDecoration(            borderRadius: BorderRadius.all(Radius.circular(15)),            border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)        ),        margin: EdgeInsets.only(bottom: 10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Container(              decoration: BoxDecoration(                borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),                color: Theme.of(context).primaryColor,              ),              padding: EdgeInsets.all(10),              child: Text(                post.title,                textAlign: TextAlign.left,                style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),            ),            Container(              child: Text(                post.body,                style: Theme.of(context).textTheme.bodyText2,              ),              padding: EdgeInsets.all(10),            ),          ],        )    );  }}

Не забудьте удалить ненужный код из post_list_page.dart:

import 'package:flutter/material.dart';import '../../controllers/post_controller.dart';import '../../models/post.dart';import 'post_list_item.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostListPage extends StatefulWidget {  @override  _PostListPageState createState() => _PostListPageState();}// не забываем расширяться от StateMVCclass _PostListPageState extends StateMVC {  // ссылка на наш контроллер  PostController _controller;  // передаем наш контроллер StateMVC конструктору и  // получаем на него ссылку  _PostListPageState() : super(PostController()) {    _controller = controller as PostController;  }  // после инициализации состояние  // мы запрашивает данные у сервера  @override  void initState() {    super.initState();    _controller.init();  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post List Page"),      ),      body: _buildContent()    );  }  Widget _buildContent() {    // первым делом получаем текущее состояние    final state = _controller.currentState;    if (state is PostResultLoading) {      // загрузка      return Center(        child: CircularProgressIndicator(),      );    } else if (state is PostResultFailure) {      // ошибка      return Center(        child: Text(          state.error,          textAlign: TextAlign.center,          style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)        ),      );    } else {      // отображаем список постов      final posts = (state as PostResultSuccess).postList.posts;      return Padding(        padding: EdgeInsets.all(10),        // ListView.builder создает элемент списка        // только когда он видим на экране        child: ListView.builder(          itemCount: posts.length,          itemBuilder: (context, index) {            // мы вынесли элемент списка в            // отдельный виджет            return PostListItem(posts[index]);          },        ),      );    }  }  }

Заключение

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

Я постарался кратко рассказать и показать на наглядном примере работу с сетью.

Надеюсь моя статья принесла вам пользу)

Ссылка на Github

Всем хорошего кода!

Подробнее..

Откуда берется заголовок Content-Type nginx php-fpm

25.10.2020 10:22:20 | Автор: admin
Rocket science не будет. Если вы используете php-fpm, то скорее всего в связке с nginx. Простой вопрос: как в PHP получить значения HTTP заголовков запроса клиента?

1. Например, стандартные Accept, Host или Referer?
2. Знаете? Здорово! А как получить значение Content-Type, Content-Length?
3. Ничем вас не удивить, а как получить значение произвольного заголовка, например X-Forwarded-For?

image



Как в PHP получить значения HTTP заголовков входящего запроса?


Всё очень просто (табличка сарказм). Нужно перейти на страницу документации переменной $_SERVER.
Переменная $_SERVER это массив, содержащий информацию, такую как заголовки, пути и местоположения скриптов. Записи в этом массиве создаются веб-сервером.
Нет гарантии, что каждый веб-сервер предоставит любую из них;
сервер может опустить некоторые из них или предоставить другие, не указанные здесь.
Тем не менее многие эти переменные присутствуют в спецификации CGI/1.1,
так что вы можете ожидать их наличие.


Согласитесь звучит не очень обнадеживающе? Складывается ощущение, что это переменные Шрёдингера. На странице документации приводится ответ на первый вопрос.
$_SERVER['HTTP_ACCEPT']$_SERVER['HTTP_HOST']$_SERVER['HTTP_REFERER']


Ок, вроде бы всё просто, хоть на странице документации и не сказано про CONTENT_TYPE (правда есть небольшая подсказка комментария 2013 года), попробуем получить значение по аналогии.
$_SERVER['HTTP_CONTENT_TYPE']
К сожалению, такого ключа в массиве нет :(

Ну да ладно, давайте посмотрим спецификацию CGI/1.1.

4.1.3. CONTENT_TYPE
If the request includes a message-body, the CONTENT_TYPE variable is
set to the Internet Media Type [6] of the message-body.

//

There is no default value for this variable. If and only if it is
unset, then the script MAY attempt to determine the media type from
the data received. If the type remains unknown, then the script MAY
choose to assume a type of application/octet-stream or it may reject
the request with an error (as described in section 6.3.3).

//

The server MUST set this meta-variable if an HTTP Content-Type field
is present in the client request header. If the server receives a
request with an attached entity but no Content-Type header field, it
MAY attempt to determine the correct content type, otherwise it
should omit this meta-variable.


Мы узнали ответ на второй вопрос.
$_SERVER['CONTENT_TYPE']$_SERVER['CONTENT_LENGTH']


Перейдём к 3-му вопросу, продолжив чтение спецификации.
4.1.18. Protocol-Specific Meta-Variables

The server SHOULD set meta-variables specific to the protocol and
scheme for the request. Interpretation of protocol-specific
variables depends on the protocol version in SERVER_PROTOCOL. The
server MAY set a meta-variable with the name of the scheme to a
non-NULL value if the scheme is not the same as the protocol. The
presence of such a variable indicates to a script which scheme is
used by the request.

Meta-variables with names beginning with HTTP_ contain values read
from the client request header fields, if the protocol used is HTTP.
The HTTP header field name is converted to upper case, has all
occurrences of "-" replaced with "_" and has HTTP_ prepended to
give the meta-variable name.
The header data can be presented as
sent by the client, or can be rewritten in ways which do not change
its semantics. If multiple header fields with the same field-name
are received then the server MUST rewrite them as a single value
having the same semantics. Similarly, a header field that spans
multiple lines MUST be merged onto a single line. The server MUST,
if necessary, change the representation of the data (for example, the
character set) to be appropriate for a CGI meta-variable.

The server is not required to create meta-variables for all the
header fields that it receives. In particular, it SHOULD remove any
header fields carrying authentication information, such as
'Authorization'; or that are available to the script in other
variables, such as 'Content-Length' and 'Content-Type'.
The server
MAY remove header fields that relate solely to client-side
communication issues, such as 'Connection'.


А вот и ответ на 3-ий вопрос.
$_SERVER['HTTP_X_FORWARDED_FOR']
Тут же мы узнали, что спецификация просит не заполнять $_SERVER['HTTP_CONTENT_TYPE'], а использовать $_SERVER['CONTENT_TYPE'].

Как Content-Type попадет в переменную $_SERVER['CONTENT_TYPE']?


Перейдём ко второй части. Копнём чуть глубже, и посмотрим как веб-сервер (nginx) заполняет данными php массив $_SERVER.

Допустим мы решили поднять nginx + php-fpm через docker-compose
docker-compose.yaml
version: '3'services:  nginx_default_fastcgi_params:    image: nginx:1.18    volumes:      - ./app/public:/var/www/app/public:rw      - ./docker/nginx_default_fastcgi_params/app.conf:/etc/nginx/conf.d/app.conf:rw  php-fpm:    build:      context: docker      dockerfile: ./php-fpm/Dockerfile    volumes:      - ./app:/var/www/app:rw



Примерно так будет выглядеть nginx конфиг app.conf
server {    listen 81;    server_name server1.local;    root /var/www/app/public;    location / {        try_files $uri /index.php$is_args$args;    }    location ~ ^/index\.php {        fastcgi_pass php-fpm:9000;        fastcgi_split_path_info ^(.+\.php)(/.*)$;        # file location /etc/nginx/fastcgi_params        include fastcgi_params;        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;    }    error_log /var/log/nginx/app_error.log;    access_log /var/log/nginx/app_access.log;}

Здесь нужно обратить внимание на строчку include fastcgi_params;. Она подключает файл /etc/nginx/fastcgi_params, который выглядит примерно так
fastcgi_param  QUERY_STRING       $query_string;fastcgi_param  REQUEST_METHOD     $request_method;fastcgi_param  CONTENT_TYPE       $content_type;fastcgi_param  CONTENT_LENGTH     $content_length;fastcgi_param  SCRIPT_NAME        $fastcgi_script_name;fastcgi_param  REQUEST_URI        $request_uri;fastcgi_param  DOCUMENT_URI       $document_uri;fastcgi_param  DOCUMENT_ROOT      $document_root;fastcgi_param  SERVER_PROTOCOL    $server_protocol;fastcgi_param  REQUEST_SCHEME     $scheme;fastcgi_param  HTTPS              $https if_not_empty;fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;fastcgi_param  SERVER_SOFTWARE    nginx/$nginx_version;fastcgi_param  REMOTE_ADDR        $remote_addr;fastcgi_param  REMOTE_PORT        $remote_port;fastcgi_param  SERVER_ADDR        $server_addr;fastcgi_param  SERVER_PORT        $server_port;fastcgi_param  SERVER_NAME        $server_name;# PHP only, required if PHP was built with --enable-force-cgi-redirectfastcgi_param  REDIRECT_STATUS    200;


В этом месте как раз заполняется $_SERVER['CONTENT_TYPE']. А так же остальные значения указанные в спецификации image.

И последний вопрос: Как остальные HTTP заголовки, например User-Agent попадают от nginx к php-fpm?

Всё просто, документация nginx даёт ответ.

Parameters Passed to a FastCGI Server
HTTP request header fields are passed to a FastCGI server as parameters. In applications and scripts running as FastCGI servers, these parameters are usually made available as environment variables. For example, the User-Agent header field is passed as the HTTP_USER_AGENT parameter. In addition to HTTP request header fields, it is possible to pass arbitrary parameters using the fastcgi_param directive.


Заметьте, здесь сказано, что HTTP заголовки передаются в приложение как HTTP_*. Но на самом деле два заголовка Content-Type и Content-Length, передаются по другому. Я бы назвал это ошибкой документации, но в ней есть слово usually, поэтому не будем придираться.

Выводы


1) Чтобы в php получить значение заголовка Content-Type/Content-Length нужно использовать $_SERVER['CONTENT_TYPE']/$_SERVER['CONTENT_LENGTH']. Для всех остальных заголовков $_SERVER['HTTP_*']
2) Я не знаю причину почему CGI выделил логику заголовков Content-Type/Content-Length. Возможно, для этого была весомая причина. Но результатом является куча неправильного кода программистов :(
Например на stackoverflow советуют вот так получить все HTTP заголовки
function getRequestHeaders() {    $headers = array();    foreach($_SERVER as $key => $value) {        if (substr($key, 0, 5) <> 'HTTP_') {            continue;        }        $header = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));        $headers[$header] = $value;    }    return $headers;}

Как не сложно заметить, заголовки Content-Type/Content-Length данный код не вернет. При этом ответ имеет 350+ лайков.
Похожий код можно найти и в документации php
<?phpif (!function_exists('getallheaders')){    function getallheaders()    {           $headers = [];       foreach ($_SERVER as $name => $value)       {           if (substr($name, 0, 5) == 'HTTP_')           {               $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;           }       }       return $headers;    }}
Подробнее..
Категории: Php , Nginx , Http , Php-fpm , Content-type , Fastcgi

API для генерации ответов сервера с любыми кодами статусов

20.07.2020 14:14:41 | Автор: admin

Привет, Хабр! Работая над библиотекой-обёрткой REST API, я столкнулся с проблемой. Для тестирования обработки ошибочных кодов ответа сервера (400, 500, 403 и т.д.) необходимо искусственно создавать условия на сервере для получения соответствующих кодов. При правильно настроенном сервере, например, непросто получить ошибку 500. А тестировать функции-обработчики ошибок как-то надо. Я написал небольшое API, которое генерирует ошибочные ответы сервера httpme.tk


Как применять в тестировании?


Например, есть такой код (python3):


from requests import session as requests_sessionsession = requests_session()session.hooks = {    'response': lambda r, *args, **kwargs: raise AccessError('Доступ закрыт, т.к. сервер подключен к другой БД') if r.status_code == 403  else pass}class AccessError(Exception):    """ 'своя' ошибка """    passdef getter(url):    return session.get(url)

Если кратко в коде есть функция, которая возвращает ответ сервера на GET-запрос на заданный URL, если в результате выполнения запроса возникает ошибка 403 вызывается внутреннее исключение модуля AccessError.


Этот код надо протестировать и отладить. Cоздать вручную условия для ошибки 403, а уж тем более, например, 500 (сервер слишком хорошо работает) довольно непросто. Тестировщику не важно, при каких условиях сервер выдаст ошибку 403: он тестирует не само API (например), а функцию, которая к нему обращается. Поэтому для тестирования вызова исключения при коде статуса 403 он может сделать вот так (python3 + pytest):


import pytestfrom mymodule import def test_forbidden():    with pytest.raises(AccessError):        getter('http://httpme.tk/403')

Как пользоваться?


Очень просто. Отправьте на сервер GET-запрос в формате http://httpme.tk/<status_code>. Например так (cURL):


curl -G http://httpme.tk/500

Или так (python3):


from requests import getget('http://httpme.tk/408')  # <Response [408]>

А что внутри?


А внутри маленькое Flask-приложение, вызывающее функцию abort(status_code) на каждый запрос.


Ссылка на GitHub


На этом всё!


Интересно услышать оценку полезности данного сервиса сообществом.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru