Backend ping что это
![]()
30 июл. 2022 в 4:49
Backend ping. legal cheat. GJ Crytek.
Good day to everyone. I apologize right away, but the text is typed in the translator, since my knowledge of English does not allow me to express my thoughts correctly in this language. So let’s get started. It all started at the beginning of this year, when I downloaded one of the VPN services to improve the connection with the servers, I found that my backend ping dropped from 130 to 20-50, depending on the region. The VPN Accelerator function was responsible for this miracle of reducing the backend ping. Now you can get to the bottom of it: the lower the backend ping, the «truer» the picture on your monitor.
For example, you have a backend ping of 120-130, you see a hunter who is standing sideways to you. You stop, aim at the head and . die, because there was a player with a backend of 30-40 in front of you and he heard you, in fact, he already turned on you, and calmly killed. With a high backend, you see a picture that is 0.5-1 seconds behind the present one. Sometimes it even seems that more. So, the playable backend starts from 110 and below.
Now, I am not able to use that VPN service. And it hurts. Watch as they kill you on the run (given that the game does not provide shooting on the run), they kill without looking at you. The developers also answer that backend ping does not affect the game. I would suggest them to try to play quickplay and hunting with a backend ping of 130 or more, on European servers. I have a good computer with 180+ stable frames at 2k resolution. I have a good 180hz monitor with a low response. I have a normal reaction time. My KD is more than 2 in hunting and more than 3 in quickplay with an aggressive style of play. So, tell me, if backend ping does not affect gameplay, why do I see a difference? Why, when I played with the 20-50 backend, I fought on equal terms with top streamers who have 20k+ kills. And now with a backend of 130, it seems unreal. Am I living in the past? What are cheats in online projects? — hacking a game or a program that gives you an advantage over other players.
So maybe the developers can somehow solve this problem? In my region, I have a ping from 17 to 30 and a backend ping from 125 to 140. As you know, with the guys and girls from our region, we have a great time in this game. But when Europeans with low backendping get into our region, it’s a pain. We need to do more pre-emption, we can’t even stop for a second, we just need to become the god of this game. /
If a VPN with a certain function can lower the backend ping, then in theory it is real? Or can we increase it for everyone by an average value?
I just wanted to speak out. And in advance, if someone says that the backend ping does not affect, then I assure you, you will definitely not convince me, since I have checked and checked the difference of 130+ backend and 20-50 for enough time.
Как мы избавились от пинг-понга задачами между разработкой и QA
Как показалось из рассказа и как я знаю из собственной практики, секрет успеха в выравнивания квалификаций обеих команд. В данном случае через навязывание правильных софт-скиллов, отказ от философии «моя хата с краю» и вовлечение в планирование. Пинг-понг маловероятен, если и разработчики и QAшники в среднем имеют миддл+ скилл.
Всего голосов 1: ↑1 и ↓0 +1
Ответить Добавить в закладки Ещё
Показать предыдущий комментарий
Привет, спасибо за ответ. Если под мидл+ идет soft skills — то да согласен =) Ключевой здесь момент — качать софты — это относится к процессам типа груммингов, планирований, да и просто построения команды и меж-командных коммуникаций со стороны ребят. В командах с развитыми софтами и джуны хорошо и гладко вливаются в процессы, а потом и растут быстрее — проверено как раз на этой же команде.
Комментарий пока не оценивали 0
Ответить Добавить в закладки Ещё
«Проблема заключается в том, что в такой системе случается пинг-понг задачами. У нас такое бывало. Разработка передаёт задачу в QA, QA её проверяет и возвращает разработке с багами. Разработчик правит эти баги, снова отправляет задачку в QA, а QA находит новые баги.»
А где тут пинг-понг? Стандартный рабочий процесс. Найдены дефекты по задачи — задача возвращается на доработку.
«Порядка 30% задач могли возвращаться с уточнением требований.»
Похоже на проблемы с аналитикой, формализацией требований задачи. У вас нет аналитиков в команде?
Комментарий пока не оценивали 0
Ответить Добавить в закладки Ещё
Показать предыдущий комментарий
Действительно аналитиков в команде нет, но это не мешает на этапе грумминга уточнить все требования — если подключать тестирование с этого этапа активно — не зря одним из видов тестирования является тестирование требований. Пинг-понгом мы называем ситуацию когда одна юзер стори может кататься между разработкой и тестированием более 2-3 раз.
Всего голосов 1: ↑1 и ↓0 +1
Ответить Добавить в закладки Ещё
Показать предыдущий комментарий
ну вот, появился этап анализа задачи (пусть и смлами QA ) сразу упал процент до 10%
Всего голосов 1: ↑1 и ↓0 +1
Ответить Добавить в закладки Ещё
А как организованы команды? Они кросс-функциональны или есть отдельно фронт, бэк и т.д.?
Комментарий пока не оценивали 0
Ответить Добавить в закладки Ещё
Показать предыдущий комментарий
В основном команды бьются backend/frontend, есть несколько кросс-функциональных. Конкретно в статье речь идет о backend команде, единственная часть фронта у команды — админка для внутренних клиентов (менеджеры/техподдержка). Обычно в команде присутствует tl + devs + qas + automation отдельно идут scrum master и PO (работают с командой очень плотно но относятся к другим веткам/отделам). Сейчас есть стремление qa + automation перевести в этакий qa fullstack, чтобы один инженер писал тест-кейсы и автоматизировал их в то же время — это позволит повысить вовлеченность и ускорить покрытие.
Основы Linux
Также есть три категории пользователей, для которых вы можете установить эти права на файл linux:
Наиболее популярен восьмеричный формат задания прав:
- 744 — разрешить все для владельца, а остальным только чтение;
- 755 — все для владельца, остальным только чтение и выполнение;
- 764 — все для владельца, чтение и запись для группы, и только чтение для остальных;
- 777 — всем разрешено все.
- chown — Изменяющая владельца и/или группу для указанных файлов. Только суперпользователь может изменять владельцев. Для рекурсивного изменения используйте опцию -R. Поменять владельца для strace.log в ‘rob’ и идентификатор группы в developers: chown rob:developers strace.log
- find — Поиск в файловой системе, файлов и папок. Это очень гибкая и мощная команда Linux не только из-за своих возможностей поиска, но и благодаря возможности выполнять произвольные команды для найденных файлов.
locate
В отличие от find ведет поиск в базе данных updatedb, для шаблонов имен файлов. Эта база данных содержит снимок файловой системы, что позволяет искать очень быстро. Но этот поиск ненадежен, потому что вы не можете быть уверены, что ничего не изменилось с момента последнего снимка.
du
(disk usage) Показать размер файла или каталога. Одни из наиболее полезных опций — h (Human), которая преобразует размеры файлов в легко читаемый формат, -s (Summarize) выводит минимум данных и -d (Depth) — устанавливает глубину рекурсии по каталогам.
- –b выводит информацию в байтах(а не в килобайтах).
- –c выводит итоговую информацию об использовании дисковой памяти.
- –k выводит информацию в килобайтах(по умолчанию).
- –s выводит итоговую информацию об использовании дискового пространства без информации о каталогах.
- -h вывести информацию в человекочитаемом виде.
- -d (Depth) — устанавливает глубину рекурсии по каталогам.
df
df(disk free) — позволяет узнать размер свободного и занятого пространства во всех смонтированных файловых системах.
- -a выводит информацию обо всех файловых системах.
- -h выводит размеры в удобном для человека виде (мегабайты, килобайты, гигабайты и т.д.)
- -T показать тип файловой системы.
- -t выводит информацию только об указанных типах файловых систем.
dd
Как сказано в официальном руководстве, это команда терминала для копирования и преобразования файлов. Не очень понятное описание, но это все что делает dd. Вы передаете ей файл-источник и пункт назначения, и пару дополнительных опций. Затем она делает копию одного файла в другой. Вы можете задать точный размер данных, которые нужно записать или скопировать. Работает утилита со всеми устройствами. Например, если вы хотите перезаписать жесткий диск нулями из /dev/zero, можете сделать это. Также она часто используется для создания LiveUSB или гибридных ISO образов.
mount /umount
Это команды консоли Linux для подключения и отключения файловых систем Linux. Можно подключать все, от USB накопителей, до ISO образов. И только у суперпользователя есть права для этого.
Работа с текстом
more/less
Это две простенькие команды терминала, для просмотра длинных текстов, которые не вмещаются на одном экране. Представьте себе очень длинный вывод команды. Или вы вызвали cat для просмотра файла и вашему эмулятору терминала потребовалось несколько секунд, чтобы прокрутить весь текст. Если ваш терминал не поддерживает прокрутки, вы можете сделать это с помощью less. Less новее, чем more и поддерживает больше опций, поэтому использовать more нет причин.
head/tail
Еще одна пара, но здесь у каждой команды своя область применения. Head выводит несколько первых строк из файла (голова), а tail выдает несколько последних строк (хвост). По умолчанию каждая утилита выводит десять строк. Но это можно изменить с помощью опции -n. Еще один полезный параметр -f. Это сокращение от Follow (следовать), утилита постоянно выводит изменения в файле на экран. Например, если вы хотите следить за лог файлом, вместо того чтобы постоянно открывать и закрывать его используйте tail -nf.
grep
Grep, как и другие инструменты Linux делает одно действие, но делает его хорошо. Она ищет текст по шаблону. По умолчанию она принимает стандартный ввод, но вы можете искать в файлах. Шаблон может быть строкой, или регулярным выражением. Она может вывести как совпадающие, так и несовпадающие строки и их контекст. Каждый раз, когда вы выполняете команду, которая выдает очень много информации, не нужно анализировать все вручную, пусть grep делает свою магию.
sort
Сортировка строк текста по различным критериям. Наиболее полезные: -n (Numeric) — по числовому значению, и -r (Reverse), которая переворачивает вывод. Это может быть полезно для сортировки вывода du. Например, если хотите отсортировать файлы по размеру, просто соедините эти команды.
wc
wc(word count) -Утилита командной строки Linux для подсчета количества слов, строк, байт и символов.
wc -l вывести количество строк wc -c вывести количество байт wc -m вывести количество символов wc -L вывести длину самой длинной строки wc -w вывести количество слов
uniq
Утилита Unix, с помощью которой можно вывести или отфильтровать повторяющиеся строки в отсортированном файле. Если входной файл задан как («-») или не задан вовсе, чтение производится из стандартного ввода. Если выходной файл не задан, запись производится в стандартный вывод. Вторая и последующие копии повторяющихся соседних строк не записываются. Повторяющиеся входные строки не распознаются, если они не следуют строго друг за другом, поэтому может потребоваться предварительная сортировка файлов.
uniq [-c | -d | -u] [-i] [-f число_полей] [-s | -w число_символов] [входной_файл [выходной_файл]]
- -u Выводить только те строки, которые не повторяются на входе.
- -d Выводить только те строки, которые повторяются на входе.
- -c Перед каждой строкой выводить число повторений этой строки на входе и один пробел.
- -i Сравнивать строки без учёта регистра.
- -s число_символов Определяет количество символов, начиная с начала строки, игнорируемых при сравнении. Все остальные символы сравниваются. Символы нумеруются начиная с единицы.
- -w число символов Определяет количество символов, начиная с начала строки, участвующих в сравнении. Все остальные символы игнорируются.
- -f число_полей Игнорировать при сравнении первые число_полей полей каждой строки ввода. Полем является строка непробельных символов, отделённая от соседних полей пробельными символами. Поля нумеруются начиная с единицы.
diff
Показывает различия между двумя файлами, в построчном сравнении. Причем выводятся только строки, в которых обнаружены отличия. Измененные строки отмечаются символом «с», удаленные — «d», а новые — «а».
Управление процессам
kill / xkill / pkill / killall
Все они служат для завершения процессов. Но они принимают различные параметры для идентификации процессов. Kill нужен PID процесса, xkill — достаточно кликнуть по окну, чтобы закрыть его, killall и pkill принимают имя процесса.
Команда killall в Linux предназначена для «убийства» всех процессов, имеющих одно и то же имя. Это удобно, так как нам не нужно знать PID процесса. Например, мы хотим закрыть все процессы с именем gcalctool. Выполните в терминале:
killall gcalctool
Команда killall, так же как и kill, по умолчанию шлет сигнал SIGTERM. Чтобы послать другой сигнал нужно воспользоваться опцией -s. Например:
killall -s 9 gcalctool
Когда вы выполняете команду «kill», то фактически вы посылаете системе сигнал, чтобы заставить ее завершить некорректно ведущее себя приложение. Всего вы можете использовать до 60 сигналов, но все, что нужно знать, это SIGTERM (15) и SIGKILL (9).
SIGTERM – Этот сигнал запрашивает остановку процесса который работает. Этот сигнал может быть проигнорирован. Процессу дается время, чтобы хорошо выключился. Когда программа хорошо выключается, это означает, что ей дано время, чтобы спасти его прогресс и освободить ресурсы. Другими словами, он не «forced» прекращение работы процесса.
SIGKILL – сигнал SIGKILL заставляет процесс прекратить выполнение своей работы немедленно. Программа не может игнорировать этот сигнал. Несохраненный прогресс будет потерян.
Вы можете просмотреть все сигналы с помощью команды:
$ kill -l
| N | Имя | Описание | Можно перехватывать | Можно блокировать |
|---|---|---|---|---|
| 1 | HUP | Hangup. Отбой | Да | Да |
| 2 | INT | Interrupt. В случае выполнения простых команд вызывает прекращение выполнения, в интерактивных программах — прекращение активного процесса | Да | Да |
| 3 | QUIT | Как правило, сильнее сигнала Interrupt | Да | Да |
| 4 | ILL | Illegal Instruction. Центральный процессор столкнулся с незнакомой командой (в большинстве случаев это означает, что допущена программная ошибка). Сигнал отправляется программе, в которой возникла проблема | Да | Да |
| 8 | FPE | Floating Point Exception. Вычислительная ошибка, например, деление на ноль | Да | Да |
| 9 | KILL | Всегда прекращает выполнение процесса | Нет | Нет |
| 11 | SEGV | Segmentation Violation. Доступ к недозволенной области памяти | Да | Да |
| 13 | PIPE | Была предпринята попытка передачи данных с помощью конвейера или очереди FIFO, однако не существует процесса, способного принять эти данные | Да | Да |
| 15 | TERM | Software Termination. Требование закончить процесс (программное завершение) | Да | Да |
ps / pgrep
Команда ps выдает информацию об активных процессах. По умолчанию информация дается только о процессах, ассоциированных с данным терминалом. Выводятся идентификатор процесса, идентификатор терминала, истраченное к данному моменту время ЦП и имя команды. Если нужна иная информация, следует пользоваться опциями. Одна из самых распространенных комбинаций флагов: ps aux Выводятся все процессы, выполняющиеся от имени всех пользователей (выводит статистику, время старта процесса и команду, которая его стартовала)
top / htop
Обе команды похожи, обе отображают процессы, и могут быть использованы как консольные системные мониторы. Я рекомендую установить htop, если в вашем дистрибутиве он не поставляется по умолчанию, так как это намного улучшенная версия top. Вы сможете не только просматривать, но и контролировать процессы через его интерактивный интерфейс.
time
Время выполнения процесса. Это секундомер для выполнения программы. Полезно если вам интересно насколько сильно ваша реализация алгоритма отстает от стандартной. Но несмотря на такое название она не сообщит вам текущее время, используйте для этого команду date.
Пользовательское окружение
su / sudo
Su и sudo — это два способа выполнить одну и ту же задачу — запустить программу от имени другого пользователя. В зависимости от вашего дистрибутива, вы, наверное, используете одну или другую. Но работают обе. Разница в том, что su переключает вас на другого пользователя, а sudo только выполняет команду от его имени. Поэтому использование sudo будет наиболее безопасным вариантом работы.
сhroot
Операция изменения корневого каталога диска для запущенного процесса и его дочерних процессов. Программа, запущенная в таком окружении не может получить доступ к файлам вне нового корневого каталога. Это измененное окружение называется chroot jail.
date
В отличие от time, делает именно то, чего вы от него и ожидаете — выводит дату и время в стандартный вывод. Вывод можно форматировать, в зависимости от ваших потребностей: вывести год, месяц, день, установить 12-ти или 24-ти часовой формат, получить наносекунды или номер недели. Например, date +»%j %V», выведет день в году и номер недели в формате ISO.
alias
Эта команда создает синонимы для других команд Linux. Это означает, что вы можете делать новые команды или группы команд, а также переименовывать существующие. Это очень удобно для сокращения длинных команд, которые вы часто используете, или создания более понятных имен для команд которые вы используете нечасто и не можете запомнить.
uname
Выводит некоторую основную информацию о системе. Без параметров она не покажет ничего полезного, кроме строчки Linux, но если задать параметр -a (All) можно получить информацию о ядре, имени хоста и узнать архитектуру процессора.
uptime
Сообщает вам время работы системы. Не очень существенная информация, но может быть полезна для случайных вычислений или просто ради интереса, как давно был перезагружен сервер.
sleep
Вам, наверное, интересно как же ее можно использовать. Но даже кроме Bash скриптинга, у нее есть свои преимущества. Например, если вы хотите выключить компьютер через определенный промежуток времени, или в качестве импровизированной тревоги.
Управления пользователями
useradd / userdel / usermod
Эти команды консоли Linux позволяют вам добавлять, удалять и изменять учетные записи пользователей. Скорее всего, вы не будете использовать их очень часто. Особенно если это домашний компьютер, и вы являетесь единственным пользователем. И даже если нет, управлять пользователями можно с помощью графического интерфейса, но лучше о них знать на случай, если вдруг понадобится.
passwd
Эта команда позволяет изменить пароль учетной записи пользователя. Как суперпользователь, вы можете сбросить пароли всех пользователей, несмотря на то, что не можете их увидеть. Хорошая практика безопасности — менять пароль не очень редко.
Linux команды для просмотра документации
man / whatis
Команда man открывает руководство по определенной команде. Для всех основных команд Linux есть man страницы. Whatis какие разделы руководств есть для данной команды.
whereis
Показывает полный путь к исполняемому файлу программы. Также может показать путь к исходникам если они есть в системе.
Команды Linux для управления сетью
ip
Если список команд Linux для управления сетью вам кажется слишком коротким, скорее всего, вы незнакомы с утилитой ip. В пакете net-tools содержится множество других утилит ipconfig, netstat и другие устаревшие, вроде iproute2. Все это заменяет одна утилита — ip. Вы можете рассматривать ее как швейцарский армейский нож для работы с сетью, или непонятную массу, но в любом случае за ней будущее.
ping
Ping — это ICMP ECHO_REQUEST дейтаграммы, но на самом деле это неважно. Важно то, что утилита ping может быть очень полезным диагностическим инструментом. Она поможет быстро проверить подключены ли вы к маршрутизатору или к интернету, и дает кое-какое представление о качестве этой связи.
nethogs
Если у вас медленный интернет, то вам, наверное, было бы интересно знать сколько трафика использует та или иная программа в Linux, или вообще какая программа потребляет всю скорость. Теперь это можно сделать с помощью утилиты nethogs. Для того чтобы задать сетевой интерфейс используйте опцию -i.
traceroute
Это усовершенствованная версия ping. Кроме непосредственно доступности узла, мы можем увидеть полный маршрут сетевых пакетов, а также время доставки их на каждый узел.
Рецепты
Копируем файл/папку с сервера на сервер
scp -r /path/from/destination username@hostname:/path/to/destination
Мониторим состояние сервера
Ищем логи
Системные вызовы скрипта пхп
Слушатель 80 порта
Посчитать топ 10 адресов в логах
less /var/log/nginx/access.log | cut -d' ' -f1 | sort | uniq -c
- less — утилита для вывода содержимого файла /var/log/nginx/access.log. Указываем путь до нужного access-лога.
- cut -d’ ‘ -f1 — разбиваем строку на подстроки разделителем «пробел». Разделитель указывается флагом -d. Флагом -f указываем порядковый номер поля, которое будет отображаться в выводе. В данном случае «1» — первое поле, это и есть ip-адрес.
- sort — сортировка строк по порядку. Команда сгруппирует одинаковые строки «рядом». Команда sort необходима для корректной работы следующей команды — uniq .
- uniq — выведет только уникальные строки. Т.е. в результате будут только уникальные ip-адреса.
- Для вывода количества, нужно добавить флаг -с (от слова count) к команде uniq :
Ищем с каких серверов демон получает запросы
.bashrc Файл ~/.bashrc определяет поведение командной оболочки для конкретного пользователя(переменных окружения например). Загружается каждый раз, когда пользователь создает терминальный сеанс, то есть проще говоря, открывает новый терминал. Все переменные окружения, созданные в этом файле вступают в силу каждый раз когда началась новая терминальная сессия.
CТРУКТУРА ФАЙЛОВОЙ СИСТЕМЫ LINUX
- / — КОРЕНЬ
- /BIN — (BINARIES) БИНАРНЫЕ ФАЙЛЫ ПОЛЬЗОВАТЕЛЯ
- /SBIN — (SYSTEM BINARIES) СИСТЕМНЫЕ ИСПОЛНЯЕМЫЕ ФАЙЛЫ
- /ETC — (ETCETERA) КОНФИГУРАЦИОННЫЕ ФАЙЛЫ
- /DEV — (DEVICES) ФАЙЛЫ УСТРОЙСТВ
- /PROC — (PROCCESS) ИНФОРМАЦИЯ О ПРОЦЕССАХ
- /VAR (VARIABLE) — ПЕРЕМЕННЫЕ ФАЙЛЫ
- /VAR/LOG — ФАЙЛЫ ЛОГОВ
- /VAR/LIB — БАЗЫ ДАННЫХ
- /VAR/MAIL — ПОЧТА
- /VAR/SPOOL — ПРИНТЕР
- /VAR/LOCK — ФАЙЛЫ БЛОКИРОВОК
- /VAR/RUN — PID ПРОЦЕССОВ
- /TMP (TEMP) — ВРЕМЕННЫЕ ФАЙЛЫ
- /USR — (USER APPLICATIONS) ПРОГРАММЫ ПОЛЬЗОВАТЕЛЯ
- /USR/BIN/ — ИСПОЛНЯЕМЫЕ ФАЙЛЫ
- /USR/SBIN/ — Содержит двоичные файлы программ для системного администрирования
- /USR/LIB/ — БИБЛИОТЕКИ для программ из /usr/bin или /usr/sbin.
- /USR/LOCAL — ФАЙЛЫ ПОЛЬЗОВАТЕЛЯ
- /HOME — ДОМАШНЯЯ ПАПКА
- /BOOT — ФАЙЛЫ ЗАГРУЗЧИКА
- /LIB (LIBRARY) — СИСТЕМНЫЕ БИБЛИОТЕКИ
- /OPT (OPTIONAL APPLICATIONS) — ДОПОЛНИТЕЛЬНЫЕ ПРОГРАММЫ
- /MNT (MOUNT) — МОНТИРОВАНИЕ
- /MEDIA — СЪЕМНЫЕ НОСИТЕЛИ
- /SRV (SERVER) — СЕРВЕР
- /RUN — ПРОЦЕССЫ
- /SYS (SYSTEM) — ИНФОРМАЦИЯ О СИСТЕМЕ
Дополнительно:
- https://habr.com/post/280093/
- https://habr.com/company/ruvds/blog/323330/
- http://rus-linux.net/MyLDP/server/monitoring-servera-v-konsoli.html
- CТРУКТУРА ФАЙЛОВОЙ СИСТЕМЫ LINUX
Пишем сервис на GO. Backend для апплета

В первой части этой дилогии мы написали рантайм контроллер для приложения на golang. Все что он умеет делать — запускать методы интерфейса Resources и функцию MainFunc , контролировать результат их выполнения, и корректно обрабатывать сигнал операционной системы о завершении работы. Это не так уж и много, но довольно полезно.
Теперь я постараюсь показать, как этот пакет можно использовать на примере простейшего бэкенда для апплета “Труд всем”. Немного поясню идею этого апплета. Допустим у нас есть любой сайт — от хомяка до новостной ленты, а в любом свободном углу при обновлении страницы показана случайная вакансия. Код апплета будет отправлять запрос на сервер и получать в качестве результата HTML код (уже готовый рендер) для вставки на страницу сайта.
Правда интригующе? Где же получать информацию о вакансиях? Где хранить эту информацию? Какие критерии отбора вакансий использовать? Для того, чтобы узнать ответы на эти вопросы, прошу заглянуть под кат!
Определимся с требованиями к нашему бэкэнду:
- Выборка осуществляется из 25 последних опубликованных за прошедшую неделю вакансий по ключевому слову «программист».
- По запросу выдавать случайную вакансию из этой выборки.
- Рассчитываем на RPS близкий к 100к.
- Среднее время ответа 60 мс.
Из этих требований становится ясно, что нам нужен кеш. Мы не можем выполнять поиск вакансии “налету”, поскольку не уверены, что получим ответ за такое короткое время. Сам я, когда пробовал выполнять запросы к публичному АПИ поиска вакансий, получал среднее время ответа около 200 мс, что не соответствует нашим требованиям. Кроме того, сервис поиска вакансий не дает возможности рандомизировать ответ. Ну и последний довод, самый весомый: всегда кешируйте ответ сторонних сервисов, потому что это экономит ресурсы вашего сервера, сервера стороннего АПИ и драгоценное время пользователя, который ждет ваш ответ, а в случае когда сервис платный, вы сэкономите деньги.
Из кеширующих инструментов я не могу вам сегодня предложить такие замечательные вещи, как супербыстрые key-value базы данных наподобие Redis или memcached, просто потому, что это уведет нас от темы. И прошу меня простить за реализацию кеша в ОЗУ нашего приложения, все-таки у нас не такие сложные требования для того, чтобы собрать какую-то приличную систему, которая бы имела базу данных, очереди сообщений, деплоилась в кубернетес и автоматически масштабировалась. Однако, не огорчайтесь, те, кто подпишется на мой канал, наверняка дождутся и таких статей.
А сейчас GO пилить то, что есть…
Труд всем
Для начала разберемся, откуда будем получать информацию о вакансиях. Конечно, мы не будем генерировать ее рандомайзерами, это будут реальные данные с официального бесплатного портала. Работа России — федеральная государственная информационная система, проект Федеральной службы по труду и занятости. Этот портал помогает гражданам найти работу, а работодателям — сотрудников. Все услуги портала предоставляются бесплатно, и поэтому мы просто берем их апи и прикручиваем к своему бэкэнду в качестве ресурса.
Как сервис
Как вы помните из предыдущей статьи, ресурсы нашего бэкэнда мы держим в виде интерфейсов Service , т.е. структур, которые имплементируют следующие методы:
Service interface
Определим какие поля требуются нашей структуре: мьютекс, потому у нас будет конкурентный доступ к списку вакансий, сам список вакансий, как слайс, может быть, http.Client , если мы хотим обернуть все тестами. Ну и давайте еще сохранять последнее время обновления списка вакансий, чтобы как-то выполнять инвалидацию кеша.
type trudVsem struct
Обратите внимание на мьютекс, который я использую. Учитывая то, что кол-во запросов на чтение случайной вакансии будет во много раз превышать кол-во операций обновления этих вакансий, я сделал выбор в сторону RWMutex , который позволяет брать блокировку на чтение отдельно от блокировок на запись, предоставляя возможность одновременного чтения данных из разных горутин. Будем использовать метод RLock для блокировок при чтении, это позволит нескольким горутинам выполнять чтение одновременно, пока нет никаких операций записи. А простой Lock для блокировок при записи, позволит блокировать небезопасный конкурентный доступ к защищаемой памяти, как в обычном мьютексе (под капотом там именно обычный мьютекс).
Итак. Проштудировав документацию открытого АПИ сервиса “Работа России” я собрал структуры, необходимые для маппинга Json данных. Возможно будет немного избыточно, и некоторые поля мы вообще не задействуем в дальнейшем, но я подумал, что лучше будет удалить потом, чем постоянно сверяться с документацией и добавлять какие-то поля в процессе разработки. Кроме того, как ни крути, количество данных в сети мы изменить не сможем — чужой АПИ нам все равно будет присылать все что знает независимо от того, есть ли у нас под эти данные соответствующие поля в наших структурах или нет.
Раскройте, чтобы увидеть код этих структур
const ( serviceURL = "https://opendata.trudvsem.ru/api/v1/vacancies" prefetchCount = 25 ) type ( Meta struct < Total int `json:"total"` Limit int `json:"limit"` >Region struct < RegionCode string `json:"region_code"` Name string `json:"name"` >Company struct < Name string `json:"name"` >Vacancy struct < ID string `json:"id"` Source string `json:"source"` Region Region `json:"region"` Company Company `json:"company"` CreationDate string `json:"creation-date"` SalaryMin float64 `json:"salary_min"` SalaryMax float64 `json:"salary_max"` JobName string `json:"job-name"` Employment string `json:"employment"` Schedule string `json:"schedule"` URL string `json:"vac_url"` >VacancyRec struct < Vacancy Vacancy `json:"vacancy"` >Results struct < Vacancies []VacancyRec `json:"vacancies"` >Response struct < Status string `json:"status"` Meta Meta Results Results `json:"results"` >)
Для тех, кто знает как работает маршалинг структур в GO, тут нет ничего нового, для остальных немного пояснения: теги (текст в одинарных кавычках), прилагаемые к полям структур, не влияют непосредственно на маппинг данных или сериализацию, тут опять нет никакой магии. Теги используются для получении информации о полях структур в рантайме. Для получения доступа к ним используется пакет reflect , который еще называют рефлексией в go. Я об этом говорю, потому что если вы хотите строить легковесные и производительные алгоритмы, то не стоит использовать рефлексию и ничего, что прямо или косвенно ее использует, вместо этого есть кодогенераторы, которые, пользуясь рефлексией, создают в своем рантайме уже готовый код на языке go, который содержит все необходимые для маршалинга/демаршалинга функции, которые, в свою очередь, уже в нашем рантайме не используют рефлексию. К таким относятся, например пакет easyjson. Подход с кодогенерацией не на много сложнее, чем подход с использованием json.Decoder , но мы его использовать пока не будем в силу того, что это опять мимо темы.
Давайте же займемся реализацией интерфейса. Первым у нас будет метод Init , который должен подготовить все ресурсы сервиса к работе. Сделаем следующее: проинициализируем рандомизатор, выберем http-клиент по умолчанию и сразу выделим необходимое место в памяти под список вакансий.
func (t *trudVsem) Init(context.Context) error
Для новичков сделаю еще одну ремарку: использование пакета rand может быть обосновано только в алгоритмах, которые не чувствительны к качеству генератора случайных чисел, в других случаях (например, в криптографических алгоритмах), требуется использование генератора из пакета crypto/rand .
Следующим в очереди будет метод Ping , как мы помним, он должен использоваться для самодиагностики и возвращать error , если произошла критическая ошибка, которая не позволяет работать дальше. Такой случай для этого сервиса я себе представить пока не могу, поэтому будем возвращать всегда nil . Функция Ping будет вызываться контроллером рантайма, а если быть точнее, ServiceKeeper должен циклично пинговать наши сервисы с определенными промежутками времени до тех пор, пока не получит команду на завершение работы. Давайте глянем, как ее реализовать.
func (t *trudVsem) Ping(context.Context) error < if time.Since(t.requestTime).Minutes() >1 < t.requestTime = time.Now() go t.refresh() >return nil >
Проверяем время обновления кеша, если прошло больше минуты, инициируем обновление кеша в фоновой горутине, чтобы не ломать процесс анализа работоспособности сервиса. Возвращаем nil . И вот вам вопрос на засыпку: какая ошибка в имплементации этой функции? Подсказка следующая: синтаксических ошибок нет, все прекрасно компилируется и замечательно работает. Давайте подискутируем об этом в комментариях?
Последняя функция имплементирующая интерфейс Service , необходимая для того, чтобы нашу структуру можно было считать сервисом и передать в ServiceKeeper под контроль. И в ней нечего обсуждать — она идеальна.
func (t *trudVsem) Close() error
Обновление вакансий
Поскольку функция рефреша списка вакансий должна обновлять данные списка вакансий, то именно в ней мы должны использовать блокировку записи (чтения/записи). После выполнения t.mux.Lock() чтение списка вакансий будет недоступно, пока не выполнится t.mux.Unlock() , но мы должны будем гарантировать это выполнением t.mux.RLock() в читающей функции. Прошу обратить внимание на следующую деталь: с самого начала функции никакие блокировки не ставятся, а выполняется функция вызывающая загрузку обновленных вакансий loadLastVacancies само выполнение этой функции не приводит к обновлению списка, поэтому блокировок тут не нужно. Это кажется очевидным, но тем не менее, ситуация, когда разработчик оборачивает в Lock/Unlock лишние операции, довольно распространена. Всегда ограничивайте блокировкой наименьший участок кода, все, что можно выполнить без блокировок, нужно выполнять без блокировок. Блокировки — это всегда вынужденное зло.
func (t *trudVsem) refresh() < vacancies, err := t.loadLastVacancies(context.Background(), "программист", 0, prefetchCount) if err != nil < log.Println(err) return >t.mux.Lock() t.vacancies = t.vacancies[:0] for _, v := range vacancies < t.vacancies = append(t.vacancies, v.Vacancy) >t.mux.Unlock() >
Вероятно, все знают, почему я использовал t.vacancies = t.vacancies[:0] а не make([]Vacancy, 0, prefetchCount) . Это сделано для уменьшения кол-ва аллокаций памяти. На самом деле этот участок кода не критичный и выделение памяти в этом месте не приведет к деградации даже при самых высоких нагрузках, но я позволил себе сумничать. Выражение slice[:0] просто установит длину слайса (len) в 0, оставив его вместительности (cap) прежнее значение, а это значит, что выполнение append будет наполнять слайс заново, просто перетирая старые значения новыми.
Теперь посмотрим, как выполняется загрузка вакансий, комментарии по коду четко отделяют три этапа этой функции: создание запроса, передача его по сети, парсинг ответа. Если какой-то из этапов вернет ошибку, мы просто пробросим ее в качестве ответа для вызывающей функции.
func (t *trudVsem) loadLastVacancies(ctx context.Context, text string, offset, limit int) ([]VacancyRec, error) < // создадим HTTP-запрос для получения данных req, err := newVacanciesRequest(ctx, text, offset, limit) if err != nil < return nil, err >// выполняется запрос данных resp, err := t.client.Do(req) if err != nil < return nil, err >// выполняем парсинг данных parsed, err := parseResponseData(resp) // не забываем закрывать Body resp.Body.Close() return parsed.Results.Vacancies, err >
Давайте посмотрим, как выполняется создание HTTP-запроса:
func newVacanciesRequest(ctx context.Context, text string, offset, limit int) (*http.Request, error) < URL, err := url.ParseRequestURI(serviceURL) if err != nil < return nil, err >query := url.Values< "text": []string, "offset": []string, "limit": []string, "modifiedFrom": []string, > URL.RawQuery = query.Encode() return http.NewRequestWithContext(ctx, http.MethodGet, URL.String(), http.NoBody) >
И тут есть о чем подискутировать, ведь в самой первой строке я выполняю парсинг константы! Каждый раз вызов этой функции будет выполнять парсинг константы serviceURL и даже возвращать ошибку, если она возникнет. Как-то не очень умно, да? Можно же было использовать литерал url.URL структуры. А еще весьма прилично и даже наглядно будет смотреться вызов функции форматирования fmt.Sprintf , как в примере под спойлером.
Пример
weekAgo := url.QueryEscape(time.Now().Add(-time.Hour * 168).UTC() newURL := serviceURL + fmt.Sprintf( "?text=%s&offset=%d&limit=%d&modifiedFrom=%s", url.QueryEscape(text), offset, limit, weekAgo.Format(time.RFC3339)), )
Преимущества моего варианта в том, что ParseRequestURI может обнаружить некоторые синтаксические ошибки в константе serviceURL , далее литерал url.Values и вызов его метода Encode позволит правильно вписать пары ключ/значение в наш HTTP-запрос, он экранирует все недопустимые символы по всем канонам URL, и никаких ошибок тут не будет. Ну и как итог URL.String() вернет нам какую-то гарантию корректного URL. Единственное, что можно было бы сделать — вынести парсинг константы отдельно, но этот участок кода настолько нетребователен к производительности, что разделять эту логику и выносить куда-то ее часть, я считаю нецелесообразным.
Немного по поводу длинного и неприятного выражения time.Now().Add(-time.Hour * 168).UTC().Format(time.RFC3339) . Его логика, думаю, всем понятна — мы получаем текущее время, уменьшаем его на 168 часов (это ровно неделя), переводим в UTC и форматируем по стандарту RFC3339 — это то, чего от нас ждет сервис вакансий. Но я хотел сказать другое. Когда мы видим в коде среди простых выражений сложное, у нас должно возникать желание упростить код. Давайте же спрячем это страшное выражение внутрь функции и вместо него будем вызывать функцию:
const hoursInWeek = 168 func modifiedFrom() string
Парсинг ответа от сервиса вакансий будет состоять из демаршалинга тела http.Response в нашу структуру с помощью базового json.Decoder и минимальной проверки на валидность этих данных. Ничего такого о чем бы я хотел сказать отдельно.
func parseResponseData(resp *http.Response) (result Response, err error) < decoder := json.NewDecoder(resp.Body) if err = decoder.Decode(&result); err != nil < return >// мы ожидаем статус 200 if result.Status != "200" < err = errors.New("wrong response status") return >// если вакансий в слайсе нет, тут явно что-то не то if len(result.Results.Vacancies) == 0 < err = io.EOF >return >
Итак, вот наш первый checkpoint — готов единственный ресурс нашего бэкэнда. Готов сервис бесплатных вакансий. На уровне кода он плоский с единственной внешней связью в виде вызова стороннего АПИ через HTTP и никаких кешей и прочих премудростей. На верхнем уровне мы считаем, что у него все-таки есть кеш, потому что для выполнения своих функций, ему не требуется выполнять вызов стороннего апи.
Пишем бэкэнд с использованием полученного опыта
В предыдущей статье я описал код runtime-контроллера, а в главе выше описал код сервиса вакансий, который будет являться единственным ресурсом нашего бэкэнда. Теперь пора аккумулировать полученный опыт и построить работающее приложение. Вероятно, из предыдущей статьи не становится понятным, как использовать runtime-контроллер так, чтобы изнутри кода с бизнес-логикой были доступны ресурсы приложения. Но давайте сейчас посмотрим на код main :
type server struct < // все ресурсы нашего бэкэнда перечислены здесь trudVsem trudVsem >func main() < var srv server var svc = appctl.ServiceKeeper< Services: []appctl.Service< &srv.trudVsem, // регистрируем ссылку на ресурс trudVsem >, PingPeriod: time.Millisecond * 500, // периодичность вызова Ping > var app = appctl.Application < MainFunc: srv.appStart, // эта функция будет запущена с Run Resources: &svc, // регистрируем ServiceKeeper TerminationTimeout: time.Second * 10, // для порядка >// стартуем if err := app.Run(); err != nil < logError(err) os.Exit(1) >>
Немного освежим память. Структура server понятна — она состоит всего из одного поля — структуры сервиса вакансий trudVsem . Это не интерфейс и оно не скрывает реализации, кроме того, я поместил весь код этого приложения внутри одного пакета, а это значит, что никакого сокрытия реализации тут не будет и из кода с бизнес логикой мне будут доступны даже приватные методы и поля структуры trudVsem . Не делайте так, когда пишете реальное приложение. Я же себе это позволил, потому что это снова вне темы. Просто хочу сказать, что тот сервис, который я передал ServiceKeeper на контроль, как ресурс приложения, мы должны передать в качестве сервиса в функцию выполняющуюся, как основной поток. MainFunc будет запущена строго после того, как все ресурсы будут проинициализированы и у нас есть гарантия, что trudVsem.Init к тому времени уже будет успешно выполнено.
Структура Application получает указатель на ServiceKeeper и знает только о том, что нужно запустить Init , выполнить в фоновой горутине Watch и следить за сообщениями от ОС. Вся эта логика будет запущена в то время, когда мы вызовем Run и любое из следующих трех событий позволит потоку выполнения пойти дальше:
- Возврат из функции MainFunc .
- Возврат из функции ServiceKeeper.Watch .
- Сигнал от операционной системы о завершении работы.
А что там еще за logError ? Это я обернул логирование ошибки в вызов функции, чтобы не сильно нагружать по коду вызовами fmt.Errorf .
func logError(err error) < if err = fmt.Errorf("%w", err); err != nil < println(err) >>
Запуск HTTP сервера и контроль его Graceful Shutdown
Функция appStart будет запускать HTTP сервер из стандартного пакета net/http . Я в очередной раз прошу прощения за magic в коде. Все таймауты и номера портов должны быть спрятаны под конфигами. Конфигурацию можно передать, как переменные окружения операционной системы, опции командной строки или в конфигурационном файле. В идеале следует предусмотреть все три способа передачи конфигурации, возможно, мы с вами займемся этим в будущих статьях, но сейчас для демонстрации результатов мы это опустим.
В стандартной реализации HTTP сервера уже предусмотрен процесс мягкого завершения работы (Graceful Shutdown) с помощью метода Shutdown . В документации сказано, что вызов этого метода приведет к отключению HTTP сервера без прерывания выполнения текущих запросов. Сначала будет выключен листенер, чтобы предотвратить поступление из сети новых запросов, затем будут разорваны все спящие соединения (в состоянии IDLE), а затем будет выполнено ожидание завершения обработки активных запросов и выход.
Я бы описал логику главной функции в такой последовательности: создаем сервер, запускаем на нем обработку запросов, ожидаем сигнала через halt канал и вызываем мягкое завершение работы. В действительности же вышла не очень простая функция с горутиной внутри и каналом для конкурентной передачи возникающей в процессе ошибки.
Код функции appStart(context.Context, ) error
func (s *server) appStart(ctx context.Context, halt ) error < var httpServer = http.Server< Addr: ":8900", Handler: s, ReadTimeout: time.Millisecond * 250, ReadHeaderTimeout: time.Millisecond * 200, WriteTimeout: time.Second * 30, IdleTimeout: time.Minute * 30, BaseContext: func(_ net.Listener) context.Context < return ctx >, > var errShutdown = make(chan error, 1) go func() < defer close(errShutdown) select < case if err := httpServer.Shutdown(ctx); err != nil < errShutdown >() if err := httpServer.ListenAndServe(); err != http.ErrServerClosed < return err >err, ok := return nil >
В качестве Handler для HTTP сервера мы передаем сам указатель на структуру server , для того, чтобы наша структура могла стать обработчиком HTTP запросов, мы должны имплементировать метод ServeHTTP . В качестве Addr нужно передавать адрес локального порта, на котором будем ожидать запросы. В моем случае это будет порт 8900 на всех доступных сетевых интерфейсах. И в качестве BaseContext я порекомендовал HTTP серверу отдавать контекст с которым была запущена функция appStart . Может быть, это решение не очень аккуратное, потому что хоть на данном этапе у нас и есть гарантии, что контекст не будет отменен внезапно и незапланированно, но никаких гарантий в том, что это не станет происходить в дальнейшем, когда мы или кто-то другой станет развивать пакет с runtime-контроллером. Я бы рекомендовал для обработки HTTP запросов использовать всегда чистый контекст — в нем корректная информация о статусе и мы получаем cancel только когда клиент сам закрывает соединение.
Обратите внимание на запуск горутины в середине функции. Это вынужденная необходимость, поскольку вызов ListenAndServe блокирует дальнейшее выполнение. Мы должны предусмотреть варианты вызова Shutdown в случае получения сигнала через канал halt — этот канал, напомню, закрывается, когда операционная система передала сигнал о завершении работы. Дополнительно к этому случаю я добавил еще один случай, когда мы выполняем Shutdown — при “протухании” основного контекста case
В нижней части функции я читаю из канала, который использую для передачи информации об ошибке мягкого завершения работы. Выполнение функции может не дойти до этого участка кода, если ListenAndServe вернет какую-то ошибку, которая немедленно будет отдана в качестве результата для вызывающей функции. Однако, если ошибки не произошло, мы можем проверить корректно ли завершилась операция Shutdown , для этого в канал errShutdown передается ошибка, если таковая возникла. Обратите внимание на то, что я сделал этот канал буферизованным, это значит, что процесс записи в него не остановит выполнение горутины, в противном случае мы рискуем “замерзнуть” в месте выполнения errShutdown
err, ok := return nil
Напомню, что чтение из канала возвращает кортеж, вторым значением которого будет булево значение, показывающее успешность чтения данных. Простыми словами, если ok != true, значит в канале нет данных и он уже закрыт.
Мне нравится эта функция тем, что она оставляет некоторый простор для рефакторинга. Мы можем попробовать вынести из нее литерал http.Server и запуск горутины, контролирующей сигнал halt . Можем завернуть все magic numbers в конфигурацию и как-нибудь иначе выполнить синхронизацию ошибки вызова Shutdown . Но я пока просто оставлю это здесь.
Теперь у нас есть еще один checkpoint — реализация мягкого завершения полностью самодостаточна, все функции, вызываемые ниже по коду уже не должны заботиться о корректной работе сервера в целом, им достаточно будет убедиться, что их работа выполнена корректно.
Обработка HTTP-запроса
Давайте начнем реализацию бизнес-логики. При получении запроса мы должны обратиться к сервису trudVsem и попросить у него случайную вакансию. Если данных нет, вернем HTTP 204 No Content , если что-то есть, выполним рендер вакансии в HTML и вернем его в качестве ответа.
Раскройте, чтобы увидеть код ServeHTTP и renderVacancy
func (s *server) ServeHTTP(w http.ResponseWriter, _ *http.Request) < vacancy, ok := s.trudVsem.GetRandomVacancy() if !ok < w.WriteHeader(http.StatusNoContent) return >data, err := renderVacancy(vacancy) if err != nil < logError(err) w.WriteHeader(http.StatusInternalServerError) return >w.Header().Add("Content-Type", "text/html;charset=utf-8") w.WriteHeader(http.StatusOK) if _, err = w.Write(data); err != nil < logError(err) >> func renderVacancy(vacancy Vacancy) ([]byte, error) < var w = bytes.NewBuffer(nil) if _, err := w.WriteString(fmt.Sprintf("%s (%s)
", vacancy.JobName, vacancy.Region.Name)); err != nil < return nil, err >if _, err := w.WriteString(fmt.Sprintf("Компания: %s ищет сотрудника на должность '%s'.
", vacancy.Company.Name, vacancy.JobName)); err != nil < return nil, err >if _, err := w.WriteString(fmt.Sprintf("Условия: %s, %s.
", vacancy.Employment, vacancy.Schedule)); err != nil < return nil, err >if vacancy.SalaryMin != vacancy.SalaryMax && vacancy.SalaryMax != 0 && vacancy.SalaryMin != 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата от %0.2f до %0.2f руб.
", vacancy.SalaryMin, vacancy.SalaryMax)); err != nil < return nil, err >> else if vacancy.SalaryMax > 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата %0.2f руб.
", vacancy.SalaryMax)); err != nil < return nil, err >> else if vacancy.SalaryMin > 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата %0.2f руб.
", vacancy.SalaryMin)); err != nil < return nil, err >> if _, err := w.WriteString(fmt.Sprintf("ознакомиться", vacancy.URL)); err != nil < return nil, err >return w.Bytes(), nil >
Принимая во внимание то, что выглядит это не очень, я сделаю небольшое отступление от основной темы. У меня вышла довольно сложная для восприятия функция renderVacancy к тому же, скорее-всего, она будет в большей степени влиять на производительность. А еще код бизнес-логики вшит прямо в обработчик запросов, хотя, нам для примера сойдет. В конце-концов, чтобы немного упростить код, мы могли бы сделать следующее:
- Добавить поле template *template.Template в нашу структуру server .
- Заполнить ее при запуске программы srv.template = template.Must(template.New(«render»).Parse(htmlTemplate))
И изменить нашу логику примерно вот так:
Раскройте, чтобы поглядеть, что вышло
const htmlTemplate = `> (>)
Компания: > ищет сотрудника на должность '>'.
Условия: >>, >>.
> >зарплата от > до > руб.> >зарплата > руб.>>зарплата > руб.>> >
> >'>ознакомиться` type server struct < template *template.Template trudVsem trudVsem >func renderVacancy(vacancy Vacancy, tpl *template.Template) ([]byte, error) < var w = bytes.NewBuffer(nil) if err := tpl.Execute(w, vacancy); err != nil < return nil, err >return w.Bytes(), nil > func (s *server) ServeHTTP(w http.ResponseWriter, _ *http.Request) < . . . data, err := renderVacancy(vacancy, s.template) . . .
Да, выглядит получше, но такой рендер снизит скорость обработки запроса в два раза. Правда-правда, я проверял. Это вполне рабочий вариант, но без шаблона с одними только w.WriteString(fmt.Sprintf на моем компьютере я получаю около 220к RPS, а с шаблонизатором чуть больше 100к (нагрузочное тестирование будет дальше).
Что можно сделать еще? Чтобы увеличить производительность, мы можем попробовать заранее создавать буфер данных []byte и заполнять его с помощью append , а не w.WriteString(fmt.Sprintf . По понятным причинам такой подход еще сильнее усложнит код, а толку от него будет не очень много — мы можем снизить количество аллокаций и может даже избавимся от нескольких случаев “убегания в кучу”, но не от всех. Еще мы можем сразу писать в http.ResponseWriter , вроде как хорошая идея, но тоже не даст прироста производительности. А если сделать наоборот — чтобы упростить код, мы можем разбить рендер на отдельные блоки: блок рендера заголовка, описания компании, условий труда и отдельно блок с заработной платой. Вот и отлично, пока оставим в голове этот план и вернемся к нашим баранам.
Функция выборки случайной вакансии, которую мы использовали, но еще не описали, содержит обещанный RLock в качестве блокировки при чтении. Это позволяет множеству горутин выполнять этот код не мешая друг другу. Единственное, что заблокирует вход сюда, это Lock при обновлении списка вакансий. Выбор случайной вакансии нам поможет сделать rand.Intn(len(t.vacancies)) .
func (t *trudVsem) GetRandomVacancy() (vacancy Vacancy, ok bool) < t.mux.RLock() defer t.mux.RUnlock() if ok = len(t.vacancies) >0; !ok < return >vacancy = t.vacancies[rand.Intn(len(t.vacancies))] return >
Нагрузочное тестирование
Итак, кажется, что все готово. Пора проверить на работоспособность. Я запускаю приложение и перехожу по адресу http://localhost:8900/test . Выглядит так, как будто сработало: я вижу вакансию на должность программиста; обновляю страницу и вижу следующую вакансию. Но давайте проверим, производительность нашего приложения. Все-таки в требованиях указано 100 RPS.
Нагрузочное тестирование удобно проводить с помощью утилиты wrk. Wrk легко устанавливается и позволяет настраивать параметры нагрузочного тестирования. Для своих тестов я решил использовать 15 активных коннектов и 10 рабочих потоков. Для этого запускаю утилиту с ключами -t 10 -c 15 . Первый тест сразу обрадовал, я получил результат ~224k RPS. И среднее время ответа в 68 микросекунд, а максимальное в 20 миллисекунд, что явно удовлетворяет нашим требованиям. Вот детализированные результаты тестирования:
devalio@devastator:~$ wrk -t 10 -c 15 http://localhost:8900/test Running 10s test @ http://localhost:8900/test 10 threads and 15 connections Thread Stats Avg Stdev Max +/- Stdev Latency 68.69us 223.22us 20.48ms 97.01% Req/Sec 22.52k 842.70 25.47k 76.31% 2260841 requests in 10.10s, 1.51GB read Requests/sec: 223848.81 Transfer/sec: 153.06MB
Ну что ж, отлично! Двойной резерв по производительности, это то, что нам нужно. А среднее время ответа даже меньше миллисекунды. Но нужно ли на этом останавливаться? Кажется, я там пропустил одну функцию, реализация которой была не самым удачным примером кода. Да, были какие то мысли, как можно ее переделать, но они строились на предположениях, а не на фактах. Что-то придется с этим делать.
Рефакторинг
Ну вот, я сам себя раздразнил и поскольку уже обещал показать, как я рассуждал в процессе, придется быть честным и приступить к рефакторингу функции renderVacancy . Недолго думая, я посмотрел, какой процент времени выполнение запроса проводит в этой функции. Для этого добавил в секцию импорта _ "net/http/pprof" , а в самое начало функции main вот такой вызов HTTP-листенера:
go http.ListenAndServe(":9900", nil)
Пакет net/http/pprof при подключении сам настраивает роутинг по умолчанию, поэтому сразу имею доступ ко всем полезным функциям профилировщика. Когда мы снимаем профили, обязательно нужно, чтобы приложение было нагружено, иначе мы будем видеть метрики “холостого хода”, поэтому я запускаю нагрузочный тест, увеличив время его выполнения до 20 секунд, и одновременно с этим запускаю команду на снятие профиля:
devalio@devastator:~$ go tool pprof http://localhost:9900/debug/pprof/profile?seconds=15 Fetching profile over HTTP from http://localhost:9900/debug/pprof/profile?seconds=15 Saved profile in /home/devalio/pprof/pprof.___go_build_github_com_iv_menshenin_appctl_example.samples.cpu.001.pb.gz File: ___go_build_github_com_iv_menshenin_appctl_example Type: cpu Time: Nov 16, 2021 at 6:24am (MSK) Duration: 15s, Total samples = 26.97s (179.80%) Entering interactive mode (type "help" for commands, "o" for options) (pprof)
Для тех, кто не пользуется go tool pprof или не знает об этом инструменте, очень рекомендую изменить свое мнение и нагуглить хорошие инструкции по работе с этим профилировщиком. А пока покажу немногое из того, что он умеет.
Конечно, первое, на что я хочу взглянуть — это диаграмма, которая доступна из профилировщика с помощью команды web — откроется страница браузера с вот такой картинкой. Неправда ли, очень наглядно. Тут показано, какое количество процессорного времени было уделено той или иной функции.

На этом графике я выделил блок, который меня интересует. Обратите внимание на то, что сама обработка запроса (суммарно за все время профилирования) заняла около 5 секунд процессорного времени, а из них чуть больше 3х секунд ушло на renderVacancy .
Следующая команда, которая мне нравится — list , позволяет показать исходный код и расставить ключевые метрики прямо напротив строки кода к которым они относятся. В качестве аргумента эта команда может принимать шаблон поиска по коду, я ввожу list ServeHTTP , чтобы найти функцию обработки HTTP-запроса. Поглядите на картинку ниже, слева от исходного кода мы увидим два столбца. Первый столбец показывает какое кол-во процессорного времени взяла на себя функция, исходный код которой мы видим на экране, без учета времени вложенных в нее функций. Второй — суммарное процессорное время с учетом вложенных вызовов. А вот и наш data, err := renderVacancy(vacancy) — обратите внимание на то, что в левом столбце пусто, а в правом 3 секунды, это значит, что время, потраченное на выполнение этой части кода, относится не к функции ServeHTTP, а ко вложенному в нее вызову renderVacancy .
Т.е. львиная доля Latency, который мы видим в нагрузочных тестах, уходит на рендер вакансии в HTML формат, что дает мне повод заняться оптимизацией в этом месте. И, конечно же, я пошел самым простым путем: решил выполнить рендер прямо в сервисе, который хранит вакансии, ведь данные вакансий не изменяются, а поэтому нам не нужно выполнять рендер “налету”.
Я добавил поле render []byte к структуре Vacancy , вынес рендер вакансии в сервис вакансий и разбил его на кусочки. Затем разбил страшную процедуру рендера на небольшие куски. Итоги рендера я сохраню в поле render и, когда мне потребуется получить вакансию, я буду сразу лить ее в HTTP-респонз вот так:
func (s *server) ServeHTTP(w http.ResponseWriter, _ *http.Request) < vacancy, ok := s.trudVsem.GetRandomVacancy() if !ok < w.WriteHeader(http.StatusNoContent) return >w.Header().Add("Content-Type", "text/html;charset=utf-8") w.WriteHeader(http.StatusOK) // сразу вливаем готовый HTML в ResponseWriter if err := vacancy.RenderTo(w); err != nil < logError(err) >> . . . // а эта функция находится в пакете с сервисом “Труд всем” func (v Vacancy) RenderTo(w io.Writer) error
Раскройте, чтобы увидеть функции рендера
func (v Vacancy) renderBytes() ([]byte, error) < var w = bytes.NewBufferString("") if err := v.renderHead(w); err != nil < return nil, err >if err := v.renderDesc(w); err != nil < return nil, err >if err := v.renderConditions(w); err != nil < return nil, err >if err := v.renderSalary(w); err != nil < return nil, err >if err := v.renderFooter(w); err != nil < return nil, err >return w.Bytes(), nil > func (v Vacancy) renderHead(w io.StringWriter) error < _, err := w.WriteString(fmt.Sprintf("%s (%s)
", v.JobName, v.Region.Name)) return err > func (v Vacancy) renderDesc(w io.StringWriter) error < _, err := w.WriteString(fmt.Sprintf("Компания: %s ищет сотрудника на должность '%s'.
", v.Company.Name, v.JobName)) return err > func (v Vacancy) renderConditions(w io.StringWriter) error < _, err := w.WriteString(fmt.Sprintf("Условия: %s, %s.
", v.Employment, v.Schedule)) return err > func (v Vacancy) renderSalary(w io.StringWriter) error < if v.SalaryMin != v.SalaryMax && v.SalaryMax != 0 && v.SalaryMin != 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата от %0.2f до %0.2f руб.
", v.SalaryMin, v.SalaryMax)); err != nil < return err >> else if v.SalaryMax > 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата %0.2f руб.
", v.SalaryMax)); err != nil < return err >> else if v.SalaryMin > 0 < if _, err := w.WriteString(fmt.Sprintf("зарплата %0.2f руб.
", v.SalaryMin)); err != nil < return err >> return nil > func (v Vacancy) renderFooter(w io.StringWriter) error < _, err := w.WriteString(fmt.Sprintf("ознакомиться", v.URL)) return err >
Еще немного поразмыслив, я понял, что теперь мне незачем хранить слайс элементов в структуре Vacancy , теперь достаточно будет хранить массив готовых HTML, и сделал еще кое-какие изменения:
type ( VacancyRender []byte trudVsem struct < mux sync.RWMutex requestTime time.Time client *http.Client vacancies []VacancyRender // вот тут >) // и тут func (r VacancyRender) RenderTo(w io.Writer) error < _, err := w.Write(r) return err >// и вот этот кусочек вот в этой функции func (t *trudVsem) refresh() < . . . var newVacancy = v.Vacancy if rendered, err := newVacancy.renderBytes(); err == nil < t.vacancies = append(t.vacancies, rendered) >. . .
Провел нагрузочное тестирование еще раз и получил прирост производительности аж на 30%. Теперь вижу следующие результаты:
10 threads and 15 connections Thread Stats Avg Stdev Max +/- Stdev Latency 64.87us 251.00us 9.09ms 97.39% Req/Sec 29.48k 2.09k 34.76k 64.72% 2959186 requests in 10.10s, 1.97GB read Requests/sec: 292990.05 Transfer/sec: 199.60MB
Хорошо. Я остался доволен производительностью этого приложения. Немного беспокоит то, что рендер теперь выполняет сервис trudVsem , а я хотел от него только получение/хранение пакета вакансий. Но с этим могу смириться, поскольку свою цель считаю достигнутой: я показал, как можно использовать пакет, который мы написали в предыдущей статье.
Заключение
Начиная первую из двух статей “Пишем сервис на GO”, я задался целью: показать, как можно построить простое но полноценное веб-приложение на GO. Какие нюансы нужно учитывать при разработке веб-сервиса и на что нужно обращать внимание — это все я постарался разобрать. Да, согласен, в своей статье я описал весьма простое приложение, которое можно было уместить в 100-200 строчек кода, но мне кажется, что даже в решении такой мелкой задачи мне удалось найти и обратить внимание на определенные нюансы разработки веб-сервисов на GO.
Конечно, писать плоский и неструктурный код не задумываясь, что у приложения есть жизненный цикл — это довольно легко. И на самом деле даже может что-то получиться, но чем больше разрастается функциями наше приложение, тем сложнее нам понять, где та самая ниточка, дернув за которую мы заставим наше приложение правильно закрыться. Может для кого-то эта проблема выглядит, как из пальца высосаная, ведь есть же теперь всякие кубернетесы — все, что упало поднимут заново, ну а если наше приложение перед завершением наплодит 500-ых респонзов, то на балансировщике можно выкрутиться ретраями. Но я встречал в своей практике веб-приложение, которое закладывалось, как приложение могущее в перспективе HighLoad, но фактически не поддерживающее не только Graceful Shutdown, но и банальных ContextTimeout. Поэтому постарался вложить между строк правильное понимание структуры go-приложений — такие штучки, как каналы для синхронизации при конкурентности или мьютексы, которые могут блокировать запись и при этом разрешать одновременное чтение. А в некоторых местах попробовал спорить сам с собой, чтобы показать, что в разработке не все так однозначно — где-то нам нужен быстрый код, а где-то мы можем написать код не очень производительный, но зато более стабильный. Все-таки разработка — это процесс непрерывного появления на свет какой-то истины в форме имплементации поставленной задачи на определенном языке программирования, а что помогает рождать истину? Конечно дискуссии и споры.
Чего мы достигли в процессе разработки кода, описанного в этих статьях:
- Мы написали инфраструктурный код — структуры Application и ServiceKeeper , которые помогают нам контролировать системы жизнеобеспечения нашего сервиса.
- Разработали сервис поиска случайной вакансии с использованием вызова API на удаленном сервере.
- Провели нагрузочное тестирование и ознакомились с одним из способов профилирования и оптимизации нашего кода.
Что ж, я считаю достижение этих целей достойной наградой за потраченное время. Если кому-то статья показалась полезной, можете поставить лайк. Если есть такие, для кого эта статья разрушила последний барьер на пути к большому плаванию под эгидой “пишу микросервисы на golang с нуля”, то я более чем доволен. Прошу ознакомиться с полным кодом на моем github.
Ну а я уже полон новых идей и хочу незамедлительно приступить к следующей статье.