/
Теги: языки программирования программирование
ISBN: 978-5-4461-4388-7
Текст
Go in Practice,
Second Edition
NATHAN KOZYRA
M ATT BUTCHER
M ATT FARINA
Go на практике,
второе издание
М ЭТТ БАТЧЕР
М ЭТТ ФАРИНА
2026
ББК 32.973.2-018.1
УДК 004.43
К59
Козыра Натан, Батчер Мэтт, Фарина Мэтт
К59 Go на практике, 2-е изд. — СПб.: Питер, 2026. — 400 с.: ил. — (Серия «Библио
тека программиста»).
ISBN 978-5-4461-4388-7
Итак, вы освоили азы Go, что дальше? Чтобы создавать надежные и готовые к использованию Go-приложения, вам нужно научиться реализовывать конкурентность, налаживать сетевые
взаимодействия, получать доступ к файлам, использовать такие нативные протоколы передачи
данных, как gRPC и TCP/REST, а также разбираться во множестве практических нюансов. В этой
книге приводятся готовые решения, которые можно незамедлительно применить в повседневной
работе. Здесь вы найдете описание десятков техник разработки и сопровождения Go-приложений
в формате «проблема — решение — обсуждение». Издание охватывает новейшие возможности
языка Go, включая дженерики, обработку паник и ошибок, современные модели конкурентности,
а также такие продвинутые темы, как рефлексия и генерация кода. Кроме того, авторы дают полезные советы по интеграции в ваш процесс разработки ИИ-инструментов.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ББК 32.973.2-018.1
УДК 004.43
Права на издание получены по соглашению с Manning Publications. Все права защищены. Никакая часть
данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения
владельцев авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как
надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не
может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за
возможные ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook,
Instagram и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы
можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были
действующими.
ISBN 978-1633436886 англ. Authorized translation of the English edition © 2024 Manning Publications.
This translation is published and sold by permission of Manning
Publications, the owner of all rights to publish and sell the same.
ISBN 978-5-4461-4388-7
© Перевод на русский язык ООО «Издательство Питер», 2025
© Издание на русском языке, оформление ООО «Издательство Питер»,
2025
© Серия «Библиотека программиста», 2025
Краткое содержание
Часть I
Основы языка Go
Глава 1. Начало работы с Go.......................................................................................................26
Глава 2. Создание консольного приложения........................................................................56
Глава 3. Структуры, интерфейсы и обобщения...................................................................92
Часть II
Создание надежных приложений
Глава 4. Обработка ошибок и паник..................................................................................... 118
Глава 5. Конкурентность в Go................................................................................................. 149
Глава 6. Форматирование, тестирование, отладка
и оценка производительности................................................................................................. 175
Глава 7. Доступ к файлам и основы сетевого взаимодействия.................................... 209
Часть III
Создание полноценных веб-приложений
Глава 8. Создание HTTP-сервера.......................................................................................... 236
Глава 9. HTML-шаблоны и шаблоны электронных писем........................................... 262
Глава 10. Отправка и получение данных............................................................................. 288
Глава 11. Работа с внешними сервисами............................................................................. 312
Часть IV
Go в облаке, микросервисы и другие специализированные темы
Глава 12. Облачные приложения и взаимодействие между сервисами................... 340
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go.................. 367
Оглавление
Предисловие.....................................................................................................................................14
Благодарности..................................................................................................................................16
О книге................................................................................................................................................18
Кому стоит прочитать эту книгу.........................................................................................18
Структура издания...................................................................................................................18
О коде...........................................................................................................................................20
Дополнительные онлайн-ресурсы......................................................................................21
Об авторах.........................................................................................................................................22
Иллюстрация на обложке............................................................................................................23
От издательства...............................................................................................................................24
О научном редакторе русскоязычного издания............................................................24
Часть I
Основы языка Go
Глава 1. Начало работы с Go.......................................................................................................26
1.1. Язык Go................................................................................................................................26
1.2. Важные особенности Go.................................................................................................29
1.2.1. Возврат нескольких значений..........................................................................29
1.2.2. Современная стандартная библиотека.........................................................31
1.2.3. Реализация конкурентности с помощью горутин и каналов................34
1.2.4. Инструментарий Go............................................................................................38
1.3. Место Go среди других языков программирования.............................................45
1.3.1. C и Go.......................................................................................................................45
1.3.2. Rust, Zig, Nim и Go...............................................................................................47
Оглавление
7
1.3.3. Java и Go..................................................................................................................47
1.3.4. Python, PHP, JavaScript и Go...........................................................................49
1.4. Первые шаги в работе с Go............................................................................................51
1.4.1. Установка Go.........................................................................................................51
1.4.2. Работа с Git и системами контроля версий.................................................51
1.4.3. Изучение рабочего пространства....................................................................52
1.4.4. Переменные окружения.....................................................................................52
1.4.5. Использование ИИ-инструментов вместе с Go........................................52
1.5. Привет, Go...........................................................................................................................53
Резюме..........................................................................................................................................55
Глава 2. Создание консольного приложения........................................................................56
2.1. Создание CLI-приложений на языке Go..................................................................57
2.1.1. Флаги командной строки..................................................................................57
2.1.2. Определение допустимых значений
с помощью перечислений.............................................................................................60
2.1.3. Срезы, массивы и словари.................................................................................62
2.1.4. Фреймворки для создания консольных приложений.............................63
2.2. Работа с конфигурацией................................................................................................69
2.2.1. Использование конфигурационных файлов..............................................70
2.2.2. Передача конфигурации через переменные окружения.........................75
2.3. Работа с веб-серверами в реальных условиях........................................................77
2.3.1. Запуск и завершение работы сервера............................................................77
2.3.2. Корректное завершение работы с использованием
сигналов ОС......................................................................................................................78
2.3.3. Маршрутизация веб-запросов.........................................................................81
Резюме..........................................................................................................................................91
Глава 3. Структуры, интерфейсы и обобщения...................................................................92
3.1. Представление данных с помощью пользовательских структур.....................93
3.1.1. Создание пользовательских структур данных...........................................93
3.1.2. Функции внутри структур................................................................................95
3.1.3. Анонимные идентификаторы..........................................................................98
3.1.4. Структурные теги.................................................................................................98
3.1.5. Преобразование данных в формат JSON.................................................. 100
3.2. Сравнение Go с функциональными и объектно-ориентированными
языками программирования.............................................................................................. 101
3.3. Расширение функциональности с помощью интерфейсов............................. 106
8
Оглавление
3.4. Упрощение кода с помощью обобщений............................................................... 109
3.4.1. Использование функций с обобщениями................................................. 112
3.4.2. Использование ограничений и аппроксимации типов........................ 114
Резюме....................................................................................................................................... 116
Часть II
Создание надежных приложений
Глава 4. Обработка ошибок и паник..................................................................................... 118
4.1. Обработка ошибок......................................................................................................... 119
4.1.1. Рекомендации по работе со значением nil................................................ 122
4.1.2. Пользовательские типы ошибок.................................................................. 125
4.1.3. Переменные ошибок......................................................................................... 126
4.2. Оборачивание ошибок................................................................................................. 129
4.3. Механизм паники.......................................................................................................... 130
4.3.1. Отличие паники от ошибки........................................................................... 130
4.3.2. Обработка паник................................................................................................ 132
4.3.3. Восстановление после паники...................................................................... 134
4.3.4. Перехват паники с помощью оператора defer......................................... 135
4.3.5. Обработка паник, возникших в горутинах............................................... 140
Резюме....................................................................................................................................... 148
Глава 5. Конкурентность в Go................................................................................................. 149
5.1. Модель конкурентности в Go.................................................................................... 150
5.2. Работа с горутинами..................................................................................................... 151
5.2.1. Ожидание завершения горутин.................................................................... 152
5.2.2. Блокировка с помощью мьютекса............................................................... 157
5.3. Работа с каналами.......................................................................................................... 162
5.3.1. Использование каналов.................................................................................. 163
5.3.2. Закрытие каналов.............................................................................................. 166
5.3.3. Блокировка с помощью буферизованных каналов............................... 171
Резюме....................................................................................................................................... 174
Глава 6. Форматирование, тестирование, отладка
и оценка производительности................................................................................................. 175
6.1. Поддержание чистоты кода и структуры проекта.............................................. 176
6.1.1. Форматирование кода..................................................................................... 176
6.1.2. Выявление типичных ошибок с помощью go vet................................... 179
6.1.3. Обновление зависимостей............................................................................. 182
Оглавление
9
6.2. Логирование.................................................................................................................... 182
6.2.1. Отправка логов в разные каналы вывода.................................................. 186
6.2.2. Структурированное логирование................................................................ 188
6.2.3. Получение доступа к трассировке стека и ее захват............................. 190
6.3. Модульное тестирование в Go.................................................................................. 193
6.3.1. Создание тестового набора с использованием табличного
подхода............................................................................................................................. 193
6.3.2. Фаззинг-тестирование..................................................................................... 197
6.3.3. Именование тестов........................................................................................... 202
6.3.4. Проверка тестового покрытия с помощью go cover.............................. 203
6.3.5. Подождите, а где отладчик?........................................................................... 205
6.4. Оценка и оптимизация производительности...................................................... 205
Резюме....................................................................................................................................... 208
Глава 7. Доступ к файлам и основы сетевого взаимодействия.................................... 209
7.1. Работа с файлами........................................................................................................... 210
7.1.1. Чтение файлов.................................................................................................... 210
7.1.2. Запись в файлы.................................................................................................. 214
7.2. Организация сетевого взаимодействия через TCP........................................... 216
7.2.1. Логирование в сетевой ресурс...................................................................... 216
7.2.2. Обратное давление при сетевом логировании........................................ 218
7.3. Основы сетевого взаимодействия по UDP........................................................... 220
7.4. Веб-сокеты и события, отправляемые сервером................................................. 222
7.4.1. Реализация онлайн-чата с использованием веб-сокетов.................... 223
7.4.2. События, отправляемые сервером.............................................................. 229
Резюме....................................................................................................................................... 234
Часть III
Создание полноценных веб-приложений
Глава 8. Создание HTTP-сервера.......................................................................................... 236
8.1. Маршрутизация запросов и обработка данных.................................................. 237
8.1.1. Маршрутизация на основе HTTP-методов.............................................. 238
8.1.2. Расширение контроля над сервером........................................................... 243
8.2. Маршрутизация на основе значений, извлеченных
из пути запроса....................................................................................................................... 246
8.2.1. Обработка данных POST-запросов и параметров
запроса.............................................................................................................................. 249
8.2.2. Обработка данных форм................................................................................. 251
10
Оглавление
8.2.3. Чтение и запись cookie и других заголовков........................................... 254
8.2.4. Обработка веб-токенов JSON....................................................................... 257
8.3. Генерация ошибок и базовая аутентификация................................................... 259
Резюме....................................................................................................................................... 261
Глава 9. HTML-шаблоны и шаблоны электронных писем........................................... 262
9.1. Работа с HTML-шаблонами....................................................................................... 263
9.1.1. Обзор стандартного пакета для работы с HTML................................... 263
9.1.2. Добавление функциональности в шаблоны............................................ 268
9.1.3. Ограничение парсинга шаблонов................................................................ 271
9.1.4. Когда выполнение шаблона завершается с ошибкой........................... 272
9.1.5. Комбинирование шаблонов........................................................................... 275
9.2. Использование шаблонов для электронных писем........................................... 285
Резюме....................................................................................................................................... 287
Глава 10. Отправка и получение данных............................................................................. 288
10.1. Раздача статического содержимого...................................................................... 289
10.1.1. Раздача подкаталогов.................................................................................... 291
10.1.2. Использование файлового сервера
с пользовательскими страницами ошибок.......................................................... 292
10.1.3. Встраивание файлов в исполняемый файл........................................... 294
10.1.4. Раздача содержимого из альтернативного местоположения........... 296
10.2. Продвинутая обработка форм................................................................................. 298
10.2.1. Обработка запросов с данными формы................................................... 299
10.2.2. Обращение к нескольким значениям одного поля формы............... 300
10.2.3. Работа с файлами и отправка составного содержимого.................... 301
10.2.4. Загрузка нескольких файлов...................................................................... 303
10.2.5. Проверка допустимости типа загружаемого файла............................ 305
10.2.6. Инкрементное сохранение загружаемого файла................................. 307
Резюме....................................................................................................................................... 311
Глава 11. Работа с внешними сервисами............................................................................. 312
11.1. Использование REST API в качестве полнофункционального
клиента...................................................................................................................................... 313
11.1.1. Использование HTTP-клиента.................................................................. 313
11.1.2. Обработка ошибок.......................................................................................... 315
11.2. Обработка и передача ошибок по HTTP............................................................. 319
11.2.1. Создание пользовательских ошибок........................................................ 321
11.2.2. Обработка пользовательских ошибок..................................................... 323
Оглавление
11
11.3. Парсинг и преобразование данных JSON........................................................... 325
11.4. Версионирование REST API................................................................................... 329
11.4.1. Версия API в URL-адресе............................................................................ 329
11.4.2. Версия API в типе содержимого................................................................ 331
11.5. Работа с протоколом gRPC...................................................................................... 333
Резюме....................................................................................................................................... 338
Часть IV
Go в облаке, микросервисы и другие специализированные темы
Глава 12. Облачные приложения и взаимодействие между сервисами................... 340
12.1. Основы облачных вычислений............................................................................... 341
12.1.1. Модели облачных вычислений.................................................................. 341
12.1.2. Контейнеры и облачно-ориентированные приложения................... 343
12.2. Микросервисы и обеспечение их высокой доступности............................... 346
12.3. Взаимодействие между сервисами........................................................................ 349
12.3.1. Ускорение REST-взаимодействия............................................................ 349
12.4. Запуск приложений на облачных серверах........................................................ 357
12.4.1. Определение среды во время выполнения............................................ 358
12.4.2. Сборка приложения для облачной среды.............................................. 361
12.5. Мониторинг среды выполнения............................................................................ 364
Резюме....................................................................................................................................... 366
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go.................. 367
13.1. Три составляющие системы рефлексии.............................................................. 368
13.1.1. Переключение логики на основе типа и вида....................................... 369
13.1.2. Проверка реализации интерфейса............................................................ 373
13.1.3. Анализ полей структуры.............................................................................. 377
13.2. Структуры, теги и аннотации.................................................................................. 381
13.2.1. Аннотирование структур.............................................................................. 381
13.2.2. Использование тегов...................................................................................... 383
13.2.3. Обработка тегов в структуре....................................................................... 384
13.3. Генерация кода средствами самого Go................................................................. 390
13.4. Взаимодействие с кодом на других языках........................................................ 396
Резюме....................................................................................................................................... 398
Отзывы о первом издании
Это руководство по Go содержит примеры, описание подводных камней и масштабируемых подходов, о которых полезно знать как начинающим разработчикам, так и опытным архитекторам программного обеспечения.
Закари Мэннинг,
старший DevOps-инженер в компании Zillow
Незаменимый справочник для Go-разработчиков, желающих освежить знания
ключевых концепций или еще лучше разобраться в процессах тестирования,
логирования и проектирования микросервисов.
Джонатан Ривз,
инженер-программист в компании EQL Games
Эта книга обязательна к прочтению для всех Go-энтузиастов. В ней приводятся
практические советы и обсуждаются интересные идеи, которые позволят вам
задействовать весь потенциал Golang при разработке современного программного обеспечения.
Серхио Бритос Аревало,
старший инженер-программист
в компании Schwarz Global Services
Книга, которая поможет вам усовершенствовать свои навыки практической
работы на языке Go.
Леонардо Таккари,
архитектор облачных решений в компании faire.ai
Посвящается моим друзьям и родным, которые не видели меня
по вечерам и в выходные, пока я работал над этой книгой, коллегам, поддерживавшим меня и подстраховывавшим в нужные
моменты, а также наставникам и соавторам, у которых
я постоянно учусь чему-то новому.
Натан Козыра
Предисловие
Я начал программировать в 12 лет и сразу же полюбил это ремесло и искусство.
Спустя почти 30 лет удивительно видеть, как сильно изменилась и выросла эта
отрасль: то, что раньше было занятием для увлеченных, стало повсеместным.
В школьные годы, во время учебы в университете и аспирантуре, а затем и в ходе
своей профессиональной деятельности я использовал любую возможность для
написания кода. То, что мне удалось превратить это увлечение в профессию, до
сих пор вызывает у меня изумление.
Хотя многое из тех времен я вспоминаю с ностальгической теплотой, например
отдельную комнату с компьютером, а также открытия, сделанные до и после
появления Интернета, я совсем не скучаю по старым языкам программирования. В те годы мне приходилось использовать различные версии BASIC, C,
Delphi, Perl, PHP, ASP и Java, но ни один из них не казался мне по-настоящему
удобным — ни в плане архитектуры, ни в плане инструментария. Мне всегда
чего-то не хватало, какая-нибудь особенность языка непременно оборачивалась
проблемой — периодически или постоянно. Когда в 2009 году был публично
анонсирован язык Go, я решил опробовать его, не питая особых надежд. Я знал,
кто стоял за его созданием, но полагал, что он представляет собой просто очередную сложную версию C. Однако на деле он оказался одним из самых чистых
и удобных языков, которые мне доводилось использовать. Я начал продвигать
Go среди своих коллег и почти сразу приступил к активной разработке на нем.
И хотя сегодня Go не единственный инструмент в моем арсенале, я по-прежнему
ежедневно пишу код на этом языке. Одним из недооцененных аспектов Go является то, что он вдохновил разработчиков на создание множества современных
языков, способных задействовать мощь сегодняшних вычислительных систем
и соответствовать требованиям актуальных сценариев использования. С момента
появления Go к нему присоединились такие языки, как Rust, Zig, Nim, Odin,
Carbon и другие, предлагающие элегантные способы решения аналогичных
Предисловие
15
задач. Каждый из них имеет свои достоинства, но Go по-прежнему остается
языком, который обеспечивает все необходимое для создания простого, чистого,
компилируемого кода с автоматическим управлением памятью.
Если ранее вы работали с другими системными языками, вам понравятся
встроенные инструменты Go. Его пакетный менеджер прост в использовании
и совсем не мешает работать. Набор для тестирования дает все необходимое для
интеграции тестов в конвейер сборки, а форматировщик кода go fmt позволяет
избежать споров по поводу стиля и линтинга.
Если у вас уже есть опыт работы с Go, надеюсь, что книга поможет вам найти
более эффективные способы решения задач. Мы рассмотрим множество разных
тем и постараемся охватить широкий спектр вопросов с помощью небольших,
но наглядных примеров. Больше всего мне хотелось бы, чтобы эта книга вдохновила вас продолжать использовать Go и присоединиться к сообществу Goразработчиков.
Натан Козыра
Благодарности
Написание книги (и даже ее переработка) требует немало сил, времени и терпения, а также участия множества людей. Я хотел бы поблагодарить всех, кто
помог довести этот проект до завершения.
Когда посвящаешь все свое время работе, не уделяешь должного внимания своим близким. Поэтому прежде всего я благодарю свою семью и друзей, которые
мирились с моей занятостью по вечерам и в выходные дни. Без их поддержки
и понимания книга никогда не была бы завершена. Написание и переработка
книги отнимают гораздо больше времени, чем ожидают большинство авторов,
и я глубоко признателен всем, кто окружил меня заботой и дал необходимое
пространство, чтобы я смог дойти до конца.
Не меньшую поддержку я получил от своего работодателя и коллег, которые
дали мне возможность сосредоточиться на проекте.
Отдельную благодарность я выражаю команде издательства Manning и особенно хочу отметить моего редактора Джеффри Вайсберга, который помогал мне
улучшить стиль, ясность, структуру текста, а порой и грамматику. Без его руководства я не смог бы соблюсти сроки и выдать качественно написанный текст.
Остальные участники команды Manning также работали очень четко и слаженно,
поддерживая работу над проектом в бодром, но контролируемом темпе.
Я глубоко признателен рецензентам — они внимательно отнеслись к деталям
и нашли множество мелочей, которые в противном случае могли бы ускользнуть.
От их стилистических и идиоматических правок эта книга только выиграла.
Я благодарю всех, кто участвовал в рецензировании: Адира Шемеша, Андреаса
Шрепфера, Ануджа Мора, Борко Джурковича, Кристофера Ниба, Клиффорда
Тербера, Дэна Шейха, Диего Стамини, Эрнеста Аддея, Джузеппе Максию, Джасмита Сингха, Джейсона Контента, Жерома Мейера, Джима Амрайна, Джоэла
Холмса, Джонатана Ривза, Кита Кима, Леонардо Таккари, Манзура, Микеле
Благодарности
17
Ди Педе, Михаэла Барбу, Ниража Шаха, Нолана, Пола Сноу, Павла Анни, Питера Селларса, Раманана Н., Сачина Хандикера, Скотта Линга, Серхио Бритоса
Аревало, Тома Хоуарта, Тони Холдройда, Закари Мэннинга. Ваши предложения
помогли существенно улучшить эту книгу.
И наконец, хочу выразить особую признательность команде Go в Google. За более
чем 30 лет разработки я не встречал языка, который позволял бы просто решать
задачи так, как это делает Go. Это по-настоящему увлекательный язык. Каждый
раз, переходя на другие языки, я вспоминаю о его простоте и выразительности —
фундаментальных качествах, заложенных в его философию. Надеюсь, этот язык
будет развиваться, а вы — получать от работы с ним столько же удовольствия
и пользы, сколько получаю я.
Натан Козыра
О книге
Второе издание книги «Go на практике» — это обзорный курс, в котором
читатель познакомится с концепциями, выходящими за рамки основ языка.
В книге рассматривается множество тем: от построения конкурентных систем с использованием сопрограмм для языка Go — так называемых горутин
(goroutine) — до разработки производительных веб-приложений, а также
инструменты, необходимые для написания качественного кода. Вы научитесь
работать с сетевыми соединениями, получать доступ к файлам, познакомитесь
с паттернами реализации конкурентности и такими протоколами передачи
данных, как TCP/REST, gRPC и UDP. Отдельное внимание будет уделено
тестированию и обработке ошибок, что позволит вам в дальнейшем создавать
надежные системы, способные справляться с вызовами, возникающими в реальных условиях эксплуатации.
Кому стоит прочитать эту книгу
Книга ориентирована на Go-разработчиков среднего уровня, а также на опытных программистов, владеющих такими языками, как Java, C/C++, C# или
Rust. В то же время люди, желающие познакомиться с Go, также смогут
усвоить материал без особых трудностей. Язык Go предельно прост и обладает
низким порогом входа, что делает процесс обучения по ходу чтения вполне
комфортным.
Структура издания
Книга состоит из 13 глав, сгруппированных в четыре тематические части.
Первая часть знакомит читателей с общей структурой, возможностями и инструментарием языка Go.
О книге
19
В главе 1 язык Go сравнивается с другими языками (как уже устаревшими,
так и современными), объясняются структура приложений, написанных на
Go, основы их сборки и работы с зависимостями.
Глава 2 посвящена созданию консольных приложений: в ней рассматриваются флаги командной строки, конфигурационные параметры и переменные
окружения. В завершение описаны способы запуска и корректной остановки
веб-сервера.
В главе 3 подробно рассматриваются ключевые структуры данных языка
Go — структуры и интерфейсы. В ней вы научитесь создавать абстрактные
типы данных, использовать интерфейсы для обеспечения совместимости
с функциями и методами, а также познакомитесь с давно ожидаемыми и наконец реализованными обобщениями.
Вторая часть книги посвящена фундаментальным аспектам разработки ПО
(программного обеспечения) и инструментам, которые позволяют подготовить
приложения к работе в реальных условиях. Основное внимание в ней уделяется
обработке ошибок, тестированию и повышению производительности за счет
реализации конкурентности.
В главе 4 рассматриваются подходы к обработке ошибок и так называемых
паник. В ней мы обсудим методы передачи и обертывания ошибок, а также
сравним механизм остановки потока выполнения с другими способами аварийного завершения работы программ.
Глава 5 посвящена конкурентности — одной из ключевых особенностей языка
Go. Мы рассмотрим саму модель, а также примеры использования горутин
и каналов, позволяющих этим горутинам обмениваться данными.
В главе 6 вы познакомитесь с мощными средствами тестирования, преду
смотренными в Go. Здесь рассматриваются бенчмарки, табличные тесты
и тестовые наборы, позволяющие поддерживать работоспособность кода при
возникновении пограничных случаев и регрессий.
Глава 7 посвящена основам работы с файловыми системами и сетевыми интерфейсами. Вы узнаете, как получать доступ к файлам, читать и записывать
их, а также извлекать информацию из файловых атрибутов уровня операционной системы (ОС). Кроме того, мы реализуем базовое низкоуровневое
сетевое взаимодействие с использованием протоколов TCP и UDP.
В третьей части книги мы применим изученные инструменты на практике для
разработки серверной и клиентской частей веб-сервиса. Вы научитесь обрабатывать входящие запросы и формировать исходящие, выступая в роли потребителя.
В главе 8 рассказывается, как построить полноценный HTTP-сервер на
основе стандартной библиотеки Go. В ней говорится о маршрутизации, работе с HTTP-запросами и ответами, а также о cookie-файлах и механизмах
аутентификации.
20
О книге
Глава 9 посвящена расширению базовой функциональности веб-серверов до
полноценного веб-приложения. В ней также рассматривается шаблонизатор
Go, позволяющий формировать ответы на основе абстрактных шаблонов для
веб-страниц, электронных писем и другого текстового содержимого.
В главе 10 поднимаются темы, связанные с веб-разработкой. Вы научитесь
создавать автономные или вспомогательные серверы для раздачи статических файлов, внедрять данные в скомпилированный исполняемый файл,
а также обрабатывать данные POST-запросов и загружать файлы.
Глава 11 посвящена взаимодействию с внешними сервисами, в рамках которого мы выступаем в роли клиента, а не сервера. Вы научитесь проектировать API, а также работать со сторонними интерфейсами и их протоколами.
Мы также рассмотрим альтернативу REST — gRPC — и способы использования этого протокола для улучшения кода.
В четвертой, завершающей части книги говорится о более специализированных
и продвинутых областях применения языка Go.
В главе 12 речь пойдет о подготовке и запуске приложений в облачной среде. Сначала мы обсудим, как с помощью Go можно оркестровать облачные
сервисы, решать задачи сетевого взаимодействия, обнаружения сервисов
и мониторинга. Затем мы рассмотрим альтернативные варианты облачного
взаимодействия, например, с помощью gRPC, выступая в роли как сервера,
так и клиента API.
Глава 13 посвящена нишевым и продвинутым темам экосистемы Go. Более
глубокое изучение концепции рефлексии и системы типов поможет вам
создавать более качественные библиотеки и обобщенный код. Мы также
рассмотрим вопросы совместимости с языком C и способы более тонкого
управления сборщиком мусора.
О коде
В книге содержится множество примеров кода. В основном они приведены
целиком и могут быть скомпилированы без дополнительных зависимостей.
Возможность привести полный исходный код подчеркивает краткость и простоту языка Go! Некоторые фрагменты кода включены прямо в текст и выделены моноширинным шрифтом — они относятся к более ранним примерам или
иллюстрируют способ изменения поведения представленного кода. Часто такие
блоки акцентируют внимание на тех местах, которые требуют дополнительного
пояснения или уточнения.
Исполняемые фрагменты кода можно найти по адресу https://livebook.manning.com/
book/go-in-practice-second-edition. Актуальная и полная версия исходного кода содержится в репозитории книги на GitHub: https://github.com/nkozyra/go-in-practice.
О книге
21
Дополнительные онлайн-ресурсы
Помимо этой книги и аналогичных изданий, существует еще несколько замечательных ресурсов для изучения языка Go:
раздел Reddit, посвященный Go, — это отличное место как для начинающих,
так и для опытных Go-разработчиков: https://www.reddit.com/r/golang;
платформа Stack Overflow позволяет задавать вопросы, помогать другим и находить решение распространенных проблем, связанных с использованием
языка Go: https://stackoverflow.com/questions/tagged/golang;
встроенная документация по Go, часто упоминаемая в книге, отличается
полнотой и ясностью. Справочник по стандартной библиотеке позволяет
легко узнать об основных функциях языка: https://pkg.go.dev/std;
мы настоятельно рекомендуем подписаться на новостную рассылку Golang
Weekly, где регулярно освещаются интересные проекты, а также нововведения и изменения, которые еще только предлагается внести в язык: https://
golangweekly.com.
Об авторах
Натан Козыра — full-stack-разработчик с более чем
30-летним опытом создания веб- и мобильных приложений.
Одним из первых начал использовать язык Go и активно
работает с ним с 2009 года. Имеет степень магистра компьютерных наук в области машинного обучения и более
десяти лет занимается созданием решений в данной области. Помимо Go, он активно использует TypeScript
и Rust, а также экспериментирует с такими новыми языками для системного программирования, как Odin и Zig.
Мэтт Батчер — архитектор компании Deis, участвующий
в разработке проектов c открытым исходным кодом. Автор
нескольких книг и десятков статей. Имеет докторскую
степень по философии и преподает на факультете информатики в Университете Лойолы в Чикаго. Он с увлечением занимается построением сильных команд и разработкой
элегантных решений для сложных задач.
Мэтт Фарина — ведущий инженер в группе передовых
технологий компании Hewlett Packard Enterprise. Автор,
докладчик и активный участник сообщества разработчиков и пользователей ПО с открытым исходным кодом,
обладающий более чем 25-летним опытом разработки программного обеспечения. Старается решать практические
задачи обычных пользователей, сочетая новейшие технологии с простыми, но зачастую незаслуженно забытыми
подходами.
Иллюстрация на обложке
Иллюстрация на обложке называется «Костюм жены русского крестьянина,
1768 год» и заимствована из коллекции изображений костюмов Томаса Джеффериса, которая публиковалась в Лондоне в период с 1757 по 1772 год. На титульном листе указано, что изображения представляют собой гравюры на меди,
раскрашенные вручную и покрытые гуммиарабиком.
В те времена происхождение, профессию и социальное положение любого
человека можно было легко определить по его одежде. Издательство Manning
продолжает традицию уважения к культурному наследию, украшая обложки
книг образами из прошлого. Они символизируют изобретательность, индивидуальность и стремление к знаниям, которые сегодня так актуальны в мире
технологий и науки.
От издательства
Мы выражаем огромную благодарность клубу рецензентов ИТ-литературы
ReadIT Club за помощь в работе над русскоязычным изданием книги и их вклад
в повышение качества переводной литературы.
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com
(издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.
О научном редакторе русскоязычного издания
Дмитрий Бардин — ведущий разработчик, архитектор решений, один из авторов
курса «Архитектор ПО» от «Яндекс Практикума». В настоящее время занимается разработкой бэкенда «КиноПоиска» с применением языков Go и Java.
В прошлом руководитель службы продуктовой разработки и ресурс-менеджер.
Опыт в ИТ — более 16 лет.
Часть I
Основы языка Go
В этой части рассматриваются основные сведения о языке Go и его встроенных
инструментах, а также закладывается фундамент для создания приложений.
Мы надеемся, что этот фундамент окажется полезным при разработке любых
проектов, в том числе тех, что описываются в последующих главах.
Глава 1 содержит обзор языка Go и его экосистемы — для тех, кто еще не знаком
с ним. Главы 2 и 3 посвящены базовым компонентам, необходимым для построе
ния консольного приложения. В главе 2 поднимаются такие темы, как работа
с консольными утилитами и серверами, а также использование параметров
конфигурации. В главе 3 мы обсудим ключевые структуры данных и вызовы
методов с помощью структур и интерфейсов. Вы также познакомитесь с обобщенными типами и обобщенным программированием. Разработчики языка Go
более десяти лет избегали добавления поддержки этих инструментов, однако
их внедрение позволило упростить код за счет обобщения некоторых функций
и методов и избавления от необходимости прибегать к избыточной рефлексии
во время выполнения.
1
Начало работы с Go
В этой главе
3 Знакомство с языком Go.
3 Место Go среди других языков программирования.
3 Первые шаги в работе с Go.
В этой главе дается обзор языка Go и его ключевых особенностей. В ней закладывается основа, необходимая профессиональному разработчику для применения
Go в процессе решения реальных рабочих задач. Вы познакомитесь с самим
языком и сопровождающим его инструментарием, узнаете, какое место Go занимает среди других языков программирования, а также научитесь устанавливать
Go и сможете приступить к разработке собственного приложения или модуля.
Если вы уже давно используете Go, то часть материала может показаться знакомой и вам наверняка захочется сразу перейти к следующим главам. Тем не менее
за последние годы язык претерпел значительные изменения. Он по-прежнему
остается лаконичным и сфокусированным на практических задачах, но благодаря отзывам участников сообщества в него были добавлены мощные новые
возможности, включая поддержку обобщений.
1.1. Язык Go
Go (иногда называемый Golang) — это статически типизированный компилируемый язык программирования с открытым исходным кодом, изначально разработанный компанией Google. В 2007 году Роберт Гриземер, Роб Пайк и Кен
1.1. Язык Go
27
Томпсон начали работу над языком системного программирования нового поколения, предназначенным для решения проблем, возникающих при масштабировании больших систем. Публичный анонс языка состоялся в ноябре 2009 года,
и вскоре он начал стремительно набирать популярность.
Вместо стремления к теоретической чистоте создатели Go сосредоточились на
реальных практических задачах. Они черпали вдохновение из лучших языков
прошлого, таких как C, Pascal, Java и Python, стараясь перенять их сильные стороны и при этом свести сложность к минимуму. Результатом стал мощный, но
по-прежнему легкий язык. Go обладает лаконичным синтаксисом, выдающимся
набором инструментов и производительностью, которая делает его особенно
привлекательным на фоне многих современных альтернатив.
Go — это не обычный компилируемый язык со статической типизацией. Благодаря ряду особенностей статическая типизация в Go создает впечатление
динамической, а скомпилированные исполняемые файлы работают в среде
выполнения со встроенным сборщиком мусора. Разработчики Go изначально
ориентировались на проекты, характерные для Google, в частности связанные
с огромными кодовыми базами, над которыми работают большие команды разработчиков.
В своей основе Go представляет собой язык программирования, определенный
спецификацией, реализация которой может быть выполнена любым компилятором. Стандартная реализация поставляется через инструмент командной
строки go. Однако Go — это не только язык. Как показано на рис. 1.1, поверх
языка выстроено несколько уровней инфраструктуры.
Рис. 1.1. Уровни языка Go
Помимо самого языка программирования, для разработки приложения требуются средства компиляции, тестирования, документирования и форматирования. Во многих языках соответствующие возможности предоставляются
28
Глава 1. Начало работы с Go
с привлечением сторонних инструментов. Однако утилита go, которая используется для компиляции приложений, обеспечивает поддержку всех этих функций.
Одним из самых заметных элементов этого инструментария является система
управления пакетами. Язык Go и связанная с ним утилита go изначально поддерживают работу с зависимостями как на локальном, так и на глобальном уровне.
Встроенная система управления пакетами, наряду с единым инструментарием
для решения основных задач разработки, способствовала формированию целой
экосистемы вокруг этого языка.
Одной из характерных особенностей Go является его простота. При создании
этого языка Гриземер, Пайк и Томпсон придерживались подхода, согласно которому ни одна функция не добавлялась в язык, пока все трое не соглашались с тем,
что она действительно нужна. Такая модель принятия решений, подкрепленная
многолетним опытом самих разработчиков, привела к созданию простого, но
мощного языка — настолько лаконичного, что его можно удержать в голове, и при
этом достаточно выразительного для написания широкого спектра программ.
Такой поступательный и осторожный подход к проектированию позволяет сохранять компактность языка и внедрять новые возможности после тщательного
согласования. Например, обобщения, давно существующие в C++ и Java, долго
не включались в Go. Команда разработчиков на протяжении длительного времени вела открытое обсуждение достоинств и недостатков их внедрения. В конечном счете сообщество пользователей и создатели языка пришли к выводу, что
поддержка обобщений действительно полезна и ее можно добавить, не нарушая
удобочитаемость кода, не усложняя язык и не ломая обратную совместимость.
О поддержке обобщений мы подробно поговорим в главе 3. Хорошей иллюстрацией простоты Go является синтаксис объявления переменных:
var i int = 2
Здесь создается переменная целочисленного типа (int) и ей сразу присваивается значение 2. Однако, поскольку переменная инициализируется конкретным
значением, этот синтаксис можно сократить:
var i = 2
Компилятор сам выведет тип переменной, исходя из присвоенного значения.
В данном случае — int, так как переменной присвоено целое число 2.
Но и это еще не все. Если вы не хотите указывать ключевое слово var, то можете
использовать краткую форму объявления переменной:
i := 2
Этот компактный эквивалент первого варианта почти вдвое короче, легко читается и работает благодаря тому, что компилятор сам заполняет пропущенные
фрагменты. Такая переменная выглядит как динамически типизированная, но на
деле использует все механизмы контроля, присущие статически типизированным
1.2. Важные особенности Go
29
компилируемым языкам. Подобная простота делает изучение основ Go особенно
доступным.
Хотя ядро языка Go остается довольно простым, встроенная система управления
зависимостями позволяет подключать недостающие элементы, выходящие за
рамки стандартной библиотеки. Такие внешние компоненты можно импортировать как сторонние пакеты и использовать в приложениях через встроенный
менеджер пакетов.
1.2. Важные особенности Go
Поскольку Go ориентирован на решение реальных практических задач, он обладает рядом примечательных особенностей, которые, если их использовать
вместе, составляют строительные блоки Go-приложений.
1.2.1. Возврат нескольких значений
В Go функции и методы могут принимать и возвращать несколько значений.
В большинстве языков программирования, даже разработанных после Go, функция возвращает только одно значение. При необходимости вернуть несколько
значений их обычно упаковывают в кортеж, хеш-таблицу, массив или другую
структуру данных. Go — это один из немногих языков, в котором поддержка
множественных возвращаемых значений реализована на уровне синтаксиса. Эта
возможность используется практически во всех библиотеках и приложениях, написанных на Go. Рассмотрим функцию из листинга 1.1, возвращающую две строки.
Листинг 1.1. Возврат нескольких значений
package main
import (
"fmt"
)
Функция без входных
параметров, возвращающая
два значения типа string
func getStrings() (string, string) {
return "Foo", "Bar"
Возвращаются две
}
строки: "Foo" и "Bar"
func main() {
n1, n2 := getStrings()
fmt.Println(n1, n2)
n3, _ := getStrings()
fmt.Println(n3)
}
Вызывающая сторона
получает оба значения
и отображает их
Одно из значений можно проигнорировать,
используя вместо переменной символ
нижнего подчеркивания (_)
СОВЕТ Используемые в этой главе пакеты (например, fmt, bufio и net) входят в состав стандартной библиотеки Go. API и особенности их работы подробно описываются
на сайте https://golang.org/pkg.
30
Глава 1. Начало работы с Go
В этом примере оба возвращаемых значения определены в сигнатуре функции
после списка аргументов. Поскольку функция getStrings не принимает параметров, но возвращает два значения типа string, при ее вызове необходимо
подготовить две переменные для приема возвращаемых значений. Если одно
из значений вам не нужно, то вместо имени переменной можно использовать
символ нижнего подчеркивания (_).
Наиболее часто такой метод применяется при возврате ошибки. В Go принято
значение ошибки указывать последним. Поэтому конструкции вроде a, b, err :=
someFunc() встречаются довольно часто. Рекомендуется придерживаться этого
шаблона при возврате ошибки (или значения nil, если ошибка отсутствует).
Подробнее об ошибках мы поговорим в главе 4.
Кроме того, вы можете задать имена возвращаемых значений прямо в сигнатуре
функции и использовать их как обычные переменные. Благодаря этому можно
явно не указывать эти значения в инструкции return в теле функции, сократив
тем самым объем шаблонного кода. Чтобы это продемонстрировать, давайте
перепишем предыдущий пример с использованием именованных возвращаемых
значений (листинг 1.2).
Листинг 1.2. Именованные возвращаемые значения
package main
import (
"fmt"
)
func getStrings() (first string, second string) {
first = "Foo"
Именованным возвращаемым значениям
second = "Bar"
присваиваются конкретные значения
return
}
Инструкция return вызывается
без указания значений
func main() {
n1, n2 := getStrings()
Переменным
fmt.Println(n1, n2)
присваиваются значения
}
У возвращаемых
значений есть имена
Когда вызывается функция getStrings, именованные переменные уже доступны для присваивания значений. При вызове оператора return без аргументов
возвращаются текущие значения этих именованных переменных. Такой способ
называется пустым возвратом (naked return). Со стороны вызывающего кода
поведение остается прежним: получение и применение результата будет происходить так же, как при обычном использовании оператора return. Хотя такой
гибкий подход делает код более лаконичным и удобочитаемым, он становится
менее распространенным по мере увеличения длины функций и методов, которое
затрудняет отслеживание возвращаемых значений.
1.2. Важные особенности Go
31
1.2.2. Современная стандартная библиотека
Многие современные приложения имеют общие черты, например работают
в сети и используют шифрование. Go-разработчику не нужно тратить время на
поиск сторонних пакетов, поскольку стандартная библиотека Go предоставляет
средства для реализации сетевого взаимодействия, криптографии, сериализации
данных, выполнения математических вычислений и многого другого. Давайте
кратко рассмотрим некоторые компоненты стандартной библиотеки, чтобы вы
имели представление о ее возможностях.
ПРИМЕЧАНИЕ Документация по стандартной библиотеке Go с примерами доступна
по адресу https://pkg.go.dev/std.
Сетевое взаимодействие и протокол HTTP
Сетевое приложение, как правило, должно уметь подключаться к другим
устройствам в сети (выступать в роли клиента) и принимать входящие со
единения (выступать в роли сервера). Как показано в листинге 1.3, стандартный
пакет Go net упрощает реализацию такого взаимодействия, независимо от
того, используете ли вы HTTP или такие протоколы, как TCP (Transmission
Control Protocol, протокол управления передачей), UDP (User Datagram
Protocol, протокол пользовательских датаграмм) и другие распространенные
схемы.
Листинг 1.3. Проверка состояния TCP-соединения
package main
import (
"bufio"
"fmt"
"log"
"net"
)
func main() {
conn, err := net.Dial("tcp", "golang.org:80")
if err != nil {
Обрабатывает возникшую
log.Fatal(err)
при подключении ошибку
}
и завершает выполнение функции
}
Устанавливает TCP-соединение
с golang.org:80
Посылает через соединение
отформатированную строку —
в данном случае строку HTTP-запроса
методом GET
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")
status, err := bufio.NewReader(conn).ReadString('\n')
Считывает ответ
if err != nil {
до первого символа
Обрабатывает ошибки, возникающие
log.Fatal(err)
новой строки (\n)
при чтении данных из соединения
}
Выводит на экран
первую строку ответа
log.Println(status)
32
Глава 1. Начало работы с Go
Прямое подключение к определенному порту по протоколу TCP реализуется
с помощью пакета net, который предоставляет унифицированный интерфейс
для установки различных типов соединений. Функция Dial устанавливает
соединение, используя заданный тип и конечную точку. В данном случае мы
устанавливаем TCP-соединение с golang.org на порте 80, через которое отправляется сформированный GET-запрос, после чего в консоль выводится первая
строка ответа.
ПРИМЕЧАНИЕ В листинге 1.3 мы обрабатываем ошибки по мере их возникновения
и вызываем функцию log.Fatal, чтобы вывести информативное сообщение и завершить выполнение процесса. Язык Go предусматривает множество способов завершения работы программы, каждый из которых имеет свое назначение и преимущество.
Помимо функции log.Fatal (имеющей аналог с поддержкой форматирования), можно
использовать os.Exit(STATUS_CODE) или panic(). Эти подходы подробно рассматриваются в главе 4.
Возможность прослушивания порта реализуется столь же просто. Вместо обращения к конечной точке с помощью функции Dial вы можете использовать
функцию Listen из пакета net, чтобы настроить приложение на прослушивание
порта и обработку входящих соединений.
Для создания клиента и сервера в Go предусмотрен пакет http, который есть как
у клиента, так и у сервера (листинг 1.4). Клиентская часть достаточно проста
в использовании. Она охватывает большинство типовых сценариев, но при этом
обладает гибкостью, необходимой для ее адаптации под более сложные задачи.
Листинг 1.4. HTTP-запрос методом GET
package main
import (
"io"
"log"
"net/http"
)
Выполняет HTTP-запрос методом GET
func main() {
с использованием пакета net/http
resp, err := http.Get("http://example.com/")
if err != nil {
Обработчики ошибок,
log.Fatal("could not retrieve example.com", err)
дополненные
поясняющим контекстом
}
Откладывает закрытие объекта чтения
defer resp.Body.Close()
(в данном случае ответа сервера)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal("could not read body", err)
}
Обработчики ошибок,
}
log.Println(string(body))
дополненные поясняющим
контекстом
Выводит полученный ответ в виде
строки в стандартный поток вывода
(или в другой логгер)
1.2. Важные особенности Go
33
Данный пример демонстрирует формальный способ извлечения и отображения
тела простого HTTP-запроса методом GET. HTTP-клиент в Go обладает гораздо более широкими возможностями: он может работать с прокси-серверами,
поддерживать протокол TLS, устанавливать заголовки и cookie, создавать клиентские объекты и даже настраивать транспортный уровень передачи данных.
Расширенные TCP-соединения, такие как долговременные сеансы WebSocket
и события, отправляемые сервером, поддерживаются как стандартной библиотекой, так и сторонними решениями. Эти типы соединений будут рассмотрены
в главе 8.
Обратите внимание на ключевое слово defer в листинге 1.4. Любой оператор
с этим словом выполняется в конце соответствующего блока кода — в данном
случае в конце функции main(). Обычно defer используется для освобождения
ресурсов, например, при закрытии объектов типа Reader, хотя в этом примере его отсутствие не привело бы к негативным последствиям. Мы подробно
обсудим этот механизм в главе 4. А пока просто имейте в виду, что оператор
defer помещается в стек вызовов, и чем раньше вы его определите, тем меньше
вероятность, что инициированная впоследствии паника (panic) помешает его
выполнению.
Создание HTTP-сервера на языке Go — это весьма распространенная задача.
Средства, предоставляемые стандартной библиотекой Go, являются достаточно
мощными для создания масштабируемых приложений, при этом они просты
в освоении и обладают гибкостью, позволяющей справляться со сложными
случаями. Глава 8 посвящена запуску HTTP-сервера: в ней рассматриваются
темы маршрутизации, работы с cookie-файлами и аутентификации.
Код HTML
При работе с веб-серверами часто возникает необходимость генерации HTMLкода. Для этого можно использовать пакеты html и html/template. Пакет html
предназначен для экранирования и обратного преобразования HTML, а пакет
html/template позволяет создавать многократно используемые HTML-шаблоны
с возможностью подстановки переменных и управления логикой с помощью
Go-кода. Модель безопасности при взаимодействии с данными описана в документации, а вспомогательные функции позволяют работать с HTML, JavaScript
и не только. Система шаблонов является расширяемой, что делает ее удобной
как для новичков, так и для специалистов, занимающихся построением более
сложных решений.
Криптография
Криптография широко используется в современных приложениях как для работы
с простым хешем, так и для шифрования и расшифровки конфиденциальной
информации. В Go предусмотрены стандартные криптографические средства,
включая хеш-функции MD5 и SHA (Secure Hash Algorithm — безопасный
34
Глава 1. Начало работы с Go
алгоритм хеширования), протокол TLS, алгоритмы шифрования DES (Data
Encryption Standard — стандарт шифрования данных), TDEA (или Triple DES —
тройной DES), AES (Advanced Encryption Standard — расширенный стандарт
шифрования), а также HMAC (Keyed-Hash Message Authentication Code — код
аутентификации сообщений на основе хеш-функции) и многие другие. Кроме
того, в стандартной библиотеке доступен криптографически безопасный генератор случайных чисел (crypto/rand).
Кодирование данных
При обмене данными между системами часто возникают вопросы о формате
и кодировке. Например, получены ли данные в Base64? Нужно ли преобразовать данные JSON (JavaScript Object Notation) или XML (Extensible Markup
Language) в локальный объект? Подобные задачи типичны для современной
сетевой среды.
Язык Go изначально создавался с учетом задач кодирования данных. Внутри
самого Go все данные обрабатываются в формате UTF-8, что неудивительно,
ведь его авторы стояли и за разработкой самого стандарта UTF-8. Однако не все
данные, которые передаются между системами, закодированы в этом формате.
Иногда приходится иметь дело с другими представлениями, которые несут дополнительную смысловую нагрузку. Для преобразования и обработки таких
данных в Go предусмотрены специальные пакеты и интерфейсы. Они позволяют, например, превратить строку JSON в полноценный объект или структуру,
а также переключаться между кодировками или подключать внешние пакеты
для поддержки других форматов.
1.2.3. Реализация конкурентности
с помощью горутин и каналов
Сегодня многоядерные процессоры используются в самых разных компьютерах — от серверов и смартфонов до встраиваемых систем и микроконтроллеров.
До недавнего времени большинство языков программирования создавалось
с расчетом на однопроцессорные системы. В отличие от них Go изначально
предназначался для многопроцессорных архитектур.
Одной из ключевых особенностей Go является горутинa — функция, которая
может выполняться в конкурентном режиме параллельно с основной программой или другими горутинами. За управление горутинами отвечает среда
выполнения Go: она распределяет их по соответствующим потокам операционной системы (ОС) и освобождает ресурсы, когда они больше не нужны. Если
системе доступно несколько ядер, горутины могут выполняться одновременно,
поскольку разные потоки задействуют разные процессорные ядра. Однако даже
в рамках одного потока Go самостоятельно управляет переключением контекста
и организацией конкурентного выполнения операций.
1.2. Важные особенности Go
35
С точки зрения разработчика, создать горутину так же просто, как написать
функцию. На рис. 1.2 показан принцип работы горутин. Если вы привыкли
к языкам, использующим событийно-ориентированный или асинхронный подход
(async/await), данный способ может показаться непривычным, но он открывает широкие возможности для отправки и получения данных при выполнении
операций в конкурентном режиме.
Рис. 1.2. Горутины, распределенные по потокам, запущенным на доступных
процессорных ядрах
Чтобы проиллюстрировать работу горутин, рассмотрим пример из листинга 1.5,
в котором горутина считает от 0 до 4, в то время как основная программа выводит на экран строку Hello World.
Листинг 1.5. Вывод значений в конкурентном режиме
0
1
Hello World
2
3
4
Данный вывод представляет собой смесь двух функций, которые выводят результат в конкурентном режиме. Код, реализующий такое поведение, во многом
похож на обычное процедурное программирование, но с небольшим нюансом,
показанным в листинге 1.6.
Функция count — это обычная функция, которая считает от 0 до 4. Чтобы запустить ее параллельно, а не последовательно, используется ключевое слово go,
из-за чего функция main продолжает выполняться незамедлительно. В результате
count и main работают одновременно. Благодаря вызовам time.Sleep() порядок
их выполнения становится предсказуемым и воспроизводимым.
36
Глава 1. Начало работы с Go
Листинг 1.6. Отображение значений в конкурентном режиме
package main
import (
"fmt"
"time"
)
func count() {
for i := 0; i < 5; i++ {
fmt.Println(i)
time.Sleep(time.Millisecond * 5)
}
}
Функция,
выполняемая
в качестве горутины
Запуск горутины
func main() {
go count()
time.Sleep(time.Millisecond * 20)
fmt.Println("Hello World")
time.Sleep(time.Millisecond * 10)
}
Каналы позволяют горутинам взаимодействовать друг с другом или с другими
процессами. По умолчанию они блокируют выполнение, способствуя синхронизации работы горутин. На рис. 1.3 показано, как это работает.
Рис. 1.3. Передача переменных от одной горутины к другой через канал
В этом примере переменная передается от одной горутины к другой через канал —
и это работает даже тогда, когда горутины выполняются параллельно на разных
1.2. Важные особенности Go
37
ядрах. Хотя на рис. 1.3 показана передача данных в одном направлении, каналы
в Go могут быть как однонаправленными, так и двунаправленными. Направление
передачи задается с помощью синтаксиса <- или ->. В листинге 1.7 приведена
наглядная схема этого процесса.
Листинг 1.7. Использование канала
package main
import (
"fmt"
"time"
)
Канал типа int
передается в функцию
func printCount(c chan int) {
num := 0
Ожидает поступления
for num >= 0 {
значения в канал
num = <-c
fmt.Print(num, " ")
}
}
Создает канал
func main() {
c := make(chan int)
a := []int{8, 6, 7, 5, 3, 0, 9, -1}
go printCount(c)
Запускает горутину
for _, v := range a {
Передает целые
c <- v
числа в канал
}
}
time.Sleep(time.Millisecond * 1)
fmt.Println("End of main")
Функция main приостанавливается
перед завершением
В начале функции main создается небуферизованный канал типа int с именем c,
предназначенный для передачи данных и сигналов между горутинами. При запуске функции printCount в качестве горутины этот канал передается ей в виде
аргумента. Поскольку канал используется как параметр, его тип должен быть
явно указан в сигнатуре printCount. В цикле for внутри функции printCount
переменная num ожидает поступления целочисленного значения из канала c.
Тем временем в функции main перебирается срез целых чисел, каждое из которых по одному передается в канал c. Как только значение поступает в канал
в функции main, оно сразу же принимается переменной num внутри printCount.
Функция printCount продолжает выполнение до тех пор, пока цикл for снова
не дойдет до инструкции приема данных из канала, — в этот момент выполнение
приостанавливается в ожидании нового значения. По завершении перебора
целых чисел функция main продолжает выполняться. После завершения main
программа полностью прекращает выполнение, поэтому перед выходом делается небольшая пауза в одну миллисекунду, чтобы функция printCount успела
38
Глава 1. Начало работы с Go
завершиться до окончания работы main. Результат выполнения этой программы
показан в листинге 1.8.
Листинг 1.8. Вывод значений с использованием канала
8 6 7 5 3 0 9 -1 End of main
Совместное использование каналов и горутин обеспечивает функциональность,
аналогичную той, которая свойственна легковесным потокам или внутренним
сервисам, взаимодействующим через API-интерфейс с заданными типами
данных. Для объединения этих компонентов в цепочки можно использовать
различные техники. Если воспринимать каналы как внешние слушатели, способные выполняться параллельно с остальной частью программы, становится
гораздо проще представить, как можно поддерживать работу конкурентных
и/или асинхронных процессов наряду с линейным кодом.
В книге мы будем многократно возвращаться к горутинам и каналам — двум
мощным средствам реализации конкурентности в Go. Вы увидите, как с их помощью создавать серверы, передавать сообщения и откладывать выполнение
задач. Кроме того, мы рассмотрим паттерны проектирования, задействующие
горутины и каналы, вы также научитесь применять WaitGroup, чтобы управлять
выполнением кода в конкурентном режиме.
1.2.4. Инструментарий Go
Разработка масштабируемых и сопровождаемых приложений сегодня требует
целого набора вспомогательных средств, помимо компилятора. С самого начала
в Go это было предусмотрено, поэтому Go — это не только язык и компилятор.
Исполняемый файл go представляет собой целый инструментарий, который
не только позволяет компилировать код на Go в исполняемый файл, но и включает в себя средства управления пакетами, тестирования, генерации документации
и не только. Давайте рассмотрим некоторые из этих компонентов.
Управление пакетами
Многие современные языки программирования предусматривают средства для
управления пакетами/зависимостями, однако до недавнего времени они практически никогда не включались непосредственно в языковой инструментарий.
В Go это реализовано с помощью go mod (Go-модулей), и у такого решения есть
две причины. Первая заключается в повышении продуктивности разработчика,
а вторая — в ускорении компиляции. Механизм работы с пакетами был спроектирован с учетом особенностей компилятора, и именно это во многом объясняет высокую скорость компиляции. Управление зависимостями реализовано
чрезвычайно эффективно и позволяет избежать сложной обработки заголовков,
свойственной языкам вроде C и C++. Для достижения такой скорости пришлось
пойти на определенные компромиссы, но результат вполне себя оправдывает.
1.2. Важные особенности Go
39
Самый простой способ освоить работу с пакетами — начать с изучения стандартной библиотеки (листинг 1.9). Она построена на основе пакетной системы.
Листинг 1.9. Импорт одного пакета
package main
import "fmt"
Импорт пакета fmt
func main() {
fmt.Println("Hello World!")
}
Использование
функции из fmt
Пакеты импортируются по имени, которое может быть как именем локального
модуля, так и URL-адресом и работает подобно пространству имен. В нашем
примере fmt — это пакет стандартной библиотеки, предназначенный для форматирования. Все содержимое пакета, доступное для использования, вызывается
путем указания имени пакета в качестве префикса, например fmt.Println:
import (
"fmt"
"net/http"
)
Результаты импорта можно группировать с помощью круглых скобок и располагать в алфавитном порядке (в этом помогает утилита go fmt). В приведенном
примере используется пакет net/http, а доступ к его элементам осуществляется
через префикс http.. Механизм импорта работает и с внешними пакетами, которые подключаются аналогичным образом:
import (
"fmt"
"net/http"
)
"golang.org/x/net/html"
Внешний пакет
указан через URL
Имена пакетов представляют собой уникальные строки, которые могут быть
абсолютно произвольными. Согласно принятой конвенции пакеты, не относящиеся к стандартной библиотеке, указываются после тех, которые входят в ее
состав. Большинство IDE с поддержкой go fmt или go imports упорядочивает
эти импорты автоматически. В приведенном примере последняя зависимость
указывается через URL-адрес внешнего пакета, что позволяет Go однозначно
идентифицировать и загрузить соответствующий ресурс:
$ go get ./...
Команда go get принимает путь, например golang.org/x/net/html, чтобы загрузить отдельный пакет. Можно также использовать шаблон ./..., который
обходит кодовую базу и загружает все внешние зависимости. Правда, этот
40
Глава 1. Начало работы с Go
способ применяется реже. В данном случае Go анализирует операторы импорта,
обнаруживает внешние ссылки, загружает соответствующие пакеты и делает их
доступными в текущем рабочем пространстве.
Go также умеет взаимодействовать с системами контроля версий, такими как
Git, при условии, что они установлены в вашей локальной среде. В этом случае
Go получает исходный код из репозитория, извлекает последний коммит из
основной ветки и компилирует его как часть вашей кодовой базы. Это упрощает
подключение приватных репозиториев, но может потребовать дополнительной
настройки при развертывании и контейнеризации (см. главу 12).
Обычно новый проект начинается с запуска команды go mod init [package
name] в корневом каталоге, где package name — имя пакета. Как правило, в качестве имени используется URL-адрес вашего репозитория, например go mod
init github.com/[USERNAME]/[REPO], где USERNAME — имя пользователя, REPO —
репозиторий. Эта команда создает файл go.mod, в котором указывается целевая
версия Go и добавляются сторонние зависимости (с помощью go get). Если вы
вручную измените целевую версию или удалите зависимости, команда go mod
tidy очистит дерево зависимостей и обновит файл go.mod. Для установки Goприложения в /bin или аналогичный системный каталог используется команда
go install [PACKAGE NAME], которая собирает исполняемый файл, доступный для
запуска из командной строки, где PACKAGE NAME — имя пакета.
При разработке библиотеки файлы могут размещаться как в корне проекта, так
и в произвольных подпапках. Однако при создании исполняемого приложения
принято размещать функции main и init в подкаталогах ./cmd, например: ./cmd/
host/main.go. Если в одной папке окажется несколько файлов с функцией main,
инструменты тестирования и сборки Go сообщат об ошибке.
Тестирование
Тестирование — это важный этап разработки программного обеспечения, а для
сторонников такой методологии, как TDD (Test-Driven Development — разработка через тестирование) — и вовсе главный. В Go предусмотрена встроенная
система тестирования, включающая пакет стандартной библиотеки, утилиту
командной строки для запуска тестов, а также средства для генерации отчетов
о покрытии кода и обнаружения состояний гонки. Создание и выполнение тестов
в Go не вызывает особых сложностей (листинг 1.10).
В этом варианте классического приложения "Hello World", меняющем порядок символов пользовательского ввода на противоположный, используется функция reverseName, которую можно протестировать. В соответствии
с принятым в Go соглашением об именовании все файлы с тестами должны
оканчиваться на _test.go. Этот суффикс сообщает Go, что файл должен выполняться во время тестирования и не включаться в итоговую сборку приложения (листинг 1.11).
1.2. Важные особенности Go
Листинг 1.10. Вывод строки "Hello World" в обратном порядке
package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
)
Создает срез байтов
func reverseName(name string) string {
для каждого символа
reversed := make([]byte, 0)
for i := len(name) - 1; i >= 0; i-- {
Обходит строку в обратном порядке
reversed = append(reversed, name[i])
и добавляет символы в срез
}
return string(reversed)
Возвращает результат
}
в виде строки
func main() {
Считывает имя из стандартного
fmt.Print("Enter your name: ")
потока ввода
reader := bufio.NewReader(os.Stdin)
name, err := reader.ReadString('\n')
Создает срез байтов
if err != nil {
для каждого символа
log.Fatal("could not read from stdin", err)
}
Удаляет пробелы в начале
name = strings.TrimSpace(name)
и в конце строки
}
reversed := reverseName(name)
fmt.Println(reversed, ",olleH")
Вызывает функцию reverseName
Выводит результат
Листинг 1.11. Тестирование функции reverseName
package main
import (
"log"
"strings"
"testing"
)
func TestName(t *testing.T) {
name1 := reverseNameFixed(strings.TrimSpace("William"))
expected1 := "mailliW"
if !strings.EqualFold(name1, expected1) {
t.Errorf("Response from reverseName is unexpected
value. got [%s], expected [%s]", name1, expected1)
}
name2 := reverseNameFixed(strings.TrimSpace("Mister
"))
expected2 := "
retsiM"
log.Println(name2, expected2, len(name2), len(expected2))
41
42
}
Глава 1. Начало работы с Go
if !strings.EqualFold(name2, expected2) {
t.Errorf("Response from reverseName is unexpected
value. got [%s], expected [%s]", name2, expected2)
}
Хотя наша функция reverseName на первый взгляд кажется вполне корректной,
в ней допущена ошибка, которую не всегда удается выявить при ручной проверке.
При запуске команды go test выполняется функция, имя которой начинается
с Test. В данном случае запускается функция TestName, и ей передается структура t, предоставляющая вспомогательные средства для тестирования, в том
числе методы фиксации ошибок. Если имя окажется некорректным, появится
сообщение об ошибке. Обратите внимание, что для выполнения проверки вместо обычного оператора == используется strings.EqualFold, что уже намекает
на причину ошибки.
Вывод команды go test содержит список проверенных пакетов и результат тестирования (листинг 1.12). Чтобы протестировать текущий пакет и содержимое
всех вложенных подкаталогов, используется команда go test ./....
Листинг 1.12. Запуск команды go test
$ go test
--- FAIL: TestName (0.00s)
listing1_11_test.go:19: Respone from reverseName is unexpected value.
got [???? retsiM], expected [
retsiM]
Упомянутая ошибка связана с многобайтовым символом, не входящим в набор
ASCII, который было бы корректнее представить в виде руны (rune) — целочисленного значения, соответствующего коду символа Unicode. Если изменить
функцию так, чтобы она работала со срезом рун, проблема исчезнет:
func reverseNameFixed(name string) string {
reversed := make([]rune, 0)
runes := []rune(name)
for i := len(runes) - 1; i >= 0; i-- {
reversed = append(reversed, runes[i])
}
return string(reversed)
}
После этого можно снова запустить тест (листинг 1.13).
Листинг 1.13. Успешное выполнение go test
$ go test
PASS
ok
chapter1. 0.249s
Такое тестирование позволяет выявлять регрессии и распространенные ошибки.
Эти инструменты применяются при разработке самого языка Go. Если вы хотите
1.2. Важные особенности Go
43
использовать более специфический подход, например, на основе поведенческоориентированной разработки (behavior-driven development, BDD), или привычный фреймворк из другого языка, то можете подключить внешние пакеты
для расширения встроенных средств тестирования. Тесты в Go обладают всей
мощью самого языка, а также поддерживают сторонние библиотеки.
Покрытие кода
Помимо выполнения тестов, система тестирования Go позволяет генерировать
отчеты о покрытии кода и анализировать его вплоть до уровня отдельных операторов, как показано в листинге 1.14. Чтобы оценить покрытие кода тестами,
выполните следующую команду:
$ go test -cover
Добавление флага -cover к команде go test позволяет отобразить сведения
о покрытии кода наряду с другой информацией о тестах.
Листинг 1.14. Тестирование с выводом отчета о покрытии кода
$ go test –cover
PASS
Coverage: 33.3% of statements
ok
go-in-practice/chapter1/hello
0.011s
Полученную информацию о покрытии кода можно экспортировать в файлы,
которые затем анализируются другими инструментами, а также отображать
отчеты встроенными средствами Go. На рис. 1.4 приведен отчет в браузере,
указывающий на то, какие инструкции были выполнены во время тестирования.
Рис. 1.4. Отчет о покрытии кода в браузере
44
Глава 1. Начало работы с Go
Часто отчеты о покрытии кода содержат подробную информацию вплоть до
уровня отдельных строк. Большинство современных IDE поддерживает инструментарий Go и в некоторых случаях позволяет более гибко управлять запуском
тестов и форматированием результатов прямо в среде разработки. Следует
учитывать, что в Go несколько операторов могут располагаться на одной строке
(как, например, в конструкции if/else): отчет о покрытии покажет, какие из
этих операторов были выполнены, а какие остались без проверки.
СОВЕТ Дополнительную информацию об инструменте cover можно найти по адресу
https://go.dev/blog/cover.
Тестирование — это одна из мощнейших функций Go и достаточно гибкая,
чтобы вы могли реализовать собственный подход. Обратите внимание, что
в листинге 1.12 в одной тестовой функции проверяется сразу несколько случаев.
В главе 6 мы рассмотрим более эффективные способы, в том числе табличный
подход к тестам.
Форматирование
Вопросы стиля и форматирования кода (например: «Что использовать для создания отступов — табуляцию или пробелы?») являются предметом регулярных
обсуждений и споров. Сколько времени можно было бы сэкономить, если бы
таких споров не возникало вовсе? К счастью, в случае с Go нам не нужно тратить время на обсуждение стилей форматирования и прочих стилистических
мелочей.
Руководство Effective Go, доступное по адресу https://go.dev/doc/effective_go, учит
тому, как писать идиоматический код на языке Go. В нем изложены соглашения
и практики, используемые сообществом Go-разработчиков. Следование им
упрощает чтение, сопровождение и совместную работу над Go-кодом.
В Go встроен инструмент для автоматического приведения кода к стилю, принятому в сообществе. Команда go fmt, запущенная из корневого каталога пакета,
форматирует все .go-файлы, переписывая их в каноничном стиле. К команде
go fmt можно добавить путь к конкретному пакету или шаблон ./..., чтобы
выполнить обход всех подкаталогов. Большинство современных редакторов,
таких как Visual Studio Code, GoLand от JetBrains и Sublime Text, поддерживает
команду go fmt либо по умолчанию, либо с помощью расширений.
Помимо go fmt, имеет смысл опробовать и команду goimports, которая не только форматирует код, но и автоматически добавляет недостающие пакеты или
удаляет неиспользуемые, предотвращая тем самым ошибки сборки. Хотя Go
предоставляет оба эти инструмента для тонкой настройки форматирования кода,
в большинстве случаев все связанные с этим задачи позволяет решить команда
goimports. Чтобы ею воспользоваться, установите goimports с помощью команды go get imports или подключите официальное расширение для вашей IDE.
1.3. Место Go среди других языков программирования
45
1.3. Место Go среди других языков
программирования
На популярной платформе GitHub размещены проекты, написанные на сотнях
языков программирования. Согласно индексу TIOBE, оценивающему их частоту
использования, доля самых популярных языков на рынке постепенно сокращается. В условиях такого многообразия полезно понимать, какое место занимает Go.
Изначально Go разрабатывался как язык системного программирования. И хотя
он до сих пор остается отличным выбором для решения подобных задач, его
применение распространилось на множество других областей. Тем не менее
в своей основе Go остается прежде всего языком для создания сервисов и системных процессов.
Узкая направленность на системы означает и определенные ограничения.
Например, хотя Go может использоваться там, где раньше применялись C или
C++, он не является оптимальным решением для встраиваемых систем. Главным
образом это связано с управлением памятью. Существуют проекты вроде TinyGo
(https://tinygo.org), призванные уменьшить объем занимаемого пространства, однако среда выполнения Go и его сборщик мусора не слишком хорошо работают
в условиях ограниченных ресурсов. Хотя эти механизмы можно до некоторой
степени настраивать, Go вряд ли стоит рассматривать как отправную точку при
разработке встраиваемых систем.
Сравнение Go с другими популярными языками помогает понять его место среди
них. Хотя Go действительно хорошо подходит для решения определенных задач,
мы не стремимся доказать его превосходство. Выбор подходящего инструмента
требует учета многих факторов, помимо характеристик самого языка.
1.3.1. C и Go
Go был задуман как альтернатива языкам C, C++ и Java, используемым для
разработки внутренних приложений. Поскольку источником вдохновения для
создателей Go служил их опыт программирования на C (а сам C по-прежнему
остается одним из самых популярных языков), имеет смысл обсудить сходства
и различия между этими языками.
Оба языка компилируются в машинный код, предназначенный для конкретной
операционной системы и архитектуры. Однако Go выходит далеко за рамки
возможностей C и практически всегда обеспечивает более быструю сборку,
особенно если речь идет о нетривиальных проектах.
Язык Go предоставляет среду выполнения с такими возможностями, как
управление потоками и сборка мусора. При написании Go-приложений вы
не управляете потоками напрямую и учитываете перерывы на сборку мусора,
как в случае с другими языками с автоматическим управлением памятью.
В языке C ответственность за управление потоками и памятью возлагается на
46
Глава 1. Начало работы с Go
разработчика, что нередко приводит к утечкам памяти, переполнению буфера
и состоянию гонки. Все потоки и связанная с ними логика в C обрабатываются
вручную, а управление памятью осуществляется без использования сборщика
мусора. Хотя новые стандарты C предусматривают средства реализации конкурентности, в отличие от Go, этот язык не имеет унифицированного механизма
для работы с потоками на разных платформах.
C + Go = cgo
Язык Go поддерживает привязку C-библиотек к Go-программам. Он предусматривает
набор инструментов для обеспечения совместимости с C, например для преобразования строк, написанных в стиле C, в Go-строки. Кроме того, инструменты Go
позволяют собирать проекты, где код на C и Go используется совместно. Более того,
Go поддерживает обертки, создаваемые с помощью SWIG (Simplified Wrapper and
Interface Generator — упрощенный упаковщик и генератор интерфейсов). Описание
всех этих возможностей можно получить, выполнив команды go help c и go doc cgo.
Подробнее о них мы поговорим в главе 13.
Язык C и его объектно-ориентированные производные, такие как C++, позволяют
создавать широкий спектр приложений: от встраиваемых систем с высокими
требованиями к производительности до облачных сервисов и сложных настольных программ. Go, в свою очередь, хорошо подходит для создания системных
компонентов, серверов и облачных платформ. Go обеспечивают высокую продуктивность разработчика без необходимости вручную управлять памятью.
Среда выполнения и набор инструментов Go предоставляют разработчику
множество средств, позволяющих писать приложения быстрее и с меньшими
трудозатратами, чем при на C. Так, программа на Go, которая задействует все
четыре ядра сервера, может распределить нагрузку с помощью горутин. В версии
на C пришлось бы создавать потоки и самостоятельно управлять распределением
задач между ними, помимо основной логики приложения. В стандарте C++20
появились корутины, принципом работы напоминающие горутины, но менее
интуитивно понятные из-за различий в подходах, которые выражаются, например, в использовании ключевого слова yield вместо привычных конструкций
async/await или событийно-ориентированных систем.
Компиляция приложений, написанных на C, может занимать довольно много
времени, особенно при наличии внешних зависимостей. В Go высокая скорость
компиляции с самого начала была приоритетом, поэтому на практике программы
на Go собираются значительно быстрее аналогов на C. Когда масштаб проекта
достигает того уровня, что компиляция занимает минуты или даже часы, экономия времени на сборке становится критически важным условием обеспечения
должной продуктивности. Компиляция большинства приложений на Go достаточно быстра, чтобы даже крупные проекты с зависимостями собирались за
считаные секунды.
1.3. Место Go среди других языков программирования
47
1.3.2. Rust, Zig, Nim и Go
Язык Go стал катализатором появления новых современных языков, претендующих на звание альтернативы для C. Первым и наиболее заметным из них
является Rust.
Если Go был разработан в компании Google в 2009 году, то Rust был создан компанией Mozilla в 2015 году в рамках инициативы по модернизации компонентов
браузера Firefox. Хотя цели Rust и Go в значительной степени пересекаются, Rust
изначально был ориентирован на разработку ПО для операционных и встраиваемых систем, то есть на те области, для которых Go не предназначен. Доказательством этого является интеграция кода на Rust в цепочку инструментов ядра Linux.
Как и Go, Rust использует синтаксис в стиле C, но предусматривает уникальный
механизм проверки заимствований (borrow checker), обеспечивающий более
сложное, но и более точное управление временем жизни переменных и данных.
Эти языки имеют сопоставимую производительность, однако Rust превосходит
Go в ситуациях, где критически важен строгий контроль за памятью. Разработчику на Go не приходится заботиться о владении и неизменяемости данных.
Язык Zig, появившийся спустя год после Rust, ориентирован на ту же нишу, но
отличается более привычным стилем написания кода для разработчиков на C
и C++. Он также придерживается С-подобного подхода к управлению памятью
и не имеет стандартного сборщика мусора. Вместо него используется система
умных указателей, обеспечивающая безопасность работы с памятью.
Еще одним претендентом на звание альтернативы для C является язык Nim,
в котором в качестве разделителей блоков применяются отступы (как в Python).
Подобно Zig, Rust и Go, язык Nim нацелен на кросс-компиляцию и тесную интеграцию с C. Все три языка также поддерживают компиляцию в WebAssembly,
что позволяет использовать их для создания высокопроизводительных вебинструментов.
Все три языка привлекательны по тем же причинам, что и Go, однако Go является
наиболее зрелым и предлагает удачное сочетание преимуществ и компромиссов,
благодаря которому начать работу с ним можно практически без затруднений.
Zig и Nim — более молодые языки, находящиеся в стадии активного развития,
и потому их использование сопряжено с повышенным риском возникновения
проблем обратной совместимости. В то же время команда Go целенаправленно
добивается того, чтобы изменения в языке не ломали существующий код.
1.3.3. Java и Go
Java — это один из самых популярных и широко используемых языков программирования в мире. Он применяется для реализации самых разных проектов: от
серверных и мобильных приложений (предназначенных в том числе для ОС
Android) до кросс-платформенных настольных программ. Go изначально создавался как язык системного программирования. Несмотря на то что со временем
48
Глава 1. Начало работы с Go
он нашел применение в веб- и мобильной разработке, он все еще не подходит для
создания настольных приложений, а раскрывает свой потенциал при решении
именно тех задач, для которых изначально предназначался.
Возникает вопрос: если язык Java столь универсален и популярен, зачем использовать Go? Несмотря на сходство базового синтаксиса, Java и Go сильно
различаются. Go компилирует приложение в один исполняемый двоичный файл
для конкретной операционной системы. В этот файл включены среда выполнения
Go, все подключенные пакеты и само приложение — то есть все, что нужно для
работы программы. В Java применяется другой подход, предполагающий установку в ОС специальной среды выполнения. Java-приложения упаковываются
в отдельные файлы, которые можно запускать на любой системе, если в ней есть
совместимая версия этой среды.
Такие различия (показанные на рис. 1.5) имеют практическое значение. Развертывание Go-приложения, как правило, сводится к размещению на сервере
одного исполняемого файла.
Рис. 1.5. Работа приложений на Java и Go в операционной системе
В случае с Java необходимо не только развернуть само приложение, но и установить (а затем и поддерживать) среду выполнения на сервере.
Другое важное различие между Java и Go связано со способом выполнения
приложений. Программы на Go компилируются в системный исполняемый
файл и запускаются операционной системой. Приложения на Java выполняются на виртуальной машине (ВМ), которая обычно содержит JIT-компилятор
(just-in-time — компиляция «точно в нужное время»). JIT-компилятор способен анализировать выполнение кода с учетом контекста и оптимизировать
его на лету.
1.3. Место Go среди других языков программирования
49
Отсюда возникает закономерный вопрос: может ли код, выполняемый на ВМ
с JIT-компилятором, работать быстрее скомпилированного исполняемого
файла? Ответить на него не так просто, поскольку все зависит от конкретного
JIT-компилятора, особенностей исполняемого кода и других факторов. В тестах,
сравнивающих реализации схожего функционала, явного победителя, как правило, не бывает. Иногда код на Java может выполняться на виртуальной машине
быстрее по сравнению с исполняемым Go-файлом (а иногда наоборот).
Стоит также отметить, что в Go предусмотрены настройки для управления
сборкой мусора, о которых мы поговорим в главе 13. В большинстве случаев вам
не придется задумываться о том, как работает сборщик мусора в Go, поскольку
он практически не влияет на производительность во время выполнения. Тем
не менее в некоторых ситуациях вам может потребоваться ручная оптимизация,
вплоть до полного отключения сборки мусора путем установки переменной окружения в значение GOGC="off". Разумеется, это требует осторожности — прежде
чем экспериментировать с этой настройкой, хорошо подумайте, действительно ли
вам это необходимо.
1.3.4. Python, PHP, JavaScript и Go
Сравнение Go с такими языками, как PHP, Python и JavaScript (включая его
надстройку TypeScript), может показаться не вполне уместным. Однако по
мере знакомства с Go вы заметите, что он подходит и для написания небольших
сценариев в тех случаях, где вы обычно использовали бы Python. Команда go
run работает настолько быстро, что компиляция не мешает оперативно менять
и тестировать код.
Python, JavaScript и PHP — это языки с динамической типизацией, тогда как
Go — статически типизированный язык, обладающий рядом динамических
черт. В динамически типизированных языках проверка типов происходит во
время выполнения, а преобразования типов зачастую осуществляются на лету.
В статически типизированных языках, таких как Go, типы проверяются на этапе
анализа кода. При этом Go в ряде случаев позволяет выполнять преобразование
типов без явного приведения. Это может показаться необычным для языка со
статической типизацией, но на практике упрощает код, делает его лаконичнее и позволяет избежать избыточного преобразования типов переменных.
В TypeScript динамическая типизация JavaScript заменяется более строгой
структурой и системой проверки типов.
У Go есть встроенный веб-сервер (рис. 1.6). Программы вроде браузеров подключаются напрямую к приложению на Go, оно само управляет этими соединениями. Это обеспечивает более низкоуровневый контроль и позволяет теснее
взаимодействовать с подключающимися клиентами. Встроенный веб-сервер
Go обрабатывает соединения в конкурентном режиме, эффективно используя
особенности языка. В большинстве рабочих сценариев приложения на JavaScript,
Python или PHP работают через выделенный прокси-сервер.
50
Глава 1. Начало работы с Go
Хотя для всех трех языков существуют сторонние решения, одна из причин для
развертывания приложений на Python, JavaScript и PHP позади веб-сервера
связана с тем, как эти языки обрабатывают потоки и реализуют модель конкурентности. Встроенный веб-сервер Go использует горутины для обработки
соединений в конкурентном режиме. Среда выполнения Go сама распределяет
горутины по потокам приложения (как будет показано в главе 3). В отличие
от Python и PHP, где для каждого соединения создается отдельный процесс,
Go позволяет обрабатывать соединения в общем окружении и при необходимости делить ресурсы между ними.
Рис. 1.6. Пути обработки клиентского запроса в приложениях на Python, PHP и Go
Go и JavaScript (в основном за счет Node.js с поддержкой TypeScript) решают
схожие задачи, но делают это по-разному. Сравнение этих подходов позволяет
лучше понять, какое место занимает Go в мире языков программирования.
По мере роста популярности одностраничных приложений все большую поддержку получала идея использования одного и того же языка для создания
клиентской и серверной частей. Благодаря Node.js JavaScript стал полноценным
решением для серверной разработки.
В JavaScript используется однопоточная модель. Хотя асинхронные операции
ввода/вывода могут выполняться в отдельных потоках, основной код работает
в одном. Поэтому, если выполнение какого-либо фрагмента занимает много
времени, это блокирует запуск остального кода. Для внутреннего переключения
контекста и реализации конкурентности в JavaScript применяется модель событийного цикла и конструкции async/await. В Go же используется многопоточная модель, в рамках которой за управление горутинами, выполняющимися
в разных потоках, отвечает среда выполнения. Такая архитектура позволяет Go
задействовать больше аппаратных ресурсов, особенно в многопроцессорных
системах, по сравнению с JavaScript.
1.4. Первые шаги в работе с Go
51
Как уже упоминалось, Go имеет встроенную систему управления пакетами.
Языки вроде Python, PHP и JavaScript изначально проектировались без нее,
а решения наподобие pip, composer и npm появились позднее. Будучи лишь надстройками, эти системы оказались далеки от идеала — их устройство и централизованное управление со временем стали причиной возникновения серьезных
проблем с безопасностью.
1.4. Первые шаги в работе с Go
Существует несколько способов начать работу с Go — выбор зависит от того,
насколько серьезно вы настроены. Самый легкий способ познакомиться с языком — пройти интерактивный тур по Go (https://go.dev/tour/welcome/1), в котором
представлены ключевые особенности языка. Его главное отличие от типичных
учебников в том, что все примеры можно запускать прямо в браузере. Более того,
вы можете вносить изменения в код и сразу же проверять результат.
Для запуска простых Go-приложений предназначена среда Go Playground
(https://go.dev/play), которая лежит в основе вышеупомянутых интерактивных
примеров. Здесь можно тестировать код и делиться ссылками на него. Примеры
из книги, представляющие собой полноценные программы, тоже можно запускать
в среде Playground. Для более глубокого знакомства с основами языка отлично
подойдет ресурс Go by Example (https://gobyexample.com/).
1.4.1. Установка Go
Процесс установки Go достаточно прост. В ОС Windows и macOS он ничем
не отличается от установки обычного приложения. Пользователям утилиты
Homebrew в macOS достаточно выполнить команду brew install go.
Пользователям Linux доступно больше вариантов. Вы можете установить Go
через встроенные менеджеры пакетов, такие как apt-get или yum, но они, как
правило, содержат устаревшие версии. Чтобы получить свежую сборку с новыми
возможностями и улучшенной производительностью, следуйте официальным
инструкциям: скачайте дистрибутив с сайта, распакуйте его и добавьте исполняемые файлы Go в переменную окружения PATH.
Подробное руководство по установке с перечнем поддерживаемых операционных
систем и архитектур доступно на странице https://go.dev/doc/install.
1.4.2. Работа с Git и системами контроля версий
Для использования внешних пакетов, хранящихся в системах контроля версий,
необходимо установить эти системы локально. Go не реализует собственные
средства управления конфигурациями, но умеет использовать инструменты,
установленные в системе.
52
Глава 1. Начало работы с Go
Двумя наиболее распространенными системами контроля версий являются Git
и Mercurial (hg). Git используется особенно широко, в том числе разработчиками
Google, публикующими свои пакеты на GitHub. Для работы достаточно установить любую современную версию системы Git, так как язык Go не предъявляет
к ней жестких требований.
1.4.3. Изучение рабочего пространства
Изоляция модулей в Go позволяет создать корень приложения где угодно. Благодаря этому приложение является полностью самодостаточным, а зависимости
не конфликтуют с другими проектами.
Единственная переменная окружения, которую вам, возможно, придется настроить, — это $GOPATH. Она указывает на базовый каталог рабочего пространства.
Исходный код, как ваш собственный, так и код зависимостей, может размещаться
в корне проекта (в случае библиотек) или в подкаталогах, например /src. Хотя
в некоторой степени этой структурой вам придется управлять вручную, инструменты Go помогут в работе с внешними репозиториями исходного кода. Как уже
упоминалось, исполняемые файлы проекта обычно располагаются в подкаталоге
cmd на верхнем уровне. При наличии нескольких исполняемых компонентов их
принято размещать по отдельным подпапкам, например ./cmd/backend/main.go
и ./cmd/frontend/main.go. Однако это всего лишь общепринятая практика, так
что при желании вы можете использовать и другую структуру.
1.4.4. Переменные окружения
Наиболее полезными переменными окружения в Go являются GOOS и GOARCH,
задающие целевую операционную систему и архитектуру центрального процессора соответственно. Они особенно удобны при сборке исполняемых файлов
для платформы, отличной от вашей. В главе 12 вы узнаете, как использовать их
при выполнении сборки в Docker-контейнерах.
СОВЕТ Полный список переменных окружения, специфичных для Go, доступен по
адресу https://mng.bz/mGyP.
1.4.5. Использование ИИ-инструментов вместе с Go
Хотя искусственный интеллект (ИИ) напрямую не связан с языком Go, стремительное развитие ИИ-инструментов, включая чат-боты и большие языковые
модели (Large Language Model, LLM), привело к почти экспоненциальному росту
производительности. Особенно это заметно в области разработки ПО. Теперь
программисты активно применяют инструменты на базе ИИ прямо в своих IDE
для получения подсказок и помощи при генерации кода.
1.5. Привет, Go
53
В книге мы периодически будем обсуждать способы использования ИИ для
улучшения опыта разработки на Go. Хотя Go технически способен справляться
с задачами машинного обучения, мы не будем сосредоточиваться на данном
направлении. Вместо этого мы покажем, как с помощью промт-инжиниринга
(prompt engineering) и ИИ-инструментов можно создавать тесты, генерировать
тестовые данные и моделировать поведение пользователей.
1.5. Привет, Go
В листинге 1.15 показана простая программа, которая выводит фразу Hello,
my name is Inigo Montoya через веб-сервер. Для начала перейдите в каталог, где
вы хотите разместить приложение, и выполните команду go mod init hellogo,
чтобы инициализировать новое приложение. Имя hellogo выбрано произвольно.
Вы можете выбрать другое, но оно должно быть уникальным, особенно если
вы собираетесь впоследствии опубликовать свой проект. Обычно во избежание конфликтов в качестве имени модуля указывают уникальный URL-адрес,
например github.com/[USERNAME]/[REPO], где USERNAME — имя пользователя,
REPO — репозиторий. После инициализации в каталоге появится файл go.mod,
содержащий информацию о модуле и при необходимости список зависимостей.
Теперь, когда проект инициализирован, создайте в этой же папке файл main.go
со следующим содержимым.
СОВЕТ Если вы хотите использовать более традиционную структуру проекта, переместите файл main.go в подкаталог cmd/example/, а на верхнем уровне создайте файл
hello.go с функцией Hello(). Обратите внимание, что в данном случае имя функции
пишется с заглавной буквы, что делает ее видимой за пределами пакета.
Листинг 1.15. Веб-сервер Hello World
package main
import (
"fmt"
"net/http"
)
Пакет main используется
для приложений
Импорт необходимых
пакетов
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello, my name is Inigo Montoya")
}
func main() {
http.HandleFunc("/", hello)
http.ListenAndServe("localhost:4000", nil)
}
Обработчик
HTTP-запроса
Выполнение
приложения
Это простое приложение состоит из трех частей. Оно начинается с объявления
пакета. Если библиотекам обычно принято давать короткие имена, отражающие
54
Глава 1. Начало работы с Go
их назначение (например, net или crypto), то для приложений используется
пакет main. Чтобы работать со строками и выполнять роль веб-сервера, мы
подключаем пакеты fmt и http. Импорт делает эти пакеты доступными как в исходном коде, так и в скомпилированном приложении.
Выполнение программы начинается с функции main, которая не принимает
аргументов и ничего не возвращает. Сразу после ее объявления вызывается
функция http.HandleFunc, обязывающая веб-сервер выполнить функцию hello
при обращении к пути /. Функция hello реализует интерфейс обработчика
HTTP-запросов, принимая два объекта — запрос и ответ. После этого вызывается функция http.ListenAndServe, которая запускает веб-сервер и заставляет
его слушать порт 4000 домена localhost.
Это приложение можно запустить двумя способами. В листинге 1.16 показана
команда go run, которая компилирует код во временную директорию и сразу же
его выполняет.
Листинг 1.16. Запуск inigo.go
$ go run inigo.go
Временный файл автоматически удаляется после завершения работы приложения. Это удобно при разработке новых и часто тестируемых версий.
СОВЕТ Если вы хотите автоматически отформатировать файл (или несколько
файлов), выполните команду go fmt или goimports в текущем каталоге. Код можно
переместить в файл ./cmd/inigo/main.go, но, поскольку проект состоит из одного
файла, проще оставить все как есть.
После запуска приложения откройте браузер и перейдите по адресу http://
localhost:4000, чтобы увидеть результат, как на рис. 1.7.
Рис. 1.7. Вывод фразы Hello, my name is Inigo Montoya в браузере
Резюме
55
Альтернативный способ предполагает компиляцию и запуск программы вручную,
как показано в листинге 1.17.
Листинг 1.17. Сборка inigo.go
$ go build inigo.go
$ ./inigo
Здесь первым делом выполняется сборка приложения. Если не указать имя
файла, команда go build соберет весь текущий каталог. Если указать конкретный
файл (или несколько) либо воспользоваться шаблоном с символами подстановки,
будет собрано только выбранное содержимое. Затем полученное приложение
необходимо запустить. По традиции скомпилированные исполняемые файлы
размещаются в каталоге bin, но вы можете указать любой другой путь. Для этого
используется флаг -o, например: go build -o bin/inigo inigo.go.
Резюме
Философия Go, сочетающая простоту и расширяемость, позволила сформировать вокруг этого практичного языка развитую экосистему.
Go предусматривает механизмы, эффективно задействующие современные
аппаратные ресурсы, в частности горутины, обеспечивающие конкурентное
выполнение кода.
Инструментарий Go, включающий в себя средства для тестирования, управления пакетами, форматирования и генерации документации, позволяет
создавать тщательно проверенные и безопасные приложения.
Go — это мощный, быстрый и универсальный язык системного программирования, сопоставимый с такими языками, как C, Java, JavaScript, Python и PHP.
2
Создание консольного
приложения
В этой главе
3 Работа с флагами, параметрами и аргументами командной строки.
3 Передача конфигурации в приложение.
3 Запуск и корректное завершение работы веб-сервера.
3 Маршрутизация запросов к веб- и API-серверам.
В этой главе мы рассмотрим несколько ключевых аспектов разработки консольных, или CLI-приложений (command-line interface — интерфейс командной
строки). Вы узнаете, как обрабатывать параметры командной строки, также
называемые флагами, в стиле современных приложений для Linux и других
POSIX-совместимых систем (Portable Operating System Interface — переносимый
интерфейс операционных систем).
Затем мы разберем несколько способов передачи конфигурации в приложение,
в том числе через переменные окружения и популярные форматы конфигурационных файлов. Кроме того, вы познакомитесь со структурами и интерфейсами
и увидите, как их можно связать для поддержания состояния конфигурации.
В этом контексте мы также коснемся перечислений (enum) и их ограничений.
Помимо всего прочего, вы научитесь собирать простой сервер с помощью командной строки и познакомитесь с лучшими практиками его запуска и корректной остановки. В финальной части главы вы освоите техники сопоставления
URL-путей, используемые в веб-приложениях и серверах, а также научитесь
2.1. Создание CLI-приложений на языке Go
57
реализовывать REST API (Representational State Transfer — передача состояния
представления) для расширения функциональных возможностей своего сервера.
2.1. Создание CLI-приложений на языке Go
Аргументы и флаги командной строки являются неотъемлемой частью пользовательского интерфейса многих приложений, включая консольные утилиты,
системные команды, системы контроля версий, такие как Git, и базы данных
вроде MySQL. Стандартная библиотека Go предусматривает встроенные средства для работы с этими аргументами и флагами, позволяя легко и элегантно
обрабатывать даже самые сложные случаи.
Приложения с графическим интерфейсом
Будучи языком системного программирования, Go не поддерживает создание
приложений с графическим интерфейсом (Graphical User Interface, GUI), подобных
тем, что используются в ОС Microsoft Windows или macOS. Существуют сторонние
библиотеки и фреймворки, позволяющие создавать кросс-платформенные GUIприложения, однако в стандартную библиотеку Go они не входят. Помимо Qt и GTK,
вы также можете попробовать Wails — систему, во многом напоминающую популярный кросс-платформенный JavaScript-фреймворк Electron. Wails позволяет создавать
полноценные настольные приложения, используя Go для разработки серверной
части, и веб-технологии (HTML, CSS и JavaScript) для создания интерфейса. Более
подробную информацию можно найти по адресу https://github.com/wailsapp/wails.
2.1.1. Флаги командной строки
Работа с аргументами и флагами командной строки в стандартной библиотеке
Go восходит к системе Plan 9, где используется иной стиль, нежели в системах
на базе GNU/Linux и BSD (включая macOS и FreeBSD). Например, в Linux
и BSD можно выполнить команду ls -la, чтобы отобразить список всех файлов
в каталоге. Здесь ls — это сама команда, а -la — часть с двумя флагами (или
параметрами): флаг -l обеспечивает вывод файловой информации в длинном
подробном формате, а флаг -a (от англ. all — «все») добавляет в список скрытые
файлы. В Go нельзя объединять флаги в одну запись. Поскольку Go обрабатывает короткие (-la) и длинные (--la) флаги одинаково, в приведенном выше
примере он будет воспринимать -la как один флаг с именем la.
Команды в стиле GNU, такие как ls, поддерживают длинные параметры (например, --color). Два дефиса сообщают программе о том, что --color — это
не пять отдельных флагов, а один. Стоит придерживаться такого стиля или
нет — вопрос предпочтений.
Встроенная в Go система флагов не различает короткие и длинные варианты:
каждый флаг — это отдельная сущность, и их нельзя объединять. Например, при
58
Глава 2. Создание консольного приложения
выполнении команды go help build вы увидите флаги -v, -race, -x, -work. Для
каждой опции используется один флаг без разделения на короткую и длинную
форму. Чтобы получить представление о поведении флагов по умолчанию, рассмотрим простое консольное приложение, использующее пакет flag (листинг 2.1).
Листинг 2.1. CLI-приложение Hello World, использующее пакет flag
$ flag_cli
Hello World!
$ flag_cli -name Buttercup
Hello Buttercup!
$ flag_cli -s -name Buttercup
Hola Buttercup!
$ flag_cli --spanish -name Buttercup
Hola Buttercup!
Каждый флаг здесь указывается отдельно и начинается с символов - или --,
которые Go считаются равнозначными. Существует метод, позволяющий задать
для одного параметра короткую и длинную форму имени, но это необходимо
сделать явно, как показано в листинге 2.2.
Листинг 2.2. П
риложение Hello World, использующее флаги
командной строки
package main
import (
"flag"
"fmt"
)
Импортирует
стандартный
пакет flag
Новая переменная
для хранения
значения флага
Создает новую переменную
на основе флага
var name = flag.String("name", "World", "A name to say hello to.")
var inSpanish bool
func init() {
flag.BoolVar(&inSpanish, "spanish", false, "Use Spanish language.")
flag.BoolVar(&inSpanish, "s", false, "Use Spanish language.")
flag.Parse()
Парсирует флаги, помещая
Присваивает
}
значения в переменные
переменной
значение флага
func main() {
}
if inSpanish {
fmt.Printf("Hola %s!\n", *name)
} else {
fmt.Printf("Hello %s!\n", *name)
}
Обращается к переменной
name как к указателю
В этом примере показаны два способа определения флага. Первый — создать
строковую переменную со значением по умолчанию "World" с помощью функции
flag.String(), которая принимает в качестве аргументов имя флага, значение
2.1. Создание CLI-приложений на языке Go
59
по умолчанию и описание. Переменная name в нашем случае хранит адрес, указывающий на значение флага. Чтобы получить доступ к этому значению, нужно
обратиться к name как к указателю (подобно тому как это делается в языках
семейства C).
Второй способ предполагает явное определение как длинного, так и короткого варианта имени. Сначала создается обычная переменная того же типа,
что и флаг. Эта переменная будет использоваться для хранения значения
флага. Затем выполняется одна из функций пакета flag, которая помещает
значение флага в уже существующую переменную. В данном примере функция flag.BoolVar вызывается дважды: один раз с длинным именем флага,
а другой — с коротким.
СОВЕТ Для каждого базового типа данных предусмотрены свои функции работы
с флагами, включая bool, string, int (а также их беззнаковые разновидности и варианты с большей разрядностью), byte, float32, float64. Все они подчиняются схожему,
предсказуемому принципу. Например, функции flag.StringVar и flag.IntVar принимают аргумент и присваивают его переменной соответствующего типа. Подробнее
об этом можно узнать в документации по пакету flag: https://pkg.go.dev/flag.
Наконец, чтобы присвоить значения флагов переменным, вызовите функцию
flag.Parse(). Пакет flag умеет завершать выполнение программы с выводом
стандартного справочного сообщения, но только если ему переданы некорректные или неизвестные флаги (листинг 2.3). Допустим, мы передали одновременно
--spanish и -s. Поскольку оба флага имеют логический тип, их включение без
явного указания значения будет интерпретироваться как true. Если передать
--spanish=true и -s=false, то результатом окажется false, потому что функция
flag.Parse() обрабатывает флаги в том порядке, в котором они были объявлены.
Это важно учитывать, так как одна версия флага может переопределить другую.
Мы рассмотрим альтернативные подходы в разделе 2.1.4.
Листинг 2.3. Вызов встроенных справочных сообщений
$ flag_cli --invalidFlag
Через командную строку передается некорректный флаг
Usage of ./flag_cli:
В этом случае выводится справочное сообщение
-name string
A name to say hello to. (default "World")
-s
Use Spanish language.
-spanish
Use Spanish language.
При передаче некорректного флага программа выводит справочное сообщение и завершает выполнение. То же самое произойдет, если попробовать
передать значение несовместимого типа, например указать число в качестве
аргумента -name. Go не будет пытаться привести значение в ожидаемый тип:
если программа ожидает int, а получает float, она завершится с ошибкой.
60
Глава 2. Создание консольного приложения
Исключением является тип time.Duration, к которому может быть автоматически приведено целое число.
СОВЕТ При выполнении математических операций с time.Duration иногда требуется
вручную привести целое число к этому типу, обернув его в time.Duration, как, например, в выражении weekAgo := time.Now().Add(time.Duration(7 * 24) * time.Hour).
Пакет flag предоставляет две полезные функции, позволяющие контролировать вывод справочной информации. Функция PrintDefaults автоматически
формирует описание флагов. Например, строка справки для упомянутого выше
флага name будет выглядеть так:
-name string
A name to say hello to. (default "World")
Эта удобная функция Go помогает информировать пользователя о том, как
работает программа и как с ней взаимодействовать.
Кроме того, у флагов есть метод VisitAll, принимающий в качестве аргумента
функцию обратного вызова, которую он последовательно применяет ко всем
флагам, что позволяет задать для них пользовательское описание. Например, в листинге 2.4 отображается строка -name: A name to say hello to. (Default: 'World').
Листинг 2.4. Создание пользовательского описания для флага
flag.VisitAll(func(flag *flag.Flag) {
format := "\t-%s: %s (Default: '%s')\n"
fmt.Printf(format, flag.Name, flag.Usage, flag.DefValue)
})
Вы также можете получить список всех переданных аргументов с помощью
flag.Args(). Данная функция возвращает срез строк, в котором перечислены
все аргументы, кроме тех, что начинаются с - или --. Эти аргументы не парсятся
и не проверяются функцией flag.Parse().
2.1.2. Определение допустимых значений
с помощью перечислений
Давайте сделаем небольшое отступление и расширим наш пример, добавив
в него поддержку нескольких языков, чтобы продемонстрировать одну из наименее эффективных возможностей Go и описать ситуации, в которых она может
пригодиться.
Предположим, вы хотите, чтобы ваше приложение поддерживало английский,
испанский, немецкий и французский языки. Во многих строго типизированных
языках для этой цели удобно использовать перечисление (enum), как показано
в листинге 2.5.
2.1. Создание CLI-приложений на языке Go
61
Листинг 2.5. О
пределение допустимых параметров с помощью
перечисления
package main
import (
"flag"
"log"
)
var name = flag.String("name", "World", "A name to say hello to.")
type language = string
Определяет псевдоним типа для строки
var userLanguage language
Переменная, в которую будет записано значение
Перечисление констант
const (
English language = "en"
Spanish
= "sp"
French
= "fr"
German
= "de"
)
func init() {
flag.StringVar(&userLanguage, "lang", "en",
"language to use (en, sp, fr, de).")
flag.Parse()
}
Присваивает переменной
значение из флага
Использует оператор switch для переключения
func main() {
между допустимыми значениями и выводом
switch(userLanguage) {
результата в зависимости от языка
case English:
log.Printf("Hello %s!\n", *name)
case Spanish:
log.Printf("Hola %s!\n", *name)
case French:
log.Printf("Bonjour %s!\n", *name)
case German:
log.Printf("Hallo %s!\n", *name)
}
}
Сначала мы объявляем перечисление языков. В Go нет ключевого слова enum,
что, вероятно, к лучшему, поскольку перечисления не всегда работают предсказуемо. Если вы запустите этот код, передав один из допустимых языков, то
получите ожидаемый результат.
Однако, если вы передадите --lang="xy", программа ничего не выведет, потому
что в данном случае вы просто присвоите входное значение псевдониму строкового типа. Этот пример иллюстрирует одно из слабых мест Go по сравнению
с другими языками. По сути, перечисления в Go можно рассматривать как ассоциативные массивы, сопоставляющие константы со значениями, и в данной
62
Глава 2. Создание консольного приложения
ситуации они почти бесполезны. Разумеется, можно добавить блок default
в оператор switch или возвратить пользователю список допустимых значений,
однако этот пример в первую очередь демонстрирует ситуацию, в которой Go
ведет себя не так, как можно было бы ожидать от языка с полноценной поддержкой перечислений. Поэтому в большинстве случаев лучше явно определить
срез или ассоциативный массив допустимых значений и выполнять проверку
внутри функции init().
Рассмотрим простой пример такой валидации. Следующий код формализует
допустимые значения в срезе и проверяет их при инициализации:
validLanguages = []string{
"en",
"sp",
"fr",
"de",
}
func init() {
flag.StringVar(&userLanguage, "lang", "en", "language
to use (en, sp, fr, de).")
flag.Parse()
if slices.Contains(validLanguages, userLanguage) == false {
log.Fatalf("Invalid language %s. Please use one of %v",
userLanguage, validLanguages)
}
}
2.1.3. Срезы, массивы и словари
В Go существует несколько способов работы со списками, массивами, векторами и словарями. Стандартная библиотека предусматривает более сложные
структуры данных в пакете container, однако для создания однородных коллекций в большинстве случаев вполне достаточно срезов и массивов. Разница
между ними лишь в том, что у срезов динамическая длина, а у массивов —
фиксированная. Метод flag.Args() возвращает коллекцию строк переменной
длины и добавляет в срез все аргументы, не являющиеся флагами, разделяя
их пробелами.
Для поддержки словарей в Go применяются ассоциативные массивы (map),
в которых в качестве ключей и значений можно использовать любые типы.
Благодаря обобщениям появляется дополнительная гибкость при работе со
срезами, однако встроенной поддержки кортежей вы не найдете, поскольку
срезы должны соответствовать интерфейсам, обеспечивающим возможность
сравнения и упорядочивания. Тем не менее вы можете реализовать такую
функциональность самостоятельно на основе типа interface{} или any. Эти
типы называют «пустыми интерфейсами», потому что они не имеют методов,
требования которых нужно было бы удовлетворять. Подробнее об этом мы поговорим в следующей главе.
2.1. Создание CLI-приложений на языке Go
63
2.1.4. Фреймворки для создания консольных приложений
Хотя встроенный пакет flag весьма полезен и обеспечивает базовую функциональность, он не позволяет использовать флаги тем способом, к которому привыкло большинство пользователей. Разница в подходах, принятых в системе
Plan 9 и в системах на базе Linux и BSD, достаточно велика, чтобы вызвать замешательство. Особенно часто проблемы возникают при попытке использовать
комбинации коротких флагов, как в командах наподобие ls -la.
Проблема. Разработчики, пишущие приложения для Unix-подобных систем,
сталкиваются с тем, что пользователи ожидают выполнения обработки флагов в привычном стиле Unix. Как же писать консольные утилиты на языке Go,
соответствующие этим ожиданиям? В идеале это должно быть реализовано
без создания специализированных механизмов обработки флагов.
Разумеется, обработка флагов — это не единственный важный аспект создания консольного приложения. Хотя Go-разработчики быстро осваивают
способ написания простых программ, начинающихся с функции main, на
практике же часто приходится реализовывать один и тот же набор функций
в каждом новом CLI-приложении. К счастью, существуют инструменты,
предоставляющие более удобную точку входа для создания таких программ.
Один из них мы рассмотрим далее.
Решение. У этой распространенной задачи уже есть несколько решений.
Например, такие приложения, как Docker, содержат вспомогательные вложенные пакеты для обработки флагов в Linux-стиле. Иногда они представляют собой форки (ответвления) стандартного пакета Go flag, содержащие
дополнительные возможности. Однако создание собственных реализаций
парсинга (разбора) аргументов для каждого проекта приводит к появлению
множества дублирующих друг друга решений одной и той же задачи.
Лучше использовать уже существующую библиотеку для работы с командной
строкой. Вы можете подключить к своему приложению несколько сторонних
пакетов. Многие из них основаны на стандартном пакете flag и предоставляют совместимый или схожий интерфейс. Их применение позволяет сэкономить время и избавляет от необходимости дорабатывать и сопровождать
собственные реализации.
В более сложных случаях можно анализировать отдельные аргументы на наличие известных токенов с помощью стандартных средств Go для парсинга
флагов. Некоторые сторонние библиотеки именно так и расширяют базовый
функционал пакета flag.
Если вам нужно более структурированное и функционально насыщенное решение для реализации консольного приложения, существуют готовые фреймворки, которые, помимо обработки флагов, поддерживают маршрутизацию
команд, справочные сообщения, вложенные подкоманды и автодополнение
в оболочке. Одним из популярных фреймворков для создания консольных приложений является Cobra (https://github.com/spf13/cobra), которая
64
Глава 2. Создание консольного приложения
используется в Docker, Kubernetes и других проектах. Еще одним вариантом
является cli (https://github.com/urfave/cli), применявшийся как в Docker, так
и в Cloud Foundry — PaaS-платформе (Platform as a Service — платформа как
услуга) с открытым исходным кодом. В следующем разделе вы узнаете о том,
как реализовать CLI-приложение с помощью фреймворка Cobra.
Простое консольное приложение
Начнем с создания простого консольного приложения, выполняющего одно
действие. Впоследствии его можно дополнить несколькими командами и подкомандами. На рис. 2.1 представлена структура такого CLI-приложения.
Рис. 2.1. Структура простого приложения
В листинге 2.6 продемонстрировано простое консольное приложение, которое
выводит строку Hello World или строку hello с указанным именем.
Листинг 2.6. Запуск CLI-приложения Hello World с использованием флагов
$ hello_cli
Hello World!
$ hello_cli --name Inigo
Hello Inigo!
$ hello_cli -n Inigo
Hello Inigo!
$ hello_cli -help
NAME:
hello_cli - Print hello world
USAGE:
hello_cli [global options] command [command options] [arguments...]
VERSION:
0.0.0
COMMANDS:
help, h
Shows a list of commands or help for one command
GLOBAL OPTIONS:
--name, -n
'World'
Who to say hello to.
--help, -h
show help
--version, -v
print the version
2.1. Создание CLI-приложений на языке Go
65
При использовании Cobra код, необходимый для создания подобного приложения, занимает всего несколько строк. При этом фреймворк автоматически
обрабатывает короткие и длинные флаги, выводит подсказки для пользователя
и делает множество других вещей. Вам необходимо лишь немного изменить
код стандартного Go-приложения (листинг 2.7): обернуть функциональность
main в структуру &cobra.Command, где и содержится вся необходимая логика,
позволяющая подключить все эти удобные возможности, которые в противном
случае пришлось бы реализовывать вручную, задействовав большой объем
шаблонного кода.
Листинг 2.7. CLI-приложение Hello World!: hello_cli.go
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var helloCommand *cobra.Command
Создает указатель на структуру cobra.Command, которая
инкапсулирует функциональность нашего приложения
Инициализирует команду, указывая
func init() {
правила ее использования
helloCommand = &cobra.Command{
Use:
"hello",
Short: "Print hello world",
Run:
sayHello,
}
helloCommand.Flags().StringP("name", "n", "World",
"Who to say hello to.",
Определяет флаг с короткой и длинной формами и пояснением
)
Помечает флаг name как обязательный
helloCommand.MarkFlagRequired("name")
helloCommand.Flags().StringP("language", "l", "en",
"Which language to say hello in.",
Определяет флаг language,
)
но не делает его обязательным
}
func sayHello(cmd *cobra.Command, args []string) {
name, _ := cmd.Flags().GetString("name")
greeting := "Hello"
language, _ := cmd.Flags().GetString("language")
switch language {
case "en":
greeting = "Hello"
case "es":
greeting = "Hola"
case "fr":
greeting = "Bonjour"
case "de":
greeting = "Hallo"
}
Переключатель, определяющий
язык приветствия
Извлекает значения
флагов name и language
с помощью методов Cobra
66
}
Глава 2. Создание консольного приложения
fmt.Printf("%s %s!\n", greeting, name)
func main() {
helloCommand.Execute()
}
Выполняет команду
Как видите, здесь добавляется чуть больше оберток, чем в обычном коде, но
общая структура остается практически неизменной. При использовании Cobra
мы определяем команды с помощью среза аргументов, которые можно извлечь
внутри функции.
Базовый способ получения самих аргументов довольно удобен и близок к тому,
который предлагает стандартная библиотека. Эти аргументы не привязываются
к переменным автоматически, поэтому их нужно извлекать вручную и обрабатывать внутри целевой команды.
Файл cli.go позволяет задать действие по умолчанию, которое будет выполняться при запуске приложения, включая команды и подкоманды (речь о них
пойдет в следующем разделе). Для определения нужного поведения мы устанавливаем свойство Action, значением которого является функция, принимающая
в качестве аргумента *cli.Context и возвращающая ошибку. Именно в этой
функции содержится код приложения, который должен выполняться. В нашем
случае она получает имя пользователя с помощью c.GlobalString("name")
и выводит приветственное сообщение с этим именем. Если вы хотите, чтобы в случае ошибки приложение завершалось с ненулевым кодом выхода,
возвратите ошибку типа cli.ExitError, которую можно создать с помощью
функции cli.NewExitError.
На последнем этапе запускается само приложение. Для этого в метод Run передается os.Args — список аргументов командной строки, указанных при запуске
приложения.
СОВЕТ Аргументы становятся доступны функции Action в результате их передачи
инициализатору Run. В данном случае вы можете получить срез строк, вызвав функцию
c.Args(). Первый аргумент будет доступен через c.Args()[0].
Команды и подкоманды
Консольные приложения часто имеют множество контекстно зависимых аргументов, которые можно условно разделить на команды и подкоманды (рис. 2.2).
Вспомните приложение Git, позволяющее выполнять команды вроде git add,
git commit или git push origin/main. Такой подход широко распространен, но
его следует использовать с осторожностью, поскольку некорректное сочетание
команды и подкоманды может привести к ошибке, например, как в случае с git
add origin/main.
2.1. Создание CLI-приложений на языке Go
67
Рис. 2.2. Использование приложения с командами и подкомандами
Чтобы продемонстрировать работу с командами, рассмотрим простое приложение-калькулятор, принимающее команду и два позиционных аргумента
(листинг 2.8).
Листинг 2.8. Простой калькулятор, реализованный в виде CLI-приложения
package main
import (
"log"
"strconv"
"github.com/spf13/cobra"
)
func operation(op string, n1 string, n2 string) (float32, error) {
num1, err := strconv.Atoi(n1)
if err != nil {
return 0, err
}
num2, err := strconv.Atoi(n2)
if err != nil {
return 0, err
}
fl1 := float32(num1)
fl2 := float32(num2)
switch op {
case "add":
return fl1 + fl2, nil
case "sub":
return fl1 - fl2, nil
case "mul":
return fl1 * fl2, nil
68
}
Глава 2. Создание консольного приложения
case "div":
return fl1 / fl2, nil
}
return 0, nil
var cmdAdd = &cobra.Command{
Use:
"add",
Short: "Add two numbers",
Long: "Add two numbers: add <number1> <number2>",
Run: func(cmd *cobra.Command, args []string) {
result, err := operation("add", args[0], args[1])
if err != nil {
log.Fatal(err)
}
log.Printf("result: %f\n", result)
},
Args: cobra.ExactArgs(2),
}
var cmdSub = &cobra.Command{
Use:
"sub",
Short: "Subtract two numbers",
Long: "Subtract two numbers: sub <number1> <number2>",
Run: func(cmd *cobra.Command, args []string) {
result, err := operation("sub", args[0], args[1])
if err != nil {
log.Fatal(err)
}
log.Printf("result: %f\n", result)
},
Args: cobra.ExactArgs(2),
}
var cmdMul = &cobra.Command{
Use:
"mul",
Short: "Multiply two numbers",
Long: "Multiply two numbers: mul <number1> <number2>",
Run: func(cmd *cobra.Command, args []string) {
result, err := operation("mul", args[0], args[1])
if err != nil {
log.Fatal(err)
}
log.Printf("result: %f\n", result)
},
Args: cobra.ExactArgs(2),
}
var cmdDiv = &cobra.Command{
Use:
"div",
Short: "Divide two numbers",
Long: "Divide two numbers: div <number1> <number2>",
Run: func(cmd *cobra.Command, args []string) {
result, err := operation("div", args[0], args[1])
2.2. Работа с конфигурацией
69
if err != nil {
log.Fatal(err)
}
log.Printf("result: %f\n", result)
}
},
Args: cobra.ExactArgs(2),
func main() {
var calculator = &cobra.Command{
Use:
"calculator",
Short: "A simple calculator",
}
calculator.AddCommand(cmdAdd)
calculator.AddCommand(cmdSub)
calculator.AddCommand(cmdMul)
calculator.AddCommand(cmdDiv)
calculator.Execute()
}
Конечно, этот калькулятор довольно примитивен (принимает только два числа
и не умеет разбирать выражения), но даже на его примере можно увидеть некоторые возможности фреймворка Cobra. Как видите, Cobra позволяет добавлять
команды к другим командам, разделять команды на основе первого аргумента
и задавать требования к числу подкоманд, необходимых для корректной маршрутизации и обработки команды.
2.2. Работа с конфигурацией
Вторым важным аспектом, дополняющим работу с флагами, является постоянная
конфигурация приложения. Ее примерами могут служить файлы в каталоге /etc
в некоторых дистрибутивах Linux, а также пользовательские конфигурационные
файлы вроде .gitconfig или .bashrc.
Передача конфигурации в приложение — это весьма распространенная задача.
Едва ли найдется сколько-нибудь серьезное приложение, будь то консольная
утилита или сервер, которое обходилось бы без постоянной конфигурации.
В связи с этим возникает вопрос: как передать такую конфигурацию в приложение и сделать ее доступной для использования?
Если в предыдущем разделе мы обсуждали способ передачи конфигурационной
информации через параметры командной строки, то здесь мы сосредоточимся
на передаче настроек через файлы, а также через переменные окружения —
в духе методологии Twelve-Factor App — набора из 12 принципов создания
веб-приложений. Мы добавим поддержку распространенных форматов конфигурационных файлов, в частности JSON, YAML и INI, а затем рассмотрим
способ передачи конфигурации через переменные окружения, которому отдается
предпочтение в рамках вышеназванной методологии.
70
Глава 2. Создание консольного приложения
12 принципов создания веб-приложений
Эта популярная методология широко применяется при разработке веб-приложений,
SaaS-сервисов и других подобных систем и основана на следующих принципах.
¡
Использование единой кодовой базы, хранящейся в системе контроля версий,
с возможностью многократного развертывания.
¡
Явное объявление зависимостей и их изоляция от других приложений.
¡
Хранение конфигурации приложения в переменных окружения.
¡
Подключение вспомогательных сервисов.
¡
Разделение этапов сборки и запуска.
¡
Выполнение приложения в виде одного или нескольких процессов без сохранения состояния.
¡
Экспорт сервисов через привязку к TCP-порту.
¡
Горизонтальное масштабирование за счет добавления процессов.
¡
Обеспечение надежности приложения за счет быстрого запуска и корректного
завершения работы.
¡
Минимизация различий между разработкой, тестированием и эксплуатацией.
¡
Обработка журналов как потоков событий.
¡
Выполнение административных задач в виде отдельных процессов.
Более подробную информацию можно найти на официальном сайте методологии:
https://12factor.net.
2.2.1. Использование конфигурационных файлов
Аргументы командной строки, о которых шла речь ранее, во многих случаях
удобны и хорошо подходят для приложений, при каждом запуске которых
необходимо передавать функциональные переменные. Но если речь идет
об однократной настройке программы в определенной среде, то такой подход — не лучшее решение, поскольку в процессе многократного указания
одних и тех же параметров можно допустить ошибку. В таких случаях гораздо
удобнее сохранять конфигурацию в отдельном файле, который приложение
сможет загрузить при запуске. В следующих разделах мы рассмотрим три
наиболее распространенных формата конфигурационных файлов и способы
работы с ними.
Проблема. Программе требуется постоянная конфигурация, которую слишком сложно задавать с помощью аргументов командной строки.
Решение. Сегодня одним из самых популярных форматов конфигурационных
файлов является JSON. Стандартная библиотека Go содержит встроенные
средства для парсинга JSON-данных, их маршалинга и демаршалинга.
2.2. Работа с конфигурацией
71
Обсуждение. Рассмотрим конфигурационный JSON-файл config.json
со следующей структурой:
{
}
"enabled": true,
"path": "/usr/local"
В листинге 2.9 содержится парсер для конфигурационного файла в формате
JSON.
Листинг 2.9. Парсинг конфигурационного JSON-файла: json_config.go
package main
import (
"encoding/json"
"fmt"
"os"
)
type configuration struct {
Enabled bool
Path
string
}
Тип данных, способный
хранить JSON-значения
func main() {
file, err := os.Open("config.json")
if err != nil {
panic(err)
}
defer file.Close()
decoder := json.NewDecoder(file)
conf := configuration{}
err = decoder.Decode(&conf)
if err != nil {
fmt.Println("Error:", err)
}
}
Открывает конфигурационный файл
Парсит JSON и сохраняет
значения в переменной
fmt.Println(conf.Path)
Рассматриваемый метод состоит из нескольких этапов. Сначала необходимо
создать тип данных (или набор типов) в виде структуры, представляющей JSONфайл. При этом имена полей и их вложенность должны точно соответствовать
структуре JSON-документа. Поскольку каждое поле и свойство структуры должны быть доступны внешнему пакету, их имена нужно писать с заглавной буквы
(это делает свойства публичными). Если вы нарушите это требование, линтер
выдаст предупреждение; в противном случае вы можете даже не заметить, что
72
Глава 2. Создание консольного приложения
некоторые поля не были корректно демаршализованы. Это касается не только
демаршалинга JSON, но и любых структур, свойства которых должны быть доступны из других пакетов. Функция main открывает конфигурационный файл
и преобразует его содержимое в экземпляр конфигурационной структуры.
Если ошибок не возникло, значения из JSON-файла становятся доступны для
использования в приложении через переменную conf.
ПРИМЕЧАНИЕ Работа с JSON в Go имеет множество нюансов, которые мы подробно
рассмотрим в главе 11, посвященной работе с JSON API.
СОВЕТ Обратите внимание, что переменная err «затеняется» при повторном присваивании значения, возвращаемого методом Decode(). Обработка ошибок и их передача
вверх по стеку вызовов в Go покажется непривычной для тех, кто работает с другими
языками. Иногда ошибки можно сознательно игнорировать, присваивая результат
в специальный пустой идентификатор _, но злоупотреблять этим подходом не стоит.
Лучший вариант — переопределить переменную err или обернуть код в блок { ... },
чтобы корректно обрабатывать ошибки без применения лишнего шаблонного кода.
Подробнее об обработке ошибок мы поговорим в главе 4. Хранить конфигурации
в формате JSON имеет смысл, если вы хорошо знакомы с этим форматом, используе
те распределенное хранилище конфигурации (например, Etcd) или просто хотите
ограничиться средствами стандартной библиотеки Go. Основной недостаток формата
JSON заключается в том, что он не поддерживает комментарии, на что часто жалуются разработчики при создании конфигурационных файлов. Кроме того, его жесткая
структура может показаться излишне строгой. Но у вас всегда есть другие варианты.
Для более гибкой работы с конфигурацией можно использовать другие форматы,
которые позволяют оставлять комментарии и тем самым облегчают самодокументирование файлов.
Решение. YAML (рекурсивный акроним: YAML Ain't Markup Language —
«YAML — не язык разметки») — это удобный для восприятия человеком формат сериализации данных. Он поддерживает комментарии и достаточно прост
в использовании. YAML часто применяется для хранения конфигурации
приложений, и мы рекомендуем выбирать именно его. Хотя в стандартной
библиотеке Go отсутствует встроенный парсер YAML, существует множество
сторонних библиотек. С одной из них вы познакомитесь в этой главе.
Обсуждение. Рассмотрим простой конфигурационный файл в формате
YAML:
# Строка комментария
enabled: true
path: /usr/local
В листинге 2.10 показан пример кода, который парсит конфигурационный
YAML-файл и выводит его содержимое.
2.2. Работа с конфигурацией
73
Листинг 2.10. Парсинг конфигурационного файла в формате YAML
package main
import (
"fmt"
"github.com/kylelemons/go-gypsy/yaml"
)
func main() {
config, err := yaml.ReadFile("conf.yaml")
if err != nil {
fmt.Println(err)
return
}
Импортирует
сторонний YAML-парсер
Считывает YAML-файл
и заполняет структуру
var path string
var enabled bool
path, err = config.Get("path")
if err != nil {
fmt.Println("`path` flag not set in conf.yaml", err)
return
}
enabled, err = config.GetBool("enabled")
if err != nil {
fmt.Println("`enabled` flag not set in conf.yaml", err)
return
}
}
fmt.Println("path", path)
fmt.Println("enabled", enabled)
Выводит на экран значения
из YAML-файла
СОВЕТ Здесь мы снова «затеняем» возвращаемое значение ошибки, чтобы не плодить лишних переменных. Однако обратите внимание, что при возникновении неисправимой ошибки выполнение функции main прерывается досрочно. Такой прием
позволяет сократить объем шаблонного кода, устранить нерелевантные ошибки и упростить логику конструкций switch и if/else. Подробнее о распространении ошибок,
их обработке и преимуществах раннего возврата из функции мы поговорим в главе 4.
Для работы с YAML-файлами в листинге 2.10 импортируется сторонний пакет
github.com/kylelemons/go-gypsy/yaml, который мы настоятельно рекомендуем.
Он содержит удобные средства для чтения YAML-данных из строки или файла,
обработки различных типов и преобразования конфигурации в формат YAML.
С помощью функции ReadFile содержимое конфигурационного файла считывается и возвращается в виде структуры File из пакета yaml. Эта структура предоставляет доступ к данным YAML-файла. Метод Get позволяет получить значение
строки. Для второго значения, enabled, используется специализированный
74
Глава 2. Создание консольного приложения
метод GetBool, который автоматически приводит тип к булеву значению. Есть
и другие специализированные методы, возвращающие значения, приведенные
к соответствующим типам.
Решение. Еще одним актуальным форматом, который работает в приложениях
на языке Go, является INI. Хотя в стандартной библиотеке Go отсутствует
парсер INI, вы можете воспользоваться соответствующими сторонними биб
лиотеками. Как и YAML, формат INI является более удобным для чтения,
чем JSON.
Обсуждение. Рассмотрим следующий INI-файл:
; Строка комментария
[Section]
enabled = true
path = /usr/local # еще один комментарий
В листинге 2.11 показан парсинг этого файла и пример работы с внутренними
данными.
Листинг 2.11. Парсинг конфигурационного файла в формате INI
package main
import (
"fmt"
"os"
"github.com/go-ini/ini"
)
Подключает сторонний пакет
для парсинга INI-файлов
func main() {
config, err := ini.Load("conf.ini")
if err != nil {
Выводит
fmt.Println(err)
на экран
os.Exit(1)
значение
}
параметра path
fmt.Println(config.Section("Section").Key("path").String())
enabled, err :=
config.Section("Section").Key("enabled").Bool()
Выполняет парсинг
if err != nil {
с одновременной
fmt.Println(err)
проверкой наличия
os.Exit(1)
значения
Выводит на экран значение
}
параметра
enabled
fmt.Println(enabled)
}
Здесь сторонний пакет github.com/go-ini/ini выполняет парсинг INI-файла
и преобразует его в структуру данных. Этот пакет позволяет обрабатывать
INI-файлы и строки аналогично тому, как это делает стандартная библиотека
Go в случае с JSON.
С помощью геттера переменной config можно обращаться к разделам INI-файла
и соответствующим значениям. В случае строковых типов данных возвращается
2.2. Работа с конфигурацией
75
либо явно заданное значение, либо значение по умолчанию для этого типа в Go.
В нашем случае мы извлекаем значение параметра path из раздела INI-файла
Section в качестве строки.
Однако при попытке прочитать булево значение возникает вопрос: откуда нам
знать, было значение явно задано в INI-файле как false или оно просто отсутствует? Эта библиотека предоставляет несколько методов, позволяющих различать подобные ситуации. Если значение отсутствует, мы проверяем наличие
ошибки, в противном случае продолжаем выполнение в обычном режиме.
Пакет github.com/go-ini/ini обладает и другими полезными возможностями,
например поддерживает одновременное чтение нескольких INI-файлов, задание
значений по умолчанию для различных типов и запись в INI-файлы. Подробнее
об этом можно узнать в документации: https://ini.unknwon.io.
СОВЕТ Обратите внимание, что при возникновении критической ошибки выполнение функции main снова прерывается досрочно, только на этот раз возвращается
еще и код выхода операционной системы. Существуют разные способы для прежде
временного завершения выполнения программы (например, panic или log.Fatal, как
в главе 1), однако использование данного подхода особенно полезно в тех случаях,
когда вы хотите различать коды ошибок и корректно регистрировать их на уровне ОС.
2.2.2. Передача конфигурации через переменные окружения
Классические конфигурационные файлы по-прежнему остаются удобным средством передачи настроек приложениям. Однако в новых средах нарушаются некоторые предпосылки, на которых основана традиционная модель конфигурации.
В ряде случаев пользователь, настраивающий приложение, не имеет полного
доступа к файловой системе. А в некоторых системах конфигурационные файлы
рассматриваются в качестве (статической) части исходного кода, что сводит на
нет их гибкость и удобство.
Наиболее ярко эта тенденция проявляется в новых PaaS-платформах. Обычно
развертывание в таких системах происходит путем передачи исходного кода
на управляющий сервер (например, с помощью команды git push) в рамках
конвейера непрерывной интеграции и доставки (CI/CD-конвейера). Однако
на стороне сервера все, что доступно для конфигурирования во время работы
приложения, — это переменные окружения. Конфигурационные файлы вместе
с контейнерами здесь обычно не используются. Давайте рассмотрим технику
работы в подобных условиях.
Проблема. Многие PaaS-системы не позволяют задавать отдельные конфигурационные файлы для каждого экземпляра. Возможности настройки
ограничиваются небольшим набором средств окружения, в первую очередь
переменными окружения. Контейнеризированные приложения нуждаются
в таких параметрах уже на этапе сборки. Техники контейнеризации мы рассмотрим в главе 12.
76
Глава 2. Создание консольного приложения
Решение. В настоящее время все большую популярность получают приложения, построенные по методологии Twelve-Factor App, которые обычно развертываются на таких облачных платформах, как Heroku, Elastic Container
Service от Amazon Web Services (AWS) и их аналогах. Одним из принципов
данной методологии является хранение конфигурации в переменных окружения, что позволяет задавать различные настройки для разных сред выполнения приложения.
Обсуждение. В качестве примера возьмем переменную окружения PORT, содержащую номер порта, на котором веб-сервер должен принимать соединения. Далее мы покажем, как можно получить это значение и использовать
его при запуске веб-сервера.
В листинге 2.12 продемонстрирован распространенный прием: проверка наличия
критически важной переменной окружения и аварийное завершение программы
в случае ее отсутствия.
Листинг 2.12. Конфигурация на основе переменных окружения
package main
import (
"fmt"
"net/http"
"os"
)
Извлекает значение
func main() {
из переменной
var port string
окружения PORT
if port = os.Getenv("PORT"); port == "" {
panic("environment variable PORT has not been set!")
Завершает выполнение,
}
если переменная
http.HandleFunc("/", homePage)
окружения не задана
http.ListenAndServe(":"+port, nil)
Использует указанный порт
}
для запуска веб-сервера
func homePage(res http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(res, req)
return
}
fmt.Fprint(res, "The homepage.")
}
В этом примере используется пакет http из стандартной библиотеки, уже знакомый вам по простому веб-серверу Hello World из листинга 1.15. К теме вебсерверов мы вернемся в следующем разделе.
Получить конфигурационную информацию из переменных окружения довольно просто. Функция Getenv из пакета os позволяет извлечь значение
переменной в виде строки. Если переменная не найдена, возвращается пустая
2.3. Работа с веб-серверами в реальных условиях
77
строка. В данном примере мы решили использовать прием «раннего выхода»:
если значение не задано, программа завершает работу с помощью panic, чтобы
избежать последующих ошибок.
Поскольку все переменные окружения технически хранятся и извлекаются в виде
строк, в некоторых случаях вам может потребоваться привести их к другому типу.
Для этого существует пакет strconv. Например, если переменная PORT должна
быть целым числом, ее можно преобразовать с помощью функции ParseInt.
ВНИМАНИЕ При работе с переменными окружения нужно помнить о безопасности.
Так, к ним может получить доступ сторонний подпроцесс, запущенный из вашего приложения. Во избежание конфликтов также рекомендуется использовать пространства
имен. Например, вместо того, чтобы называть переменную просто PORT, разумнее
использовать имя MYAPP_PORT. При построении контейнеризированных приложений
переменные окружения можно задать на этапе сборки, чтобы изолировать их от этапа
выполнения. Подробнее об этом мы поговорим в главе 12.
2.3. Работа с веб-серверами в реальных условиях
Хотя стандартная библиотека Go предоставляет надежную основу для создания
веб-серверов, на практике часто возникает необходимость изменить некоторые
параметры или добавить механизмы для обеспечения отказоустойчивости. В этом
разделе мы поговорим о маршрутизации, то есть о сопоставлении URL-путей
с функциями обратного вызова, а также о запуске и корректном завершении
работы серверов.
Веб-серверы являются ключевой составляющей пакета http, который, в свою
очередь, опирается на пакет net для работы с TCP-соединениями. Поскольку
веб-серверы представляют собой неотъемлемую часть стандартной библиотеки
Go и широко используются на практике, их простейшие примеры были представлены уже в главе 1. В этом разделе мы выйдем за рамки базовой реализации
и рассмотрим некоторые практические нюансы разработки приложений.
ПРИМЕЧАНИЕ Дополнительную информацию о пакете http можно найти по адресу
https://pkg.go.dev/net/http.
2.3.1. Запуск и завершение работы сервера
С помощью пакетов net или http можно создать сервер, привязать его к TCP-порту
и начать обработку входящих соединений и запросов. Но что произойдет, если
завершить работу сервера до отключения клиентов или до того, как все данные,
например журналы или пользовательская информация, будут записаны на диск?
Запуск и остановку серверов в операционной системе лучше доверять инициализационным демонам (initialization daemon). Команда go run бывает удобна в процессе разработки и может использоваться в некоторых системах, построенных
78
Глава 2. Создание консольного приложения
по методологии Twelve-Factor App. Однако такой способ не считается типичным
или рекомендуемым. Несмотря на свою простоту, ручной запуск приложения
плохо интегрируется с операционными инструментами и не рассчитан на такие
ситуации, как неожиданная перезагрузка системы. Инициализационные демоны
были созданы именно для таких задач и успешно с ними справляются.
Большинство операционных систем имеют собственные средства инициализации. Так, например, в дистрибутивах Linux (таких как Debian и Ubuntu)
широко используется инструмент systemd (https://www.freedesktop.org/wiki/
Software/systemd). Если systemd работает со скриптом, то вы сможете запускать
и останавливать приложения с помощью команд, подобных тем, что приведены
в листинге 2.13.
Листинг 2.13. Запуск и завершение работы приложений с помощью upstart
$ systemctl start myapp.service
$ systemctl stop myapp.service
Запускает приложение myapp
Останавливает работу
приложения myapp
Для разных операционных систем и их версий существует множество инициализационных демонов. Среди наиболее известных — upstart, init, supervisor
и launchd. Поскольку конфигурационные сценарии и команды сильно различаются в зависимости от системы, мы не будем рассматривать их. Однако у всех
этих инструментов есть документация, а также обучающие материалы и примеры,
с которыми вы можете ознакомиться самостоятельно.
ПРИМЕЧАНИЕ Мы не рекомендуем запускать ваши приложения напрямую в качестве демонов. Вместо этого вам следует воспользоваться инициализационным
демоном, который будет управлять запуском приложения, следить за его состоянием
и обеспечивать автоматический перезапуск. Некоторые обратные прокси-серверы, такие как NGINX, функционируют как демоны. Хотя NGINX предоставляет обширный
набор возможностей, встроенные серверы Go, как правило, прекрасно обходятся без
внешнего веб-сервера. Поэтому, если вы не нуждаетесь в специфических функциях
NGINX, предпочтительнее запускать свой сервис напрямую с помощью инициализационного демона. Kubernetes и другие облачные инструменты оркестрации контейнеров выполняют те же задачи, что и демоны, хотя и не запускаются в качестве
отдельных процессов на уровне операционной системы.
2.3.2. Корректное завершение работы
с использованием сигналов ОС
При завершении работы сервера обычно бывает необходимо прекратить прием
новых запросов, сохранить данные на диск и корректно закрыть уже установленные соединения. Однако встроенный в стандартную библиотеку пакет http
останавливает сервер мгновенно, не позволяя выполнить какие-либо из этих действий. В худшем случае это может привести к потере или повреждению данных.
2.3. Работа с веб-серверами в реальных условиях
79
Проблема. Во избежание потери данных и непредсказуемого поведения при
завершении работы серверу может потребоваться выполнить некую процедуру очистки.
Решение. Мы можем отлавливать сигналы операционной системы и обрабатывать их в контексте, чтобы обеспечить корректное отключение текущих
соединений. Если мы зададим разумное значение тайм-аута, эти соединения
смогут завершиться до остановки сервера.
Обсуждение. В главе 5 мы подробно поговорим о реализации конкурентности.
Здесь же мы объединим HTTP-обработчики, горутины и сигналы ОС, чтобы
обеспечить более грамотное завершение работы сервера. Пока можете не беспокоиться о нюансах работы горутин, мы досконально разберем их в главе 5.
Чтобы обеспечить корректную остановку сервера, мы можем воспользоваться
горутиной для управления конкурентным процессом. В данном примере горутина будет слушать сигналы и инициировать отключение сервера через заданное
время с помощью метода Shutdown() (листинг 2.14).
Листинг 2.14. Корректное завершение работы с использованием сигналов
package main
import (
"fmt"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
handleFunc := newHandler()
server := &http.Server{
Addr:
":8080",
Handler: handleFunc,
}
Получает экземпляр
сервера и обработчика
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
go func() {
server.ListenAndServe()
}()
}
Настраивает канал для прослушивания
определенных сигналов
Запускает веб-сервер
time.Sleep(5 * time.Second)
<-ch
if err := server.Shutdown(nil); err != nil {
panic(err)
}
type handler struct{}
При получении сигнала добавляет
пятисекундную задержку
80
Глава 2. Создание консольного приложения
func newHandler() *handler {
return &handler{}
}
Наш обработчик
func (h *handler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
name := query.Get("name")
if name == "" {
name = "Inigo Montoya"
}
fmt.Fprint(res, "Hello, my name is ", name)
}
Работа функции main начинается с получения экземпляра обработчика, способного отвечать на HTTP-запросы. В нашем примере это простая функция,
возвращающая ответ Hello World. На ее место можно поместить более сложный
обработчик, осуществляющий маршрутизацию или использующий регулярные
выражения (см. листинг 2.17).
Для корректного завершения работы сервера в первую очередь необходимо
определить подходящий для этого момент. Пакет signal предоставляет средства
для получения сигналов операционной системы, включая сигналы прерывания
и завершения процесса. Далее нужно создать конкурентный канал для приема
сигналов прерывания и завершения от ОС, чтобы приложение могло на них
среагировать. Функция ListenAndServe, как и ее аналог из пакета http, блокирует
выполнение. Поэтому для отслеживания сигналов горутина должна выполняться
в конкурентном режиме. Канал ждет, пока не поступит сигнал, чтобы отправить
методу Shutdown сервера сообщение о необходимости прекратить прием новых
соединений и отключиться после обработки всех текущих запросов. Вызов
функции ListenAndServe тем же способом, что и при использовании пакета
http, запускает сервер.
СОВЕТ Сервер дожидается завершения только тех обработчиков, которые обрабатывают входящие запросы. Если в вашем приложении есть дополнительные горутины,
которые также необходимо завершить до остановки сервера, это нужно реализовать
отдельно, например, с помощью механизма WaitGroup.
Такой подход имеет следующие преимущества.
Текущие процессы обработки HTTP-запросов могут быть корректно завершены, а не прерваны на середине выполнения. Кроме того, приведенный код
можно доработать так, чтобы сервер завершал работу только после закрытия
всех активных соединений.
Сервер перестает слушать TCP-порт, но при этом продолжает обслуживать
текущие запросы, позволяя другому приложению занять этот порт и начать
обработку запросов. Это бывает удобно, например, при обновлении версий
приложения.
2.3. Работа с веб-серверами в реальных условиях
81
Все незаконченные процессы, такие как загрузка форм/файлов, многоэтапные операции с базой данных и обработка файлов, успевают завершиться до
прекращения работы приложения.
Однако есть и недостаток.
В некоторых случаях может возникнуть необходимость передать активные
сокет-соединения другому экземпляру того же приложения или другой
программе. Если соединения между сервером и клиентом «живут» долго,
процедура завершения работы может сделать такую передачу невозможной.
2.3.3. Маршрутизация веб-запросов
Одной из базовых задач любого HTTP-сервера является прием входящего запроса и сопоставление его с внутренней функцией, которая возвращает ответ
клиенту. Такой механизм маршрутизации запроса к соответствующему обработчику крайне важен. Если он корректно реализован, вы сможете создавать
веб-сервисы, которые легко сопровождать и адаптировать в случае изменения
требований. В данном разделе мы рассмотрим различные сценарии маршрутизации и решения связанных с ней задач.
Мы начнем с простых подходов, однако вам не стоит ограничиваться ими. Базовое решение, с которого мы начнем, хорошо подходит для прямого сопоставления, но может не обеспечивать той гибкости, в которой нуждаются современные
веб-приложения. Начиная с версии 1.22, Go-библиотека http предоставляет
расширенные возможности для реализации продвинутой маршрутизации.
Сопоставление путей с обработчиками
Веб-приложения и серверы, реализующие REST API, как правило, выполняют
разные действия в зависимости от сочетания пути и метода запроса. На рис. 2.3
показана часть URL-адреса, отвечающая за путь. В примере Hello World из
главы 1 (см. листинг 1.15) использовалась одна функция для обработки всех
возможных путей. Для такого простого приложения этого вполне достаточно.
Однако подобный подход плохо масштабируется, так как одна функция не справляется с обработкой множества путей. В этом разделе мы рассмотрим несколько
способов работы с путями и различными HTTP-методами, такими как GET, POST,
PUT и DELETE (которые иногда называют глаголами).
Рис. 2.3. Путь в URL-адресе, используемый при маршрутизации запросов
Проблема. Для корректной маршрутизации запросов веб-сервер должен уметь
быстро и эффективно находить путь в URL-адресе и направлять входящие
запросы к соответствующим функциям-обработчикам.
82
Глава 2. Создание консольного приложения
Решение (несколько обработчиков). Чтобы расширить пример из листинга 1.15, можно использовать отдельную функцию-обработчик для каждого пути. Такой прием, описанный в руководстве Writing Web Applications
(https://go.dev/doc/articles/wiki), особенно хорошо подходит для несложных
веб-приложений с ограниченным числом простых маршрутов. Как вы вскоре
увидите, этот подход может оказаться одним из самых мощных.
Обсуждение. Начнем с простого примера, в котором используется несколько
обработчиков.
Мы добавим несколько обработчиков и путей, чтобы приложение выполняло
разные действия в зависимости от запроса (листинг 2.15).
Листинг 2.15. И
спользование нескольких функций-обработчиков:
multiple_handlers.go
package main
import (
"fmt"
"net/http"
"strings"
)
func main() {
http.HandleFunc("/hello", helloHandler)
Регистрирует обработчики
http.HandleFunc("/goodbye/", goodbyeHandler)
URL-путей
http.HandleFunc("/", homePageHandler)
Пытается запустить
if err := http.ListenAndServe(":8080", nil); err != nil {
веб-сервер на порте
panic(err)
8080 или завершает
}
выполнение
при возникновении
}
ошибки
func helloHandler(res http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
Получает имя
Функция-обработчик,
name := query.Get("name")
из строки запроса
сопоставленная
if name == "" {
с путем /hello
name = "Inigo Montoya"
}
Функция-обработчик,
fmt.Fprint(res, "Hello, my name is ", name)
сопоставленная
}
с путем /goodbye/
func goodbyeHandler(res http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(path, "/")
Извлекает
name := parts[2]
имя из пути
if name == "" {
name = "Inigo Montoya"
Функция-обработчик
}
для домашней
fmt.Fprint(res, "Goodbye ", name)
и несуществующей
}
страницы
func homePageHandler(res http.ResponseWriter, req *http.Request) {
2.3. Работа с веб-серверами в реальных условиях
}
if req.URL.Path != "/" {
http.NotFound(res, req)
return
}
fmt.Fprint(res, "The homepage.")
83
Проверяет путь, чтобы определить,
является ли страница домашней
или несуществующей
ПРИМЕЧАНИЕ Любой контент, полученный от пользователя, следует очищать
перед использованием. Это касается и обратного отображения данных пользователю,
поскольку в некоторых случаях неочищенный пользовательский ввод, встроенный
в ответ, может содержать вредоносный JavaScript-код. Соответствующая функцио
нальность реализуется Go-пакетом html/template, который мы обсудим в главе 9.
В этом примере используются три функции-обработчика для трех путей или их
частей. При направлении трафика маршрутизатор ищет наиболее подходящий
путь, начиная с самого конкретного и заканчивая общим. В данном случае любой путь, для которого не удастся найти более специфичный обработчик, будет
сопоставлен с обработчиком по умолчанию (/).
Функция helloHandler сопоставляется с путем /hello. Обработчики получают
на вход два аргумента: http.ResponseWriter и http.Request. Имя для приветствия может передаваться через строку запроса с ключом name. Запрошенный
URL-адрес представляет собой свойство структуры http.Request типа url.URL.
Метод Query этой структуры возвращает значение ключа или пустую строку,
если значение ключа отсутствует. Если строка пуста, используется значение по
умолчанию — Inigo Montoya.
СОВЕТ Пакет net/url, содержащий тип URL, предоставляет множество полезных
функций для работы с URL-адресами.
ПРИМЕЧАНИЕ Чтобы различать HTTP-методы (GET, POST и т. д.), нужно проверять
значение http.Request.Method, где хранится метод.
Функция goodbyeHandler обрабатывает путь /goodbye/, а также случаи, когда
к этому пути добавляется текст. Например, путь /goodbye/Buttercup задает имя
Buttercup. Для нахождения части пути, следующей за /goodbye/, мы разделяем
URL-адрес с помощью пакета strings.
Функция homePageHandler обрабатывает путь / и те случаи, когда страница не найдена.
Чтобы решить, возвращать ли сообщение об ошибке 404 (страница не найдена) или
содержимое домашней страницы, следует проверить значение http.Request.Path.
В пакете http есть вспомогательная функция NotFound, с помощью которой можно
задать код HTTP-ответа 404 и отправить текст 404 page not found.
СОВЕТ Пакет http содержит функцию Error, которая позволяет задать код HTTPошибки и вернуть соответствующее сообщение. Функция NotFound использует ее для
обработки ошибки 404.
84
Глава 2. Создание консольного приложения
Применение нескольких функций-обработчиков, сопоставленных с разными
путями, имеет следующие преимущества.
Будучи базовым методом из пакета http, он хорошо задокументирован и протестирован, а примеры его реализации легко найти. Кроме того, он не требует
использования дополнительных библиотек или зависимостей.
Сами пути и соответствующие им функции легко читаются и отслеживаются.
Если эти функции достаточно компактны и не снижают удобочитаемость
кода, то их можно записать непосредственно в месте вызова.
С выходом Go 1.22 в библиотеке net/http устранили ряд предыдущих ограничений, связанных с маршрутизацией.
Ранее не существовало способа различать маршруты по HTTP-методу. При
создании REST API такие методы, как GET, POST или DELETE, требуют принципиально разной логики обработки, а значит — написания большого объема
шаблонного кода.
Раньше почти каждая функция-обработчик должна была проверять, не выходит ли путь за допустимые границы, и при необходимости возвращать
сообщение Page Not Found. В листинге 2.15 обработчик /goodbye/ принимает
пути, начинающиеся с /goodbye. Все пути с этим префиксом обрабатываются
здесь, поэтому если вы захотите вернуть ошибку 404 для пути вроде /goodbye/
foo/bar/baz, то это поведение придется реализовывать непосредственно
внутри обработчика. Внесенные в версию Go 1.22 изменения, связанные
с приоритизацией маршрутов и переменными в URL, значительно расширили функциональность встроенных обработчиков и устранили потребность
в сторонних библиотеках маршрутизации и мультиплексирования.
В простых случаях использование нескольких обработчиков довольно удобно.
Поскольку такой подход не требует ничего, кроме пакета http , количество
внешних зависимостей сводится к минимуму. Однако, если приложение не ограничивается тривиальными сценариями, имеет смысл воспользоваться более
гибкими подходами к маршрутизации.
Обработка сложных путей с помощью шаблонов с символами
подстановки
Вышеописанный подход отличается простотой, но он крайне негибок в отношении именования путей, так как каждый путь, подлежащий обработке, приходится
указывать явно. Для крупных программ или систем, построенных в соответствии
с принципами REST, требуются более гибкие решения.
Проблема. Приложению может понадобиться поддержка шаблонов с символами подстановки (wildcards) или другие шаблоны URL, позволяющие серверу корректно обрабатывать запросы без явного указания всех возможных
путей для каждого обработчика.
2.3. Работа с веб-серверами в реальных условиях
85
Решение. Язык Go предоставляет возможность использовать именованные
переменные в путях, а также более точный механизм их приоритизации. Это
позволяет извлекать переменные из пути, без необходимости дублировать
логику внутри функции-обработчика.
Обсуждение. Рассмотрим маршрутизатор, который сопоставляет URL-пути
и HTTP-методы с соответствующими функциями-обработчиками.
В листинге 2.16 показан способ применения шаблонов в HTTP-запросах для
маршрутизации и сохранения значений с целью их последующего использования
внутри функции-обработчика.
Листинг 2.16. Разрешение URL-адресов с помощью пакета path
package main
import (
"fmt"
"net/http"
)
Создает новый
func main() {
мультиплексор (mux)
mux := http.NewServeMux()
mux.HandleFunc("/hello", helloHandler)
mux.HandleFunc("GET /goodbye/", goodbyeHandler)
mux.HandleFunc("GET /goodbye/{name}", goodbyeHandler)
}
Добавляет несколько
конечных точек
для обработки
if err := http.ListenAndServe(":8084", mux); err != nil {
panic(err)
}
func helloHandler(res http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
Получает значение переменной name из строки
name := query.Get("name")
запроса, если оно указано; в противном случае
if name == "" {
использует значение по умолчанию
name = "Inigo Montoya"
}
fmt.Fprint(res, "Hello, my name is ", name)
}
Запускает сервер
или вызывает
панику, если
его не удается
запустить
func goodbyeHandler(res http.ResponseWriter, req *http.Request) {
name := req.PathValue("name")
Принимает необязательный
if name == "" {
... в противном случае
параметр пути и (при наличии)
name = "Inigo Montoya"
использует значение
использует его в качестве имени...
}
по умолчанию
fmt.Fprint(res, "Goodbye ", name)
Возвращает имя (name) в качестве ответа
}
В данном случае мы создаем мультиплексор, обеспечивающий корректную
маршрутизацию запросов. Обратите внимание, что включение необязательного
HTTP-метода во второй путь позволяет сегментировать запросы по методам
в соответствии со стилем REST.
86
Глава 2. Создание консольного приложения
Для приоритизации маршрутов в роутере или мультиплексоре существует нескольких распространенных схем. Некоторые из них не допускают перекрывающихся маршрутов, поэтому в случае их использования одна из наших конечных
точек /goodbye/ была бы проигнорирована. В других схемах приоритет отдается
последнему зарегистрированному маршруту. В Go используется еще одна распространенная схема маршрутизации, в рамках которой маршрут выбирается
на основе самого длинного совпадения. В нашем примере при отсутствии двух
конечных точек нам пришлось бы реализовать более сложную логику внутри
функции-обработчика для извлечения нужной части запроса. Использование
параметра пути обеспечивает быстрый доступ к нужному значению.
Возможности мультиплексора Go не ограничиваются поддержкой вышеописанных
схем (подробнее о которых мы поговорим в главах 8 и 9). Однако в некоторых
случаях вам может понадобиться более сложная логика маршрутизации. Для ее
реализации можно разработать собственный мультиплексор, хотя это не всегда
целесообразно, учитывая, что специализированные библиотеки зачастую реализуют аналогичную функциональность гораздо эффективнее. Тем не менее
в учебных целях имеет смысл рассмотреть способ создания мультиплексора, осуществляющего маршрутизацию на основе регулярных выражений. Такой подход
редко применяется на практике, но служит хорошим демонстрационным примером.
Прежде чем заменить встроенный мультиплексор Go на собственный, важно
понимать плюсы и минусы такого выбора. Стандартный механизм обладает
рядом преимуществ.
Он позволяет легко приступить к простому сопоставлению путей.
Пакет path стабилен и надежен, поскольку входит в стандартную библиотеку Go.
Как правило, стандартный механизм работает быстрее решений, основанных
на сопоставлении с шаблоном (например, с регулярными выражениями).
Недостатки связаны с тем, что пакет path ориентирован на работу с обычными
путями без учета специфики URL-адресов.
Использование символов подстановки в пакете path имеет ряд ограничений.
Например, путь foo/{bar} будет сопоставлен с foo/bar, но не с foo/{bar}/
baz. Если использовать звездочку (*) в качестве символа подстановки, сопоставление прекращается на первом же символе /. Чтобы охватить путь foo/
bar/baz, необходимо указать шаблон вида foo/*/*.
Поскольку path представляет собой универсальный пакет и не учитывает
специфику URL, в нем отсутствуют некоторые полезные функции, например
автоматическое перенаправление при обращении к URL-адресу с завершающим слешем или без него.
Таким образом, этот способ подходит лишь для относительно простых сценариев
с небольшим количеством маршрутов.
2.3. Работа с веб-серверами в реальных условиях
87
Сопоставление URL-адресов с шаблонами
Для большинства REST-приложений простого сопоставления по шаблону
с использованием регулярных выражений оказывается более чем достаточно.
Но что, если вам нужно реализовать логику обработки URL-адресов посложнее?
Пакет path не особенно подходит для таких целей, поскольку поддерживает
лишь базовое сопоставление в стиле POSIX.
Проблема. Обычного сопоставления бывает недостаточно в тех случаях,
когда приложение должно воспринимать путь скорее как строку текста, нежели как путь к файлу. Это особенно важно, если речь идет о сопоставлении
фрагментов, содержащих символ-разделитель (/).
Решение. Встроенный пакет path поддерживает простые схемы сопоставления
путей, но для получения большего контроля над этим процессом можно использовать регулярные выражения. Комбинирование регулярных выражений
с HTTP-обработчиком позволяет создавать быстрые, но гибкие механизмы
сопоставления URL-адресов.
Обсуждение. Рассмотрим способ использования путей и разрешения URLадресов с помощью регулярных выражений.
В листинге 2.17 демонстрируется альтернативный способ маршрутизации
на основе регулярных выражений, используемый в некоторых фреймворках.
Несмотря на недостатки этого подхода (в том числе связанные с безопасностью),
мы решили уделить ему внимание и показать, как сделать встроенные средства
маршрутизации более выразительными.
Листинг 2.17. Разрешение URL-адресов с помощью регулярных выражений
package main
import (
"fmt"
"net/http"
"regexp"
"strings"
)
Импортирует пакет для работы
с регулярными выражениями
Регистрирует пути, сопоставляя
их с функциями-обработчиками
func main() {
rr := newPathResolver()
rr.Add("GET /hello", helloHandler)
rr.Add("(GET|HEAD) /goodbye(/?[A-Za-z0-9]*)?", goodbyeHandler)
if err := http.ListenAndServe(":8080", rr); err != nil {
panic(err)
}
}
func newPathResolver() *regexResolver {
return ®exResolver{
handlers:
make(map[string]http.HandlerFunc),
88
Глава 2. Создание консольного приложения
}
}
cache:
make(map[string]*regexp.Regexp),
cachedHandlers: make(map[string]http.HandlerFunc),
type regexResolver
handlers
cache
cachedHandlers
}
struct {
map[string]http.HandlerFunc
map[string]*regexp.Regexp
map[string]http.HandlerFunc
Сохраняет скомпилированные
регулярные выражения
для повторного использования
При отсутствии совпадающих путей
возвращает ошибку Page Not Found
func (r *regexResolver) Add(regex string, handler http.HandlerFunc) {
r.handlers[regex] = handler
Устанавливает ключ
cache, _ := regexp.Compile(regex)
для поиска по методу
r.cache[regex] = cache
}
func (r *regexResolver) ServeHTTP(res http.ResponseWriter, req *http.Request) {
check := req.Method + " " + req.URL.Path
if handlerFunc, ok := r.cachedHandlers[check]; ok {
Если соответствующий
handlerFunc(res, req)
обработчик уже есть
return
в кэше, пропускает цикл...
}
for pattern, handlerFunc := range r.handlers {
...в противном случае
if r.cache[pattern].MatchString(check) == true {
выполняет поиск кандидата
handlerFunc(res, req)
и сохраняет найденный
r.cachedHandlers[check] = handlerFunc
обработчик в кэше
return
}
При отсутствии совпадающих путей
}
возвращает ошибку Page Not Found
http.NotFound(res, req)
}
func helloHandler(res http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
name := query.Get("name")
if name == "" {
name = "Inigo Montoya"
}
fmt.Fprint(res, "Hello, my name is ", name)
}
func goodbyeHandler(res http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(path, "/")
name := ""
if len(parts) > 2 {
name = parts[2]
}
if name == "" {
name = "Inigo Montoya"
}
fmt.Fprint(res, "Goodbye ", name)
}
2.3. Работа с веб-серверами в реальных условиях
89
Схема маршрутизации на основе регулярных выражений, показанная в листинге 2.17, аналогична примеру с разрешением путей из листинга 2.16. Различия
касаются лишь формата шаблонов путей, сопоставляемых с функциями-обработчиками, и реализации метода ServeHTTP, отвечающего за разрешение.
Пути регистрируются в виде регулярных выражений. Они имеют такую же
структуру, как в примере с net/http: сначала указывается HTTP-метод, а затем
через пробел сам путь. Так, GET /hello является простым путем, а (GET|HEAD)
/goodbye(/?[A-Za-z0-9]*)? — более сложным. Второй допускает как метод GET,
так и метод HEAD. Данное регулярное выражение охватывает пути /goodbye,
/goodbye/ (с завершающим слешем), а также /goodbye/, за которым следуют
буквы и цифры. Учтите, что порядок элементов в срезах и ассоциативных массивах не определен, поэтому частичные совпадения могут быть обнаружены
раньше, чем более полные.
В нашем случае метод ServeHTTP сначала формирует маршрут в виде ключа для
поиска. Затем он проверяет, встречался ли этот ключ ранее и был ли он уже
разрешен. Если да, цикл пропускается и сразу возвращается соответствующий
обработчик. В противном случае метод перебирает регулярные выражения в поисках совпадения. При первом совпадении вызывается функция-обработчик,
и результат сопоставления сохраняется в кэше. Если входящий путь соответствует нескольким регулярным выражениям, используется то, которое было
добавлено первым.
ПРИМЕЧАНИЕ Скомпилированные версии регулярных выражений создаются и кэшируются в момент их добавления. Пакет Go regexp содержит функцию Match, проверяющую соответствие строки шаблону. Первым делом эта функция компилирует
регулярное выражение. Если выражение уже сохранено в кэше, его не придется
повторно компилировать при каждом обращении к серверу.
Проверка путей с помощью регулярных выражений дает большую гибкость
и высокую степень контроля. Однако вместе с этим появляется и сложность:
регулярные выражения зачастую трудно читать, поэтому, чтобы убедиться в их
соответствии нужным путям, вам, скорее всего, придется писать специальные
тесты. Go-пакет path/filepath позволяет немного упростить обработку путей
за счет их парсинга, однако его использование требует более высокоуровневой
и обобщенной маршрутизации.
Добавление слоя кэширования делает вышеописанный подход менее ресурсоемким. Однако важно отметить, что промахи кэша (то есть запросы, не соответствующие ни одному маршруту) не сохраняются в кэше обработчиков. При
относительно небольшом количестве путей такой подход помогает избежать
накопления в памяти невалидных путей, что, в свою очередь, снижает риск потенциальной атаки типа «отказ в обслуживании» (DoS, denial-of-service).
90
Глава 2. Создание консольного приложения
Быстрая маршрутизация (без лишних усилий)
Одним из недостатков встроенного в Go пакета http считается примитивная
система маршрутизации и мультиплексирования. В предыдущих разделах мы
рассмотрели несколько простых способов работы с этим пакетом. Однако вас
может не устроить уровень настраиваемости, производительности или функциональности встроенного HTTP-сервера. Кроме того, возможно, вы захотите
избежать написания шаблонного кода для маршрутизации.
Проблема. Встроенный пакет http может оказаться недостаточно гибким или
быстрым для некоторых сценариев.
Решение. Сопоставление URL-адресов с функциями-обработчиками — это
задача, актуальная практически для всех веб-приложений. Для ее решения
было создано множество пакетов. Один из наиболее распространенных подходов — импортировать готовый маршрутизатор запросов и использовать его
внутри своего приложения.
Ниже перечислено несколько популярных решений.
github.com/julienschmidt/httprouter — считается одним из самых быстрых
маршрутизаторов. Он ориентирован на минимальное потребление памяти
и высокую скорость обработки маршрутов. Поддерживает нечувствительные
к регистру пути, очистку путей от конструкций вроде /../, а также обработку
путей с необязательным завершающим слешем.
github.com/gorilla/mux — часть веб-инструментария Gorilla, имеющего
долгую историю развития. Это набор самостоятельных пакетов, компоненты
которых вы можете использовать в своем приложении. Пакет mux поддерживает гибкие правила сопоставления маршрутов, в том числе по имени хоста,
схеме, HTTP-заголовкам и другим критериям.
github.com/gin-gonic/gin — фреймворк, похожий на Gorilla, но с упором
на производительность, которая достигается благодаря использованию
маршрутизатора, не выделяющего дополнительную память при обработке
запросов.
github.com/bmizerany/pat — маршрутизатор, вдохновленный фреймворком
Sinatra. Зарегистрированные пути в нем легко читаются и могут включать
именованные параметры, например /user/:name. На основе этого инструмента был разработан пакет github.com/gorilla/pat.
Библиотека для построения веб-приложений Sinatra
Sinatra — это веб-фреймворк с открытым исходным кодом, написанный на языке
Ruby. Он используется множеством организаций и послужил основой для разработки
более чем 50 аналогичных фреймворков на других языках, в том числе на Go.
Резюме
91
Каждый из этих пакетов предлагает свой набор функций и API. Также существует множество других маршрутизаторов, и, изучив рынок, вы без труда
подберете качественное стороннее решение, соответствующее вашим требованиям. Несмотря на то что в стандартной библиотеке Go появилась поддержка
переменных пути и маршрутизации на основе HTTP-методов, некоторые
веб-приложения могут нуждаться в более сложной логике. Многие сторонние
решения также превосходят стандартные средства Go по производительности,
что особенно важно, когда вашему приложению требуется работа в условиях
серьезной нагрузки.
Резюме
В Go обработка параметров командной строки может быть реализована с помощью простых и легковесных решений, а также фреймворков для создания
консольных приложений и утилит.
Конфигурационные данные можно извлекать из файлов и переменных окружения разными способами и с использованием различных форматов данных.
Грамотный запуск и корректное завершение работы веб-сервера позволяют
избежать ухудшения пользовательского опыта и потери данных.
Существует несколько способов сопоставлять URL-пути в приложении и направлять их к соответствующим обработчикам.
3
Структуры, интерфейсы
и обобщения
В этой главе
3 Использование структур для представления данных
и методов.
3 Сравнение структур и интерфейсов Go с паттернами
объектно-ориентированного и функционального
программирования.
3 Создание интерфейсов для расширения функциональности
пользовательских типов.
3 Реализация обобщенных типов для повышения гибкости
и повторного использования кода.
Прежде чем перейти к обсуждению более сложных возможностей Go, включая
конкурентность, давайте рассмотрим основополагающие элементы этого языка,
начав с непримитивных структур данных. Пользовательские структуры (struct)
служат основой для представления специфичных данных и взаимосвязей
между ними. Go предусматривает гибкий механизм для создания, расширения
и модификации таких структур. В этой главе мы поговорим о том, как пользовательские структуры, интерфейсы и методы взаимодействуют между собой,
позволяя вам проектировать интуитивно понятные модели данных, определять
их поведение и применять такие конструкции для изоляции данных, контроля
доступа и управления состоянием.
3.1. Представление данных с помощью пользовательских структур
93
3.1. Представление данных
с помощью пользовательских структур
В главах 1 и 2 мы уже задействовали пользовательские структуры данных при
создании простых консольных приложений и веб-сервера. Пользовательские
структуры — это мощные средства языка Go, предназначенные для представления сложных данных. С их помощью можно задать поведение приложения
и организовать поток данных в нем. При разумном использовании они делают
код удобнее для чтения, избавляют от избыточности, а также позволяют аннотировать данные с помощью тегов.
Как упоминалось ранее, на внедрение обобщений (дженериков) в Go ушло более
десяти лет. Отчасти это объяснялось стремлением разработчиков сохранить
минимализм и лаконичность языка без ущерба для его функциональности.
Простота и читаемость всегда считались достоинствами кода на Go.
Другой причиной задержки было потенциальное увеличение временных затрат
на компиляцию. Например, в C++ долгая компиляции заложена в саму архитектуру языка, хотя последние обновления, связанные с метапрограммированием,
сделали его несколько более гибким.
С появлением обобщений в версии Go 1.18 подход к структурам данных и интерфейсам немного изменился. Теперь мы можем делать наши типы более
универсальными благодаря разнообразию параметров, принимаемых функциями и методами. Такая возможность позволяет писать более компактный код,
не повышая сложность его восприятия.
В этой главе вы узнаете о том, как работают обобщения и что они привносят
в модель языка Go как на уровне синтаксиса, так и в плане свободы, которую
они предоставляют разработчикам. Мы также обсудим способы расширения
функциональности структур данных с помощью методов, а затем посмотрим,
как обобщения позволяют упростить код и сделать его более универсальным.
3.1.1. Создание пользовательских структур данных
По сути, структура (struct) — это определяемый пользователем фрагмент данных,
который может представлять что угодно. Структуры Go напоминают структуры
в языке C и в некоторой степени совместимы с ними. Их можно рассматривать
как обобщенные определения объектов в других языках или как низкоуровневый аналог классов в языках без поддержки сложных пользовательских типов
данных. Рассмотрим простую структуру из главы 2, которая описывает конфигурационные данные, полученные из JSON-файла:
type configuration struct {
Enabled bool
Path
string
}
94
Глава 3. Структуры, интерфейсы и обобщения
Эту структуру можно представить как набор данных или как аналог объекта
в объектно-ориентированных языках. В объектно-ориентированном псевдокоде
она выглядела бы так:
class configuration {
public enabled boolean;
public path string;
}
В псевдокоде, не зависящем от конкретного языка, мы изменяем регистр символов. Как вы помните, в Go атрибуты, начинающиеся с заглавной буквы, являются
публичными, а со строчной — закрытыми, доступными только внутри пакета.
Что именно мы помещаем в структуру — базовые и составные типы, срезы,
указатели и т. д. — зависит от нас. Все, что может быть представлено на языке
Go, может являться полем структуры. Мы также можем включать в структуру
другие пользовательские типы; это называется композицией или встраиванием.
Как будет показано в этой главе, с помощью пустого интерфейса или типа any
можно задавать гибкие определения типов. Сама по себе идея проста, однако
способы использования подобных данных открывают множество возможностей.
Важно отметить, что, хотя мы определили повторно используемую структуру,
ее можно создать «на месте» (inline) или объявить в виде анонимной структуры,
как показано в листинге 3.1.
Листинг 3.1. Простейшая анонимная структура
package main
import (
"fmt"
)
Создает встроенную
func main() {
или анонимную структуру
animal := struct {
name string
speak func() string
} {
name: "cat",
speak: func() string {
return "meow"
},
}
}
fmt.Println(fmt.Sprintf("our animal's name is %s and it says %s",
animal.name, animal.speak() ))
Вызывает функцию speak и вставляет
результат в форматируемую строку
В большинстве случаев анонимные структуры не применяются. Однако они
бывают полезны при работе с небольшими фрагментами данных, используемыми в пределах одного метода, либо при упаковке данных во внешний источник
3.1. Представление данных с помощью пользовательских структур
95
и распаковке данных из него, особенно если вам не нужны все поля структуры.
Мы поговорим об анонимных структурах в главе 6, которая посвящена тестированию. Легковесные структуры вроде той, что показана выше, позволяют создавать
простые и гибкие тесты: с их помощью вы можете применить табличный подход
к тестам для проверки поведения метода в различных условиях.
При этом издержки при определении встроенной структуры минимальны.
Такой подход обычно используется в тех случаях, когда необходимо быстро
маршализовать данные или закодировать структуру в произвольный формат.
ПРИМЕЧАНИЕ Новую структуру можно инициализировать с помощью краткой
записи, как показано выше, либо с помощью ключевого слова new, которое создает
и возвращает указатель на нулевое значение соответствующего типа. Существенной
разницы между этими подходами нет — выбор зависит от предпочтений. Если вы
заранее знаете, какие данные собираетесь передавать в структуру, то для упрощения
восприятия кода лучше использовать синтаксис значение := struct{ … }.
3.1.2. Функции внутри структур
В листинге 3.1 функция была добавлена как элемент структуры animal. В Go
функции являются объектами первого класса, то есть их можно передавать
и возвращать так же, как и любой другой тип данных. Несмотря на то что
функции можно передавать другим функциям, на практике обратные вызовы
в Go встречаются довольно редко, хотя это и не считается плохой практикой.
Методы и функции, выступающие в качестве атрибутов (как в листинге 3.1),
воспринимаются проще и, как правило, оказываются удобнее в использовании.
Тем не менее ничто не мешает применять обратные вызовы.
Функцию можно связать с пользовательским типом двумя способами. Первый — сделать ее членом структуры, как было показано ранее. Преимущество
такого подхода в том, что функция располагается прямо внутри структуры,
наряду с остальными переменными, такими как name. Однако у такой функции
нет доступа к самой структуре, внутри которой она определена. Что, если нам
потребуется каким-то образом взаимодействовать со значением name?
Для этого существуют методы — функции, привязанные к определенным типам.
Методы используют тип данных в качестве получателя, и их можно создавать
для любых типов Go.
Проблема. Нам нужна функция, которая принадлежит типу или экземпляру
этого типа и может обращаться к его свойствам, не требуя передачи соответствующих данных в качестве аргументов. Такой подход особенно полезен,
когда необходимо изменить структуру по ссылке или когда число полей
слишком велико для передачи их в функцию в качестве аргументов.
Решение. Использовать методы, которые принимают в качестве получателя
нужный тип и имеют доступ ко всем его полям и другим членам (листинг 3.2).
96
Глава 3. Структуры, интерфейсы и обобщения
Листинг 3.2. Методы структуры
package main
import (
"fmt"
)
type Animal struct {
name string
}
Определяет
пользовательский тип
Определяет метод
с получателем типа Animal
func (a Animal) speak() string {
switch a.name {
case "cat":
return "meow"
case "dog":
return "woof"
default:
return "nondescript animal noise?"
}
}
func main() {
a := Animal{
name: "cat",
}
fmt.Println(a.speak())
a.name = "dog"
fmt.Println(a.speak())
}
a.name = "llama"
fmt.Println(a.speak())
Вызывает метод speak
для структуры вместо
ее передачи в качестве
аргумента
Этот код выглядит проще, чем предыдущий пример. Здесь определен единый
метод speak, который возвращает разные звуки животных в зависимости от названия животного, содержащегося в получателе. Если мы включим в сигнатуру
метода указатель на переменную, (a *Animal), то сможем изменять значения
полей структуры прямо в процессе работы.
ПРИМЕЧАНИЕ Видимость идентификаторов в Go определяется регистром первой
буквы. В данном случае Animal доступен из других пакетов (так как начинается с заглавной буквы), а animal — нет.
Обсуждение. Выбор между методом и функцией зависит от того, какие данные вы собираетесь передавать. Если функция принимает слишком много
входных параметров, стоит задуматься об использовании получателя для
сокращения объема передаваемых данных.
3.1. Представление данных с помощью пользовательских структур
97
Такой подход не только делает код более лаконичным, но и позволяет точнее
контролировать доступ к данным, поскольку при передаче всей структуры
в функцию ее поля становятся доступными как для чтения, так и для изменения.
Еще одной причиной для использования метода с получателем является потребность в изменении данных внутри самого метода. Для этого применяют
указатель на структуру в качестве получателя.
СОВЕТ Как уже говорилось в главе 2, в Go нет встроенного типа enum, однако перечисления можно представить с помощью именованных констант. Если бы мы хотели
ограничить допустимые значения в предыдущем примере, то использование перечислений кардинально не изменило бы наш подход: мы все равно могли бы задать строковые
константы ("cat", "dog") и проверить корректность значения при присваивании. Однако важно помнить, что язык не обеспечивает автоматическое соблюдение подобных
ограничений, поэтому допустимость данных необходимо контролировать вручную.
В листинге 3.3 мы используем указатель на структуру, чтобы иметь возможность
изменять ее свойства.
Листинг 3.3. Метод с указателем в качестве получателя
package main
import (
"fmt"
)
type character struct {
name string
}
func (ch *character) fixName() {
ch.name = "Inigo Montoya"
}
Создает указатель на структуру character
func main() {
с помощью ключевого слова new
ch := new(character)
ch.name = "Prince Humperdinck"
fmt.Println("my name is", ch.name)
Вызывает метод fixName, который
переустанавливает имя персонажа
ch.fixName()
fmt.Println("just kidding, my name is", ch.name)
Показывает результат изменения
}
Структуры — это высокоуровневые пользовательские типы данных, способные
представлять и содержать любое количество объектов первого класса, включая
примитивы, срезы, другие структуры и каналы (о которых мы поговорим в главе 4
в контексте конкурентности). Мы можем использовать методы поверх структур,
чтобы избежать лишнего парсинга и перенаправления исходных данных. Кроме
того, они позволяют изменять содержимое структуры.
98
Глава 3. Структуры, интерфейсы и обобщения
3.1.3. Анонимные идентификаторы
Во всех рассмотренных ранее структурах у всех полей было имя, однако это
не обязательно. Поле может быть анонимным, то есть не иметь имени, при усло
вии, что в структуре присутствует только один элемент каждого типа. Чтобы это
продемонстрировать, давайте немного изменим нашу структуру Animal:
type Animal struct {
string
}
Как видите, имя конкретной строки не задано — это делает ее анонимным
членом структуры. Обратиться к значению такого поля можно по его уникальному типу:
a := Animal{
"cat",
}
func (a Animal) speak() {
log.Println(a.string)
}
fmt.Println(a.speak())
a.string = "dog"
fmt.Println(a.speak())
СОВЕТ При инициализации структуры после каждого поля следует ставить запятую. Эта практика полезна во многих языках, поддерживающих такой синтаксис:
она позволяет экономить время при отладке синтаксических ошибок, а также при
копировании или вставке данных. В Go это требование является обязательным, поэто
му, если вы забудете добавить запятую, компилятор не позволит вам собрать или
запустить программу.
В нашем случае вместо того, чтобы ссылаться на имя поля (name), мы ссылаемся
на его тип. Именно по этой причине структура не может содержать несколько
безымянных полей одного типа — компилятор просто не поймет, к какому из
них вы обращаетесь.
ПРИМЕЧАНИЕ К именованному полю нельзя обращаться по типу, даже если оно
является единственным полем данного типа в структуре.
3.1.4. Структурные теги
Теги — это еще один важный аспект структур, который становится особенно
полезным по мере усложнения кода. Они представляют собой аннотации, позволяющие добавлять метаданные к полям. Их можно использовать по-разному, но
обычно они применяются для указания правил кодирования данных в различных
3.1. Представление данных с помощью пользовательских структур
99
форматах. Чаще всего вы будете сталкиваться с ними при преобразовании
данных в формат XML или JSON средствами стандартной библиотеки. Теги
заключаются в обратные кавычки и задаются в виде пар «ключ — значение»,
разделенных пробелами:
type Animal struct {
name string `help:"the name or type of any animal, as long as it is
a cat or dog"`
}
В этом примере мы добавили к полю структуры небольшой фрагмент справочных метаданных. При желании мы можем запретить объекту Animal издавать
звуки, если это не кошка или собака, и вернуть пользователю соответствующее
сообщение:
func (a Animal) speak() string {
switch a.name {
case "cat":
return "meow"
case "dog":
return "woof"
default:
if member, ok := reflect.TypeOf(a).FieldByName("name"); ok {
return fmt.Sprintf("Invalid animal name: %s",
member.Tag.Get("help"))
}
return "nondescript animal noise?"
}
}
Для получения доступа к этим данным используется пакет Go reflect, реализующий механизм рефлексии и предоставляющий мощный набор инструментов
для анализа структуры и поведения кода во время его выполнения. Если мы
обратимся к нужному полю структуры через FieldByName, то получим доступ
и к его тегам, например к тегу "help". С помощью пакета reflect и типа any
(или пустого интерфейса interface{}) можно принимать в поле любое значение
и определять его тип внутри методов.
СОВЕТ Пакет reflect (pkg.go.dev/reflect) содержит множество полезных инструментов для исследования пользовательских типов данных, в том числе для перебора
их полей и тегов. Поскольку на практике метод speak будет возвращать строку или
сообщение об ошибке, указанное ограничение можно было бы реализовать с помощью паники или раннего выхода, сопровождаемого пояснением для пользователя. Однако имейте в виду, что использование механизма рефлексии увеличивает
время выполнения кода и усложняет его, поэтому его не стоит применять без необходимости. Тем не менее он оказывается полезным при парсинге объекта JSON
с переменной структурой, например, когда одно и то же поле может содержать либо
строку, либо число.
100
Глава 3. Структуры, интерфейсы и обобщения
3.1.5. Преобразование данных в формат JSON
Как уже было сказано, структурные теги чаще всего используются для информирования кодировщика о том, как следует структурировать данные. Подробнее
о преобразовании данных в различные форматы мы поговорим в главах 9, 10
и 11. В листинге 3.4 показано, как можно задать желаемый формат представления
структуры и ее содержимого в виде JSON-файла.
Листинг 3.4. Представление данных в формате JSON
package main
import (
"fmt"
"encoding/json"
)
type Animal struct
Name
ScientificName
Weight
}
Создает структуру Animal
{
string `json:"animal_name"`
string `json:"scientific_name"`
float32 `json:"animal_average_weight"`
Задает для каждого поля
тег, определяющий его
представление в формате JSON
Создает экземпляр
func main() {
структуры Animal
a := Animal{
Name:
"cat",
ScientificName: "Felis catus",
Weight:
10.5,
}
output, err := json.Marshal(a)
if err != nil {
panic("couldn't encode json")
}
}
fmt.Println(string(output))
Выводит закодированные в JSON
данные в виде читаемой строки
Выполнение этого кода дает следующий результат:
go run union.go
{"animal_name":"cat","scientific_name":"Felis catus",
"animal_average_weight":10.5}
Обратите внимание: мы изменили имена полей структуры Animal, чтобы они
начинались с заглавной буквы. Как вы помните, в Go регистр первой буквы
идентификатора определяет его доступность: имена, начинающиеся с прописной буквы, видимы за пределами текущего пакета, а те, которые начинаются со
строчной, — нет. В данном случае, если бы имена полей были написаны с маленькой буквы, функции из пакета encoding/json не смогли бы получить к ним
доступ. При этом имя самой структуры может начинаться со строчной буквы,
поскольку она передается по значению.
3.2. Сравнение Go с языками программирования
101
Мы также добавили к полям теги с атрибутом json:"name". Если их не указать,
то имя поля будет использоваться как есть. Кроме того, при кодировании данных в JSON заглавные буквы в именах полей обычно не применяются, хотя это
и допустимо. Добавлять теги не обязательно, но они позволяют более точно
управлять результатом кодирования.
Подобная схема использования тегов подходит как для XML, так и для других
форматов кодирования. Кроме того, теги часто применяются и для предоставления справочной информации, но в целом вы можете задействовать их в своем
приложении так, как считаете нужным. Если предполагается кодирование данных
сразу в нескольких форматах, крайне редко можно встретить одновременное
использование тегов JSON и XML у одного и того же поля. В разделе 3.2 мы
рассмотрим принятый в Go подход к структурам и то, как он соотносится с парадигмами объектно-ориентированного и функционального программирования.
3.2. Сравнение Go с функциональными
и объектно-ориентированными языками
программирования
Когда опытные разработчики впервые знакомятся с Go (или любым другим
новым языком), они обычно начинают с изучения его структуры и архитектурных решений в попытке понять, как с ним работать. Является ли он классическим объектно-ориентированным языком вроде C++ или Java? Или это чисто
функциональный язык, как Haskell или F#? А может быть, он находится где-то
посередине, как Python или Rust?
Go — это мультипарадигменный язык. Иначе говоря, он занимает промежуточную позицию. Языки такого рода не придерживаются какого-то одного подхода:
их разработчики заимствуют отдельные элементы из разных парадигм и языков,
стремясь создать универсальный и внутренне согласованный инструмент.
Например, язык Python нельзя считать строго объектно-ориентированным,
однако с ним можно работать как с ООП-языком и применять множество соответствующих приемов. В свою очередь, Rust не является чисто функциональным языком, но позволяет использовать соответствующий подход. То же самое
относится и к другим языкам подобного рода. Они хорошо подходят как для
императивного, так и для декларативного программирования и не навязывают
выбор между функциональным и объектно-ориентированным стилем.
ПРИМЕЧАНИЕ Многие языки являются мультипарадигменными. Помимо функционального и объектно-ориентированного программирования (ООП), существуют
и другие парадигмы, такие как логическое, распределенное и метапрограммирование.
В строгом смысле язык Go не является объектно-ориентированным. Пользователи C++ или Java могут заметить это по отсутствию явного наследования
102
Глава 3. Структуры, интерфейсы и обобщения
или привычного полиморфизма. Во многом дело в синтаксическом сахаре. Тем
не менее многие приемы ООП можно реализовать в Go с помощью структур
и интерфейсов. В листинге 3.5 показано, как выглядел бы пример со структурой
Animal в коде на языке Java.
Листинг 3.5. Наследование в Java
public class MyApp {
public static void main(String[] args) {
Animal a = new Animal();
a.speak();
Cat c = new Cat();
c.speak();
Dog d = new Dog();
d.speak();
}
}
Llama l = new Llama();
l.speak();
class Animal {
void speak() {
System.out.println("nondescript animal noise?");
}
}
class Cat extends Animal {
void speak() {
System.out.println("meow");
}
}
class Dog extends Animal {
void speak() {
System.out.println("woof");
}
}
class Llama extends Animal { }
Проблема. Допустим, нам близка парадигма объектно-ориентированного программирования с ее абстракциями и мы хотим применить похожий подход
при написании кода на Go.
Решение. Использовать комбинацию типов для представления данных, имеющих свойства, интерфейсы — для обеспечения соответствия типов данных
заданным требованиям, а методы — для связывания логики обработки данных
с созданными типами. Таким образом, мы можем реализовать уже знакомые
принципы ООП средствами Go.
3.2. Сравнение Go с языками программирования
103
Обсуждение. В нашем примере определен родительский класс или объект
Animal, чьи свойства наследуют и реализуют классы Cat и Dog. Этот логич-
ный и иерархичный способ организации объектов предоставляет механизмы
защиты от рассогласования данных и ошибок реализации. В данном случае
Java не требует, чтобы подкласс реализовывал все методы абстрактного
класса Animal. Этот суперкласс предусматривает метод по умолчанию, поэтому Llama возвращает не звук, издаваемый ламой, а стандартный звук,
заданный в классе Animal. В Go есть почти все необходимое для достижения
того же результата, однако его синтаксис проще и не имеет явной привязки
к академическим канонам объектно-ориентированного программирования.
В листинге 3.6 представлена та же идея, что и в листинге 3.5, но реализованная
на языке Go.
Листинг 3.6. Та же идея, реализованная средствами Go
package main
import (
"fmt"
)
type Animal interface {
speak()
}
type Cat struct {}
func (c Cat) speak() {
fmt.Println("meow")
}
Определяет сигнатуру метода
интерфейса Animal
Определяет структуры,
реализующие интерфейс Animal
Конструкторы возвращают
новые объекты
func NewCat() *Cat {
return &Cat{}
}
type Dog struct {}
func (d Dog) speak() {
fmt.Println("woof")
}
Реализации обязательного
метода speak()
func NewDog() *Dog {
return &Dog{}
}
type Llama struct {}
func NewLlama() *Llama {
return &Llama{}
}
Конструкторы возвращают
новые объекты
104
Глава 3. Структуры, интерфейсы и обобщения
func main() {
var a Animal
c := NewCat()
a = c
a.speak()
d := NewDog()
a = d
a.speak()
}
l := NewLlama()
a = l
Попытка выполнения этого кода завершится ошибкой компиляции:
./animal.go:49:4: cannot use l (type *Llama) as type Animal in assignment:
*Llama does not implement Animal (missing speak method)
Причина в том, что не каждая из объявленных структур реализует интерфейс
Animal. Чтобы код заработал, нужно, чтобы структура Llama реализовала метод
speak():
type Llama struct {}
func (l Llama) speak() {
fmt.Println("nondescript animal noise?")
}
Хотя это может показаться ограничением, на деле это побуждает нас задуматься о философии приложения. Например, если лама (Llama) не издает звуков,
можно ли считать ее животным (Animal)? А если можно, то значит ли это, что
все животные обязаны издавать звуки? Вы, вероятно, уже знакомы с классическим примером этой логики, породившим термин «утиная типизация» (duck
typing): «Если нечто ходит как утка и крякает как утка, значит, это она и есть».
Подобные вопросы помогают лучше продумать логику приложения и делают
интерфейсы более интуитивно понятными. Некоторые механизмы поведения по
умолчанию, свойственные суперклассам, тоже можно реализовать в Go, только
они выглядят немного иначе. В объектно-ориентированных языках подобные
интерфейсы часто реализуются в виде абстрактных классов, и можно сказать,
что в Go они работают схожим образом.
В приведенном выше примере мы создаем интерфейс с переменной a и присваиваем ей разные типы, которые должны реализовывать сигнатуру Animal.
Каждый раз при присваивании переменной a нового значения выполняется
проверка соответствия типа требованиям интерфейса. Как только мы добавляем
типу Llama метод speak(), он начинает удовлетворять этим требованиям, и программа выполняется без ошибок.
На рис. 3.1 схематично показано тонкое философское различие между прямым
наследованием и удовлетворением требований интерфейса.
3.2. Сравнение Go с языками программирования
105
Рис. 3.1. Слева: наследование в языках вроде Java, где Poodle является
подклассом Dog, а Dog — подклассом Animal. Справа: модель Go, в которой связь
между независимыми типами определяется через интерфейс Animal
Интерфейс в Go представляет собой обобщенный тип, который задает набор
сигнатур методов. Хотя сам интерфейс не содержит ни данных, ни реализаций
методов (в отличие от родительских классов в объектно-ориентированных языках), он определяет, какие методы должны быть доступны структурам и другим
интерфейсам, реализующим соответствующую сигнатуру.
Как показано в примере выше, для возврата интерфейса, а иногда и задания
конкретных значений часто применяется инициализатор в стиле New(). Такой
подход удобен, когда вы создаете модуль с определенными свойствами или
полями, доступ к которым нужно контролировать особенно строго, например
через ограничение их видимости.
На протяжении большей части своего существования Go предоставлял довольно ограниченный набор средств для обобщенного программирования. Хотя
часть команды Go и многие пользователи считали, что этого вполне достаточно, в версию 1.18 была официально добавлена поддержка обобщенного кода
с параметризованными типами (мы поговорим об этом подробнее чуть позже).
Хотя Go не является объектно-ориентированным языком в классическом понимании, он позволяет использовать те же парадигмы, просто в менее явной
и менее жесткой форме. В Go нет ключевых слов extends или implements, так как
соответствующие им операции выполняются неявно. Тип либо соответствует
требованиям интерфейса, либо нет. В последнем случае компилятор выдает
ошибку. Именно это произошло в примере, в котором тип Llama не реализовал
метод speak(). При этом ничто не мешает вам расширять интерфейсы, добавляя
в них новые методы (например, woof() — в Dog, meow() — в Cat и т. д.).
В этом разделе был показан пример того, как Go может работать в рамках тех же
ментальных моделей, что и объектно-ориентированный язык. Вместе с тем благодаря своей мультипарадигменной природе Go может использоваться и в стиле,
напоминающем функциональное программирование. Функциональные языки,
как правило, предъявляют более строгие требования к структуре кода — во многом
106
Глава 3. Структуры, интерфейсы и обобщения
потому, что они традиционно развивались в академической среде и тесно связаны
с математикой. Такие концепции, как чистые функции и идемпотентность, направлены на предотвращение влияния внешних данных на результат вычислений. Эти ограничения нередко бывают оправданны, однако если язык допускает
возможность их обхода, то в глазах убежденных сторонников функционального
подхода он перестает считаться «по-настоящему» функциональным.
Go не является функциональным языком, но, как и в случае с ООП, он допускает
использование этого стиля программирования. До появления обобщений в Go 1.18
можно было утверждать, что это не так, поскольку, хотя функции и являются
объектами первого класса (то есть их можно передавать, возвращать и использовать как значения), язык не допускал передачу нетипизированных итераторов
в качестве аргументов функции. В результате нельзя было писать обобщенный
код, выполняющий операции с последовательностями для произвольных типов.
Тем не менее идиоматический стиль программирования на Go редко соответствует эстетике функционального программирования. Хотя работать с Go в подобном стиле вполне возможно, он изначально создавался не для этого. Модель
конкурентности Go была разработана с акцентом на передачу состояния между
различными компонентами программы. Как и в случае с ООП, из функциональной парадигмы можно заимствовать отдельные приемы, однако, если пытаться
использовать Go как строго объектно-ориентированный или функциональный
язык, некоторые подходы часто будут противоречить идиоматическим решениям
или попросту не сработают.
3.3. Расширение функциональности
с помощью интерфейсов
В предыдущем разделе было показано, как с помощью интерфейсов можно задать
допустимые сигнатуры методов для одного или нескольких пользовательских
типов. Однако на этом возможности интерфейсов не заканчиваются. В текущем
разделе вы узнаете, как с их помощью накладывать ограничения на структуры
данных. Такой подход позволяет создавать своего рода «контракты», которым
должны соответствовать разные типы данных, имеющие общее поведение.
Начнем с того, что возможность реализации интерфейсов доступна не только
пользовательским типам (вроде Cat, Dog и Llama из предыдущих примеров)
и структурам. Интерфейсы позволяют создать некое подобие обобщенной логики
даже для встроенных типов.
Проблема. Наш код должен гарантировать, что определенные структуры действительно соответствуют шаблонам интерфейса, то есть содержат нужные
атрибуты и/или функции в своем определении.
Решение. Использовать интерфейсы, которые задают такие ограничения
и предотвращают компиляцию кода, если тип не удовлетворяет требованиям
интерфейса.
3.3. Расширение функциональности с помощью интерфейсов
107
В листинге 3.7 мы определяем интерфейс Shuffleable и реализуем его отдельно
для строк и срезов других типов данных.
Листинг 3.7. И
спользование именованных типов для обеспечения
корректной реализации методов
package main
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"time"
)
type Shuffleable interface {
contents() string
shuffle()
}
type shuffleString string
Интерфейс, определяющий то,
что можно перемешивать
Новый тип на основе строки
func (s *shuffleString) shuffle() {
tmp := strings.Split(string(*s), "")
rand.Shuffle(len(tmp), func(i, j int) {
tmp[i], tmp[j] = tmp[j], tmp[i]
})
*s = shuffleString(strings.Join(tmp, ""))
}
func (s *shuffleString) contents() string {
return string(*s)
}
func NewShuffleString(init string) *shuffleString {
var s shuffleString = shuffleString(init)
return &s
}
Новый тип
на основе среза
type shuffleSlice []interface{}
func (sl shuffleSlice) contents() string {
data, _ := json.Marshal(sl)
return fmt.Sprintf("%v", string(data))
}
func (sl shuffleSlice) shuffle() {
rand.Shuffle(len(sl), func(i, j int) {
sl[i], sl[j] = sl[j], sl[i]
})
}
func init() {
rand.Seed(time.Now().UnixNano())
}
Реализации,
удовлетворяющие
требованиям
интерфейса Shuffleable
108
Глава 3. Структуры, интерфейсы и обобщения
func main() {
var myShuffle Shuffleable
Инициализация переменной Shuffleable
myShuffle = NewShuffleString("my name is inigo montoya")
myShuffle.shuffle()
fmt.Println(myShuffle.contents())
}
myShuffle = &shuffleSlice{1, 2, 3, 4, 5}
myShuffle.shuffle()
fmt.Println(myShuffle.contents())
Вызов методов
интерфейса
Если этот пример кажется вам несколько запутанным, не волнуйтесь, чуть позже
мы рассмотрим более удобные способы решения. А пока важно понять основную идею: у нас есть сигнатура для встроенных типов, которые представлены
как shuffleString и shuffleSlice. Эти типы реализуют интерфейс Shuffleable,
включая методы shuffle() и contents(). Таким образом, создав именованный
тип на основе встроенного (или даже внешнего пользовательского), мы можем
расширить его функциональность:
type specialString string
// новый именованный тип, основанный на string
type specialInt int8 | int16 | int32 | int64
// объединение нескольких
// целочисленных типов
Наша цель — заставить оба встроенных типа перемешивать свои элементы
и выводить результат. Зачем это нужно? Дело в том, что ни строки, ни срезы
изначально не реализуют интерфейс Shuffleable. Создание именованного типа
позволяет нам одинаково работать с обоими базовыми типами. Обратите внимание еще на одно интересное применение интерфейса в объявлении shuffleSlice:
type shuffleSlice []interface{}
Здесь мы объявляем срез интерфейсов, но инициализируем его целыми числами.
Почему? Дело в том, что такой срез называется пустым интерфейсом и, поскольку в нем не определено ни одного метода, он может применяться к любому типу.
В других языках interface{} иногда выступает в качестве аналога типа any. В Go
any — это просто псевдоним для interface{}. Эти обозначения взаимозаменяемы, однако в старом коде чаще встречается именно interface{}. Подробнее об
использовании подобных приемов мы поговорим в следующем разделе. А пока
имейте в виду, что если функция возвращает или принимает интерфейс, то либо
вызывающая сторона, либо сама функция должна будет определить фактический тип переданного или возвращенного значения (если только речь не идет
о тривиальной задаче вроде записи этого значения в лог). На практике следует
стремиться к тому, чтобы функция возвращала значение одного конкретного
типа, так как это упрощает код, снижает вероятность ошибок и уменьшает количество проводимых тестов.
3.4. Упрощение кода с помощью обобщений
109
Возможности интерфейсов этим не ограничиваются. Мы подробнее рассмотрим
их роль в тестировании и отладке в главе 5. А в следующем разделе вы узнаете,
как структуры и интерфейсы используются в рамках обобщенного программирования, поддержка которого была недавно добавлена в Go.
3.4. Упрощение кода с помощью обобщений
Как уже было сказано, тема обобщений долгое время оставалась спорной
в сообществе Go. Более десяти лет разработчики языка сознательно избегали
внедрения механизмов обобщенного программирования, несмотря на настойчивые просьбы части пользователей. В конце концов, обобщения — это
мощный инструмент, позволяющий избежать многократного дублирования
кода. Однако команда разработчиков настаивала на том, что язык Go уже
предоставляет все необходимые строительные блоки для реализации обобщенных решений.
Первоначальная позиция разработчиков Go по вопросу
внедрения обобщений
До выхода версии 1.18 их позиция была опубликована в разделе с часто задаваемыми вопросами (FAQ) на официальном сайте Go (https://mng.bz/5gWz): «Обобщения — это удобно, но за удобство приходится платить усложнением системы типов
и повышением издержек во время выполнения. Мы пока не нашли оптимального
решения, компенсирующего это усложнение, хотя продолжаем размышлять в этом
направлении. Между тем встроенные в Go типы map и slice, а также возможность
использовать пустой интерфейс для создания контейнеров (с явной распаковкой)
во многих случаях позволяют писать код, способный выполнять задачи, обычно
решаемые с помощью обобщений, — пусть и менее элегантно».
Как было показано ранее в главе, мы действительно можем использовать встроенные типы и интерфейсы в качестве замены обобщенным конструкциям. Однако
такая гибкость зачастую требует написания большого объема шаблонного кода
и жестко закодированной логики. Пустой интерфейс часто служит заменой
полноценной системе обобщений, но у него есть существенный недостаток — он
не накладывает никаких ограничений на типы значений, передаваемых в функции. По этой причине неаккуратное использование interface{} увеличивает
риск возникновения ошибок во время выполнения программы.
Как же наиболее эффективно использовать обобщения в Go? Для начала давайте немного реорганизуем наш пример из предыдущего раздела и вынесем
тип Animal в интерфейс, который сможет применяться к отдельным видам
животных. Напомним, что для удовлетворения ограничений интерфейса Animal
110
Глава 3. Структуры, интерфейсы и обобщения
каждой из предыдущих реализаций требовалась своя функция, привязанная
к конкретному типу:
type Cat struct {}
func (c Cat) speak() {
fmt.Println("meow")
}
type Dog struct {}
func (d Dog) speak() {
fmt.Println("woof")
}
Этот код оказывается избыточно сложным, если нам нужно, чтобы интерфейс
(а затем и функция) применялся ко множеству различных типов. В рамках обсу
ждения интерфейсов эти примеры хорошо работали. Однако в качестве демонстрации принципа работы обобщений рассмотрим другую реализацию (листинг 3.8).
Листинг 3.8. Объединенный тип и обобщенный параметр функции
package main
import (
"fmt"
)
type Cat struct {
wearsBow bool
}
type Dog struct {
canFetch bool
}
type AnimalType interface {
Cat | Dog
}
Отправка отформатированной
строки в стандартный поток вывода
type Animal[T AnimalType] struct {
value
T
AnimalNoise func() string
}
Интерфейс, представляющий
объединенный тип
Структура, принимающая
наш тип и функцию
func (a Animal[T]) Speak() {
fmt.Println(fmt.Sprintf("we got a %T", a.value))
fmt.Println(a.AnimalNoise())
}
func main() {
catAnimal := Animal[Cat]{
value:
Cat{},
AnimalNoise: func() string {
return "meow!"
},
}
Отправка отформатированной
строки в стандартный поток
вывода
3.4. Упрощение кода с помощью обобщений
111
dogAnimal := Animal[Dog]{
value:
Dog{},
AnimalNoise: func() string {
return "woof!"
},
}
}
catAnimal.Speak()
dogAnimal.Speak()
Код из листинга 3.8 показывает, как можно объединить несколько типов в одной
функции или методе (как в данном случае). Как уже упоминалось, примеры
с интерфейсами вполне подходят для наших целей, но здесь стоит обратить внимание на некоторые особенности синтаксиса и функциональности. На практике
создавать такую обобщенную конструкцию смысла нет, поскольку она не дает нам
никаких преимуществ. Чуть позже мы рассмотрим более реалистичный пример.
ПРИМЕЧАНИЕ Для обозначения типа мы используем идентификатор T, но вы
можете выбрать любой другой. Данная практика была заимствована из языков C++
и Java. Хотя T не является универсальным идентификатором, многие разработчики
предпочитают обозначать типы одиночными заглавными буквами.
Прежде всего стоит отметить, что Dog и Cat — это разные типы. Они не имеют общей иерархии, но входят в объединенный тип, заданный интерфейсом AnimalType.
В данном случае Animal инстанцируется с конкретным типом и получает метод
Speak, который вызывает переданную функцию обратного вызова.
Хотя в данном коде обобщения в целом не нужны, мы могли бы переписать
его, убрав метод из интерфейса Animal и передав типизированный параметр
AnimalType напрямую в метод Speak:
func Speak[T AnimalType](a Animal[T]) {
fmt.Println(fmt.Sprintf("we got a %T", a.value))
fmt.Println(a.AnimalNoise())
}
При этом тип можно указать явно или позволить компилятору вывести его
автоматически:
Speak(catAnimal)
Speak[Cat](catAnimal)
ПРИМЕЧАНИЕ Явное указание типа, как в случае [Cat], часто необязательно —
компилятор способен вывести его самостоятельно.
Еще один важный нюанс заключается в том, что в строке fmt.Println(fmt.Sprintf
("we got a %T", a.value)) мы задействуем механизм рефлексии. Формат %T работает
за счет механизма рефлексии и нужен здесь лишь для определения типа данных,
полученных методом. Возможно, вам захочется использовать внутри обобщенной
112
Глава 3. Структуры, интерфейсы и обобщения
функции оператор switch, чтобы реализовать разветвление логики в зависимости от типа, но такой подход считается плохой практикой. Теоретически можно
создать обобщенную функцию, которая выполняет практически любые операции
с любыми типами. Например, принимает строку и возвращает ее в верхнем регистре или получает число с плавающей точкой и возводит его в куб — и все это
в рамках одной функции. Однако такой подход приведет к путанице, поскольку
логика и наименование методов окажутся неочевидными и противоречивыми.
3.4.1. Использование функций с обобщениями
В целом обобщения больше подходят для работы с родственными типами данных,
чем с узкоспециализированными пользовательскими типами, которые мы использовали в предыдущих примерах. Там можно было обойтись и без них, однако
при работе с ассоциативными массивами и срезами преимущества обобщений
становятся особенно заметны, поскольку код для получения доступа и обработки
элементов остается одинаковым вне зависимости от базового типа коллекции.
Для наглядности мы попробуем реализовать нечто похожее на функцию filter
из JavaScript (листинг 3.9). Такие инструменты функционального программирования, как map, reduce и filter, используются во многих языках, и данный
пример продемонстрирует, как можно применить функциональный подход в Go.
Проблема. При создании функций, работающих с обобщенными типами, мы
хотим проверять элементы на соответствие определенным условиям, чтобы
воспроизвести функциональные операции фильтрации (filter) и отображения (map), характерные для других языков программирования.
Решение. Написать обобщенные функции, которые перебирают элементы
среза и проверяют каждый из них на соответствие некоему критерию с целью
их фильтрации или изменения.
Обсуждение. В приведенных далее примерах передаваемые функции спе
цифичны для своего типа, но мы можем обобщить их, расширив перечень
допустимых типов с помощью интерфейса и обеспечив соответствие всех
типов его требованиям.
Листинг 3.9. Реализация функции filter
package main
import (
"fmt"
"unicode"
)
func filter[T any](items []T, fx func(T) bool) []T {
var filtered []T
for _, v := range items {
if fx(v) {
Обобщенная функция
фильтрации, принимающая
срез произвольного типа
и функцию обратного вызова
3.4. Упрощение кода с помощью обобщений
}
}
113
filtered = append(filtered, v)
}
return filtered
func main() {
strings := []string{"My", "name", "is", "Inigo", "Montoya"}
strings = filter[string](strings, func(s string) bool {
return unicode.IsUpper(rune(s[0]))
})
}
fmt.Println(strings)
Срез, элементы
которого будут
отфильтрованы
с помощью
переданной функции
В приведенном коде представлена обобщенная функция filter, которая инстанцируется с указанием конкретного типа, принимает произвольный срез
и применяет к каждому его элементу заданную функцию обратного вызова.
В результате она возвращает новый срез, содержащий только строки, начинающиеся с заглавной буквы. Такой подход крайне прост, но при этом весьма
выразителен. Ранее мы могли бы реализовать его только для среза строк, но
благодаря обобщениям теперь у нас есть возможность применить функцию
фильтрации к срезам любого типа (листинг 3.10).
Листинг 3.10. Фильтрация среза целых чисел
func main() {
ints := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
ints = filter[int](ints, func(i int) bool {
if i % 3 == 0 {
return true
}
return false
})
}
fmt.Println(ints)
В листинге 3.10 мы фильтруем список целых чисел, оставляя лишь те, которые
делятся на 3 без остатка. Здесь хорошо видно, почему обобщения идеально
подходят для работы со срезами: пока мы оперируем только структурой среза,
внутри функции нет необходимости проводить сложную проверку типов. При
этом наш обобщенный код может работать и с определенными подмножествами
типов (листинг 3.11).
Листинг 3.11. Функция filter, применяемая к конкретным типам
type Numeric interface {
int8 | int16 | int32 | int64 | float32 | float64
}
114
Глава 3. Структуры, интерфейсы и обобщения
func filterPositive[T Numeric](items []T) []T {
var filtered []T
for _, v := range items {
if v > 0 {
filtered = append(filtered, v)
}
}
return filtered
}
func main() {
ints := []int8{-4, -3, -2, -1, 0, 1, 2, 3, 4}
ints = filterPositive[int8](ints)
fmt.Println(ints)
}
floats := []float32{-4.5, -3.5, -2.5, -1.5, 0.5, 1.5, 2.5, 3.5, 4.5}
floats = filterPositive[float32](floats)
fmt.Println(floats)
В этом коде мы ограничиваем тип с именем Numeric и определяем допустимые
типы для создания экземпляра функции filterPositive. Срез любого из этих
типов может быть передан функции в качестве аргумента. Такой подход предпочтительнее, чем дублирование кода для разных типов или использование []
interface{} и типа any.
ПРИМЕЧАНИЕ Обобщенные функции в Go не позволяют обращаться к отдельным
полям даже в тех случаях, когда эти поля являются общими для всех допустимых
типов. Возможно, в будущем это ограничение будет снято, но пока имейте в виду,
что, если вам необходимо работать с внутренними полями или методами, обобщения
могут оказаться не лучшим выбором.
3.4.2. Использование ограничений и аппроксимации типов
В предыдущем примере наша функция принимала значения типа Numeric. Если
мы передадим ей любое значение, соответствующее заданному ограничению, она
успешно его обработает. Однако функция работает только с этими конкретными
типами. Но что, если мы определили собственный тип?
type Smallint int8
Посмотрите, что произойдет при попытке использовать такой тип напрямую
(листинг 3.12).
Листинг 3.12. Точные типы
package main
import (
"fmt"
)
3.4. Упрощение кода с помощью обобщений
115
type Numeric interface {
int8 | int16 | int32 | int64 | float32 | float64
}
type Smallint int8
func doubler[T Numeric](value T) T {
return value * 2
}
func main() {
var four Smallint = 4
fmt.Println(doubler(four))
}
Мы получим сообщение об ошибке:
$ Smallint does not satisfy Numeric (possibly missing ~ for int8 in Numeric)
Но ведь SmallInt относится к int8! Казалось бы, все должно бы работать.
Однако для этого требуется точное совпадение типов. Даже если базовым
является int8, пользовательский тип все равно считается самостоятельным
и не соответствующим заданному ограничению. Чтобы разрешить использование как точных, так и производных типов, перед именем типа нужно поставить
тильду (~):
type Numeric interface {
~int8 | int16 | int32 | int64 | float32 | float64
}
Если мы хотим, чтобы наша логика охватывала, например, int16, int32 и int64,
тильду пришлось бы ставить перед каждым типом. Данный синтаксис сообщает компилятору о том, что ему следует учитывать не только int8, но и все
типы, производные от него. Это и называется аппроксимацией типов (type
approximation).
Явное перечисление и обработка всех возможных вариантов ограничений типов
(как в интерфейсе Numeric) быстро становятся громоздкими и в большинстве
случаев избыточными. Как часто вам требуется функция, принимающая типы
int8, int16 и int64, но не int32? Как мы уже видели, почти всегда нам нужно,
чтобы ограничения совпадали по базовому типу. Здесь может пригодиться
пакет constraints. Он содержит наиболее часто используемые ограничения,
объединенные в различные полезные комбинации.
Например, мы можем использовать ограничение constraints.Signed для
охвата всех знаковых целых чисел, constraints.Unsigned — для их беззнаковых
разновидностей и constraints.Integer — для всех базовых целочисленных
типов и производных от них. Ограничение constraints.Ordered охватывает
еще больше типов (включая строки), но в нашем случае речь идет только
о числах, поэтому вместо Numeric мы используем Integer, чтобы сделать код
лаконичнее (листинг 3.13).
116
Глава 3. Структуры, интерфейсы и обобщения
Листинг 3.13. И
спользование пакета constraints для задания
допустимых типов
import "pkg.go.dev/golang.org/x/exp/constraints"
type Smallint int8
func doubler[T constraints.Integer](value T) T {
return value * T(2)
}
func main() {
var four Smallint = 4
fmt.Println(doubler(four))
}
СОВЕТ Ограничения входят в состав экспериментального пакета Go. Чтобы использовать их в своем проекте, выполните такую команду: go get pkg.go.dev/golang.org/x/
exp/constraints.
В этой главе вы увидели, насколько мощной и гибкой является система структур
в Go, и познакомились со связанными с ней способами обобщения и расширения функциональности кода. Понимание того, какие данные можно связать со
структурой, помогает спроектировать приложение так, чтобы его поведение
было интуитивно понятным для другого разработчика. Интерфейсы позволяют
задать ограничения для уникальных типов данных, обладающих общей функциональностью. А обобщения дают возможность сократить или вовсе избежать
дублирования кода при необходимости реализовать один и тот же общий алгоритм для разных типов данных.
Резюме
Структуры и интерфейсы являются фундаментальными элементами для
представления данных в языке Go.
Мы можем расширять структуры, добавляя в них функции в качестве полей
или членов. Можно пойти еще дальше и добавить методы, которые принимают переменную заданного типа в качестве получателя. Это позволяет
обращаться к другим данным внутри структуры и изменять их, используя
в качестве получателя указатель на структуру.
Go не является ни объектно-ориентированным, ни функциональным языком
программирования, однако при проектировании приложений мы можем
применять некоторые подходы из обеих парадигм. В этом нам могут помочь
структуры, интерфейсы и обобщения.
Обобщения появились в Go относительно недавно. Хотя они требуются
далеко не всегда, при работе с несколькими схожими типами они позволяют
избежать дублирования кода и делают его более модульным.
Часть II
Создание надежных
приложений
Во второй части книги мы перейдем от теории к практике и приступим к созданию Go-приложений, способных выдерживать реальные нагрузки, характерные
для рабочей среды.
В главе 4 мы рассмотрим способы обработки ошибок. В Go используется довольно своеобразный подход к перехвату и передаче ошибок вверх по цепочке
вызовов, поэтому мы обсудим приемы, которые помогут вам освоиться с этой
системой без усложнения кода.
В главе 5 погрузимся в тему конкурентности — одну из главных сильных сторон
языка Go. Мы поговорим о горутинах, модели конкурентности Go, передаче
сообщений и асинхронном взаимодействии.
Глава 6 посвящена таким важным (хотя часто недооцениваемым) аспектам
разработки ПО, как обеспечение согласованности кода, тестирование и оценка
производительности. Все это помогает разработчику убедиться в корректной
работе созданного им продукта.
В главе 7 вы научитесь работать с файловой системой и налаживать сетевое
взаимодействие средствами Go. Мы обсудим способы работы с протоколами
TCP и UDP на низком уровне, а также различные подходы к взаимодействию
с файловой системой.
Эта часть завершает формирование фундамента, необходимого для создания
по-настоящему надежных приложений, чем мы займемся в части III.
4
Обработка ошибок и паник
В этой главе
3 Идиоматические приемы обработки ошибок в Go.
3 Возврат информативных сообщений вместе с ошибками.
3 Создание пользовательских типов ошибок в стиле Go.
3 Генерация паник и работа с ними.
3 Преобразование паник в ошибки.
3 Работа с паниками внутри горутин.
Как метко заметил Роберт Бернс в своем стихотворении «К полевой мыши»,
«лучшие замыслы мышей и людей часто идут наперекосяк». Даже самые продуманные планы могут не реализоваться, и, пожалуй, именно разработчики
ПО знают это лучше других. Данная глава посвящена тому, как справляться
с ситуациями, когда что-то идет не так, как задумывалось.
В языке Go различают два типа сбоев, возникающих во время выполнения
программы: ошибки и паники. Ошибка указывает на невозможность успешного
выполнения конкретной операции. Паника сигнализирует о более серьезной
проблеме — как правило, о программной ошибке или неожиданном состоянии
среды, которые требуют незамедлительного завершения работы программы.
В этой главе мы подробно рассмотрим обе категории сбоев.
4.1. Обработка ошибок
119
Начнем с ошибок. Кратко повторив идиоматические приемы их обработки
средствами Go, перейдем к обсуждению лучших практик. Ошибки могут информировать разработчика о возникшей проблеме и при правильном подходе
помогать восстановить работу приложения и двигаться дальше. Принятый в Go
метод обработки ошибок отличается от техник, используемых в таких языках,
как Python, Java и Ruby. Разбирать каждую ошибку по отдельности поначалу
может показаться утомительным, однако использование идиоматических прие
мов позволяет писать надежный код, изолирующий потенциальные сбои. Как
правило, в Go ошибки передаются вверх по стеку вызовов — к получателю, который обрабатывает их в одной или нескольких точках. Кому-то такой способ
может показаться неожиданным, но он позволяет централизовать обработку
и сосредоточить контроль в одной точке, что при вдумчивом применении оказывается весьма эффективным подходом.
Механизм паник в Go призван сигнализировать об аномальных ситуациях,
угрожающих целостности программы. На практике паники используются
эпизодически, чаще всего реактивно — для предотвращения некорректного изменения состояния или перехода к ветке кода, которая не должна выполняться.
В этой главе мы обсудим эффективные способы использования механизма паник и восстановления после них. Вы узнаете, когда уместно вызывать панику,
как (и нужно ли) после нее восстанавливаться и чем подход Go отличается от
аналогичных механизмов в других языках.
Хотя принятый в Go способ обработки ошибок порой критикуют за повторяемость, мы покажем, почему именно такой подход способствует созданию
качественного и удобочитаемого кода. Эта система не позволяет разработчикам
забывать об ошибках, тем самым помогая им оставаться бдительными. Уделяя
особое внимание обработке ошибок, Go учит нас программировать с осторожностью независимо от того, насколько высоко мы оцениваем собственные навыки.
4.1. Обработка ошибок
Одной из особенностей Go, которая часто сбивает с толку новичков, является его
подход к обработке ошибок. Во многих популярных языках, таких как Python,
Java и JavaScript, это делается путем «выбрасывания» и перехвата специальных
объектов исключений. В других языках, например в C, для обработки ошибок
используется возвращаемое значение, а изменяемые данные передаются через
указатели.
Создатели Go отказались от механизма исключений с конструкциями вида
try/catch и воспользовались возможностью возврата из функции нескольких
значений. Наиболее распространенный способ передачи ошибки предполагает
ее возврат в качестве последнего значения в списке возвращаемых значений,
как показано в листинге 4.1.
120
Глава 4. Обработка ошибок и паник
Листинг 4.1. Возврат ошибки
package main
import (
"errors"
"strings"
)
Пакет errors используется
для создания пользовательских
ошибок с помощью функции New
Функция Concat возвращает
строку и ошибку
func Concat(parts ...string) (string, error) {
if len(parts) == 0 {
return "", errors.New("No strings supplied")
Возвращает ошибку, если
ничего не было передано
}
return strings.Join(parts, " "), nil
Возвращает новую строку и значение nil
}
(говорящее об отсутствии ошибки)
Функция Concat принимает произвольное количество строк, конкатенирует
их, разделяя пробелом, и возвращает итоговую строку. Однако, если аргументы
не переданы, функция возвращает пустую строку и ошибку.
Сигнатура функции Concat демонстрирует типичный шаблон возврата ошибок.
В соответствии с общепринятой практикой ошибка всегда возвращается в качестве последнего значения. Как правило, это связано с тем, что соответствующий
код был написан до того, как эта практика утвердилась в сообществе Go.
Аргументы переменной длины
Как видно из примера с функцией Concat, Go поддерживает списки аргументов переменной длины. Префикс ... перед типом означает, что функция может принимать
любое количество аргументов данного типа. Такие аргументы автоматически преобразуются в срез. В листинге 4.1 параметр parts интерпретируется как []string.
Поскольку ошибка всегда должна быть последним возвращаемым значением,
обработка ошибок в Go выполняется по определенному шаблону. Функция,
возвращающая ошибку, оборачивается в условную конструкцию if/else, в которой значение ошибки сравнивается с nil, и в случае неравенства выполняется
ее обработка. В листинге 4.2 показана простая программа, которая принимает
список аргументов из командной строки и конкатенирует их.
Обратите внимание, что функция в этом примере может возвратить пустую
строку, то есть "". Именно поэтому важно обрабатывать ошибки, а не пропускать
их, используя пустой идентификатор. Поскольку "" — это допустимая строка,
без явной обработки ошибок невозможно понять, что произошло: возникла ли
ошибка (аргументы не переданы), или были переданы корректные входные
данные (один аргумент равен ""), так как в обоих случаях будет возвращено
одно и то же значение.
4.1. Обработка ошибок
121
Листинг 4.2. Обработка ошибки
Использует только аргументы
после Args[0], чтобы пропустить
Вызывает функцию Concat()
func main() {
имя программы
для возвращения результата
args := os.Args[1:]
и/или возможной ошибки
if result, err := Concat(args...); err != nil {
fmt.Printf("Error: %s\n", err)
} else {
fmt.Printf("Concatenated string: '%s'\n", result)
Выводит результат, если
}
ошибка не возникла
}
Запустив этот код, вы получите следующий результат:
$ go run error_example.go hello world
Concatenated string: 'hello world'
Если вы не передадите ни одного аргумента, то увидите сообщение об ошибке:
$ go run error_example.go
Error: No strings supplied
Листинг 4.2 показывает, как использовать уже написанную функцию Concat,
и иллюстрирует распространенную идиому Go. Как вы помните, Go позволяет
добавить выражение присваивания перед условием в конструкции if. Это сделано для того, чтобы можно был подготовить значения для проверки и сохранить
их в области видимости блока if/else.
СОВЕТ Go позволяет проигнорировать возвращаемые значения с помощью пустого
идентификатора (символа нижнего подчеркивания), например так: result, _:= Concat().
Однако даже на этапе разработки игнорировать ошибки не рекомендуется. Старайтесь
обрабатывать их всегда.
В листинге 4.2 демонстрируется базовая схема обработки ошибок. Сначала
вызывается функция Concat(args...), при этом массив args расширяется так,
будто мы вызвали Concat(arg[0], arg[1], ...). Возвращаемые значения присваиваются переменным result и err. Затем в той же строке кода выполняется
проверка: если err не равна nil, значит, возникла ошибка, и на экран выводится
соответствующее сообщение. Оба возвращенных значения находятся в области
видимости конструкции if/else и недоступны за ее пределами, поэтому, если
вы собираетесь использовать результат позднее, лучше вынести вызов функции
за пределы этого блока:
result, err := Concat(args...)
if err != nil {
// обработка ошибки
}
// выполнение некоторой операции над результатом
122
Глава 4. Обработка ошибок и паник
Такой подход мы уже не раз применяли в предыдущих главах, однако бывают
случаи, когда имеет смысл выполнить всю необходимую обработку внутри отдельной области видимости, как в листинге 4.2. Этот прием наглядно показывает, почему использование конструкции if с присваиванием является удачным
решением: оно способствует грамотному управлению памятью и одновременно
помогает предотвратить проблемы с отладкой.
В листинге 4.1 вы познакомились с функцией Concat, а в листинге 4.2 — со способом
ее использования. Теперь стоит обратить внимание еще на одну важную технику.
4.1.1. Рекомендации по работе со значением nil
Значения nil способны доставить немало хлопот: они нередко становятся причиной появления ошибок, а разработчикам приходится постоянно реализовывать
проверки, чтобы обезопасить свой код.
В некоторых случаях значение nil имеет четко определенный смысл. Например, в предыдущем примере значение ошибки, равное nil, означало, что «при
выполнении этой функции ошибок не возникло». Однако во многих других
ситуациях смысл nil оказывается неочевидным. В наихудших сценариях, когда
разработчику не хочется возвращать «настоящее» значение, nil используется
в качестве заглушки. Именно в таких ситуациях может пригодиться описанный
далее прием.
Проблема. Возврат значений nil наряду с ошибками не всегда является хорошей практикой. Это усложняет работу с вашей библиотекой, дает минимум
полезной информации и затрудняет восстановление после сбоя.
Решение. По возможности старайтесь возвращать не только ошибку, но и некое полезное значение.
Обсуждение. Такой подход был продемонстрирован в рассмотренном ранее
примере с функцией Concat. Давайте вернемся к нему, сосредоточившись на
строке, в которой возвращается ошибка (листинг 4.3).
Листинг 4.3. Возврат полезных данных вместе с ошибкой
func Concat(parts ...string) (string, error) {
if len(parts) == 0 {
return "", errors.New("No strings supplied")
}
return strings.Join(parts, " "), nil
}
Возвращает пустую
строку и ошибку
В данном случае при возникновении ошибки возвращаются одновременно пустая строка и сообщение об ошибке. Опытный пользователь библиотеки сможет
использовать приведенный выше код, не реализуя дополнительную логику для
обработки ошибок. В нашем примере с функцией Concat возвращение пустой
4.1. Обработка ошибок
123
строки вполне логично: если данных для объединения нет, а по контракту функция должна возвращать строку, то пустая строка является вполне ожидаемым
результатом. Теперь вы видите, как игнорирование ошибки может привести
к двусмысленности.
Этот пример тоже подчеркивает важность обработки ошибок. В отличие от таких
языков, как Rust, в которых принято возвращать либо результат, либо ошибку,
в Go типы возвращаемых значений жестко зафиксированы. Если только не возникает паника, функция Concat() всегда возвращает строку в качестве первого
значения, даже в случае возникновения ошибки. Поэтому проверка на пустую
строку менее полезна, чем проверка того, вернула ли функция ошибку.
СОВЕТ При создании ошибок в Go удобно использовать две вспомогательные
функции: errors.New из пакета errors — для генерации простых сообщений об
ошибках и fmt.Errorf из пакета fmt — для создания сообщений об ошибках с форматированием, как в случае с Printf и Sprintf. Эти функции широко применяются
Go-разработчиками.
Реализуя функцию Concat таким образом, вы упрощаете жизнь пользователям
вашей библиотеки. Однако, как уже упоминалось, ничто не мешает им проигнорировать возвращенную ошибку, как показано в листинге 4.4.
Листинг 4.4. Игнорирование возвращаемых ошибок
func main() {
args := os.Args[1:]
Передает пакет значений
result, _ := Concat(args...)
fmt.Printf("Concatenated string: '%s'\n", result)
}
В данном случае мы не оборачиваем вызов функции Concat в конструкцию if
для обработки ошибки. Поскольку функция Concat реализована таким образом,
что даже при возникновении ошибки возвращает пригодное для использования
значение, а наличие или отсутствие ошибки не влияет на выполнение текущей
задачи, дополнительная проверка оказывается излишней. Вместо того чтобы оборачивать код в конструкцию if/else, мы просто игнорируем ошибку и работаем
с результатом как со строкой (result). Если функция возвращает ошибку, она
должна нести определенный смысл. В стандартной библиотеке Go есть функции,
которые не возвращают ошибки явно. Например, strings.Join и strings.Split
все равно возвращают значение (строку string и срез строк string соответственно), даже если происходит что-то похожее на ошибку.
Когда контекст требует от вас установить факт возникновения ошибки и соответствующим образом на нее отреагировать, эта схема работает хорошо. Вы попрежнему можете получить значение ошибки и выяснить, что именно пошло
не так и почему. Таким образом, подход, при котором функция возвращает
124
Глава 4. Обработка ошибок и паник
и ошибку, и полезное значение, помогает пользователям вашей библиотеки
писать код, наилучшим образом решающий их задачу.
Однако возвращать ненулевые значения при возникновении ошибки не всегда
желательно, а порой и вовсе невозможно. Если в случае сбоя не удается сформировать никаких полезных данных, предпочтительнее вернуть значение nil.
Общее правило таково: если функция способна вернуть полезный результат при
возникновении ошибки, она должна это сделать. Но если возвращать нечего,
следует вернуть nil.
Наконец, важно сделать поведение вашего кода понятным для других раз
работчиков. В языке Go особое внимание уделяется добавлению лаконичных,
но содержательных комментариев к каждой публичной функции. Документация
к функции Concat должна выглядеть примерно так, как показано в листинге 4.5.
Листинг 4.5. О
писание значений, возвращаемых при возникновении
ошибки
// Функция Concat объединяет несколько строк, разделяя их пробелами.
// Она возвращает пустую строку и ошибку,
// если ей не было передано ни одной строки.
func Concat(parts ...string) (string, error) {
//…
}
Этот короткий комментарий соответствует принятому в Go стилю комментирования и четко объясняет, что происходит при нормальной работе функции
и что — при ошибке. Если вы привыкли работать с Java или Python, система обработки ошибок в Go поначалу может показаться довольно примитивной. Здесь
не применяются блоки try/catch, которые инкапсулируют большие фрагменты
кода и перехватывают ошибки. Вместо этого используется конструкция if/else.
Большинство возвращаемых ошибок имеют тип error, и новички в Go порой
жалуются на кажущуюся громоздкой такую систему обработки.
Однако с опытом их недовольство, как правило, исчезает. Подход Go обеспечивает детализированное представление ошибок, благодаря которому вы видите
каждую ошибку в контексте ее возникновения. При этом наблюдается интересная
закономерность: в то время как в Java и Python разработчики склонны создавать
отдельные типы исключений, в Go это делается довольно редко. Ошибка — это
просто ошибка, а всю дополнительную информацию о произошедшем можно
включить в метаданные, возвращаемые наряду с ней.
Вероятно, это связано с тем, что большинство стандартных библиотек Go используют тип error как есть. По мнению Go-разработчиков, у большинства
ошибок нет особых атрибутов, требующих создания специального типа. Поэтому
возврат универсальной ошибки является самым простым решением. Например, в случае с функцией Concat нет особого смысла создавать тип ConcatError.
Гораздо проще воспользоваться встроенным пакетом errors и сформировать
новое сообщение об ошибке.
4.1. Обработка ошибок
125
Такой простой подход к обработке ошибок зачастую оказывается наиболее
практичным. Тем не менее в некоторых случаях создание и использование
специфических типов ошибок может оказаться целесообразным.
4.1.2. Пользовательские типы ошибок
Тип error в Go представляет собой интерфейс, который выглядит так, как показано в листинге 4.6.
Листинг 4.6. Интерфейс error
type error interface {
Error() string
}
Любой тип, реализующий метод Error и возвращающий строку, удовлетворяет
требованиям этого интерфейса. В большинстве случаев для работы с ошибками
Go-разработчикам достаточно этого универсального типа. Однако иногда вам
может потребоваться, чтобы ошибки содержали дополнительную информацию.
В таких случаях можно создать собственный тип ошибки (или несколько), чтобы
вызывающий код мог обрабатывать их по-разному.
Проблема. Ваша функция возвращает ошибку. В зависимости от ее типа
пользователи этой функции могут реализовать разную логику, учитывая
важные сведения о возникшей ошибке.
Решение. Создайте тип, реализующий интерфейс error, но обладающий дополнительной функциональностью.
Обсуждение. Представьте, что вы пишете парсер файлов, который должен
возвращать ошибку при обнаружении синтаксической ошибки. Помимо
текстового сообщения, обычно полезно указать, где именно в файле возникло
несоответствие. Реализация такого типа ошибки показана в листинге 4.7.
Листинг 4.7. Реализация типа ParseError
Сообщение об ошибке без указания
type ParseError struct {
места ее возникновения
Message
string
Line, Char int
Информация о месте
}
возникновения ошибки
func (p *ParseError) Error() string {
format := "%s on Line %d, Char %d"
return fmt.Sprintf(format, p.Message, p.Line, p.Char)
}
Реализует
интерфейс Error
Структура ParseError содержит три поля: Message, Line и Char. Функция Error
форматирует эти данные в единую строку. Теперь представьте, что вы хотите
вернуться к месту возникновения ошибки и отобразить всю строку кода, выделив
проблемный символ. Структура ParseError позволяет это сделать.
126
Глава 4. Обработка ошибок и паник
Такой прием полезен в тех случаях, когда вам требуется вернуть дополнительную
информацию. Но что, если вам нужно, чтобы одна и та же функция возвращала
ошибки разных типов?
4.1.3. Переменные ошибок
Иногда функция, выполняющая сложную задачу, завершается сбоем. На то
есть несколько причин. В предыдущем разделе был показан один из способов
реализации интерфейса error, однако он может оказаться излишне громоздким, если вам не нужно сопровождать дополнительной информацией каждую
ошибку. Давайте рассмотрим другой идиоматический способ обработки ошибок
средствами Go.
Проблема. Одна и та же сложная функция может столкнуться с разными
ошибками. Необходимо предоставить пользователю информацию о типе
возникшей ошибки, чтобы приложение могло корректно обработать каждый
конкретный случай.
Решение. В Go (в отличие от некоторых других языков) считается хорошей практикой объявлять ошибки на уровне пакета в виде переменных,
которые затем можно возвращать при возникновении соответствующих
ошибок. Такие ошибки называют сигнальными (sentinel errors) — они имеют
значение только в заданном контексте. Хорошим примером служит пакет
io из стандартной библиотеки Go, в котором определены ошибки io.EOF
и io.ErrNoProgress.
Обсуждение. Прежде чем углубляться в детали работы с переменными
ошибок, давайте рассмотрим саму проблему и простой способ ее решения,
позволяющий сделать описание наших ошибок более конкретным. Итак,
нам нужно научиться различать два разных типа ошибок. В листинге 4.8
показана небольшая программа, имитирующая отправку простого сообщения получателю.
Листинг 4.8. Обработка двух разных ошибок
package main
import (
"errors"
"fmt"
"math/rand"
"time"
)
const MAX_TIMEOUTS = 5
Экземпляр ошибки
тайм-аута
var ErrTimeout = errors.New("The request timed out")
var ErrRejected = errors.New("The request was rejected")
Экземпляр ошибки
отклонения запроса
4.1. Обработка ошибок
func init() {
rand.Seed(time.Now().UnixNano())
}
Инициализирует генератор
случайных чисел
func main() {
response, err := SendRequest("Hello")
if errors.Is(err, ErrTimeout) {
timeouts := 0
for err == errors.Is(err, ErrTimeout) {
timeouts++
fmt.Println("Timeout. Retrying.")
if timeouts == MAX_TIMEOUTS {
panic("too many timeouts!")
}
response, err = SendRequest("Hello")
}
}
}
127
Вызывает функцию-заглушку
SendRequest
Обрабатывает ошибку
тайм-аута, выполняя
повторные попытки
и вызывая панику
при превышении их лимита
if err != nil {
Обрабатывает все остальные
ошибки как сбои
fmt.Println(err)
} else {
Если ошибки нет,
fmt.Println(response)
выводит результат
}
Определяет функцию, которая
имитирует отправку сообщения
func SendRequest(req string) (string, error) {
switch rand.Intn(3) % 3 {
Генерирует случайный ответ
case 0:
return "Success", nil
Обрабатывает ошибку тайм-аута,
case 1:
выполняя повторные попытки
return "", ErrRejected
default:
return "", ErrTimeout
Вместо отправки сообщения
}
моделирует случайное поведение
}
В этом листинге демонстрируется использование переменных в качестве фиксированных значений ошибок. Данный код имитирует работу простой функции
отправки сообщения. Однако на деле функция SendRequest ничего не отправляет — она просто случайным образом генерирует один из трех вариантов ответа:
Success (успешный результат), ошибку ErrTimeout или ErrRejected.
ПРИМЕЧАНИЕ В методе init() вызывается функция rand.Seed() с уникальным
значением. В пакете main функция init() вызывается до main(), как в языке C.
Результат запуска приведенной выше программы может выглядеть примерно
так:
$ go run two_errors.go
Timeout. Retrying.
The request was rejected
128
Глава 4. Обработка ошибок и паник
Пара слов о случайности
Интересной особенностью листинга 4.8 является использование генератора случайных чисел. Такие генераторы требуют задания начального значения — зерна
(seed) — и при условии его уникальности выдают псевдослучайную последовательность. Любой генератор с фиксированным начальным значением детерминирован,
а значит, его результат не является по-настоящему случайным. В нашем примере
такой генератор вполне уместен, но он совершенно не годится для задач, связанных с безопасностью. Для таких целей следует использовать пакет crypto. Если
вы зададите фиксированное начальное значение, то сразу заметите отсутствие
случайности в результатах. Начиная с версии Go 1.20, зерно генерируется автоматически во время выполнения, поэтому в большинстве случаев необходимость
в его установке отпала.
Поскольку ответы генерируются случайным образом, мы можем получить
и успешный результат. Запустите программу несколько раз и понаблюдайте за
разными вариантами ее поведения (такой подход можно рассматривать как простую форму фаззинг-тестирования). Некоторые вызовы функции SendRequest
будут завершаться с ошибкой тайм-аута, а некоторые — с ошибкой отклонения
запроса. В случае тайм-аута мы выполняем повторные попытки, максимальное
количество которых определяется значением MAX_TIMEOUTS. Такие подходы нередко применяются в работе сетевых серверов.
Разработчики, использующие такие языки, как Java или Python, скорее всего,
реализовали бы ошибки ErrTimeout и ErrRejected в виде классов, а затем выбрасывали бы их экземпляры. Широко используемая в этих языках конструкция
try/catch специально предназначена для обработки информации, инкапсулированной в типах ошибок. Однако, как уже было сказано, в Go нет конструкции
try/catch. Аналогичную функциональность можно было бы реализовать путем
сопоставления типов (например, через оператор switch), но это противоречит
идиоматическому стилю программирования на Go. Более простой и эффективный способ заключается в том, чтобы создать ошибки в виде переменных на
уровне пакета и ссылаться на них.
Как показано в приведенном выше коде, работа с переменными ошибок сводится
к простой проверке на равенство. Если ошибка связана с тайм-аутом (timeout),
мы отправляем сообщение повторно, а если с отклонением запроса (rejection), —
прекращаем обработку. Как и прежде, возврат значения nil означает отсутствие
ошибок, и на этот случай у нас тоже предусмотрено соответствующее поведение.
При использовании такой схемы одни и те же переменные ошибок задействуются
многократно. Этот подход довольно эффективен благодаря тому, что ошибки
инстанцируются всего один раз. Кроме того, он является концептуально простым: пока вашей ошибке не требуются какие-либо особые свойства, вы можете
работать с ней как с обычной переменной.
4.2. Оборачивание ошибок
129
4.2. Оборачивание ошибок
В листинге 4.8 мы определили собственные типы ошибок, которые можно различать и по-разному обрабатывать в других частях кода. Это удобно, но у такого
подхода есть определенные ограничения: если вы захотите снабдить ошибку
дополнительной информацией (то есть аннотировать или обернуть ее), то проверить ее тип прежним способом уже не получится. Взгляните на листинг 4.9.
Листинг 4.9. Оборачивание ошибки
package main
import (
"errors"
"fmt"
"log"
)
var ErrTimeout = errors.New("The request timed out")
Объявление типа ошибки,
как в листинге 4.8
func SendRequest(req string) (string, error) {
return "", fmt.Errorf("we got an error: %w", ErrTimeout)
}
func main() {
if _, err := SendRequest("Hello "); err != nil {
if err == ErrTimeout {
log.Println("we got a timeout error")
} else {
log.Println("we got some other error")
}
}
}
Оборачивание
пользовательской
ошибки в аннотацию
Проверка на совпадение
с пользовательским или…
…любым другим
типом ошибки
В данном случае, несмотря на то что возникшая ошибка является экземпляром
нашего пользовательского типа ErrTimeout, при запуске кода сработает блок
else. Для определения истинного типа ошибки можно воспользоваться методом
errors.Unwrap:
if errors.Unwrap(err) == ErrTimeout {
log.Println("we got a timeout error")
} else {
log.Println("we got some other error")
}
Такой вызов вернет ожидаемый результат, распаковав внешнюю обертку и предоставив доступ к вложенной ошибке. Это удобно, если ошибка обернута лишь
один раз, однако на практике в цепочке вызовов может накопиться несколько
уровней вложенности. На такой случай в Go предусмотрен вспомогательный
метод, который выполняет рекурсивную распаковку и позволяет определить,
130
Глава 4. Обработка ошибок и паник
принадлежит ли ошибка к определенному типу. Используемую в нашем примере
проверку можно заменить на следующую:
if errors.Is(err, ErrTimeout) {
log.Println("we got a timeout error")
} else {
log.Println("we got some other error")
}
Теперь, даже если исходная ошибка была обернута три и более раза, результатом проверки условия для ErrTimeout будет true. На практике вместо ручной
распаковки цепочки ошибок лучше использовать функцию errors.Is(). Метод
Unwrap() может вернуть значение nil, обработка которого требует написания
лишнего кода. Этого можно избежать, если сразу выяснить, с какой именно
ошибкой мы имеем дело.
ПРИМЕЧАНИЕ Обратите внимание на спецификатор формата %w. Он означает wrap
и играет ключевую роль, позволяя методу Unwrap() извлечь исходную ошибку.
В целом ошибки предназначены для передачи информации о серьезных проблемах в приложении, его окружении или входных данных. Однако бывают
случаи, когда проблема настолько критична, что необходимо остановить работу
приложения или прерывать выполнение одного из внутренних процессов. Это
может быть связано с невозможностью дальнейшей обработки данных или
с риском возникновения некорректного состояния. Именно для таких случаев
в Go предусмотрен механизм паники.
4.3. Механизм паники
Помимо описанных выше способов обработки ошибок, в Go предусмотрен еще
один механизм, сигнализирующий о серьезных сбоях, — паника (panic). Как
следует из названия, паника указывает на ситуацию, после которой приложение,
скорее всего, уже не сможет восстановиться. Данный механизм следует использовать крайне осмотрительно и только в исключительных случаях. В этом
разделе мы объясним, как и когда следует генерировать паники, и поделимся
собственным опытом их применения.
4.3.1. Отличие паники от ошибки
Прежде всего важно понять концептуальное различие между ошибкой и паникой.
Ошибка указывает на событие, которое нарушает ожидания, но не обязательно
делает дальнейшую работу невозможной. Паника же означает, что произошел
сбой, при котором система (или подсистема) больше не может продолжать работу. Если представить программу как цепочку взаимозависимых шагов, то панику
следует применять в одном из звеньев, который делает невозможной работу
4.3. Механизм паники
131
остальных. Генерация паники (путем вызова функции log.Panic()) приводит
к формированию трассировки стека, так что даже без определения собственных
типов ошибок мы можем выяснить, что именно пошло не так и где именно.
Язык Go возлагает ответственность за обработку ошибок на программиста.
Если ошибка произошла, но вы ее проигнорировали, Go не предпримет никаких
действий. При этом существует риск того, что некорректное состояние нарушит
работу следующих шагов программы. С паникой все иначе: при ее возникновении Go начинает искать соответствующий обработчик по всему стеку вызовов.
Если таковой не найден, выполнение программы прекращается. То есть необработанная паника приводит к завершению работы приложения. Эта разница
продемонстрирована в листинге 4.10.
Листинг 4.10. Ошибка и паника
package main
import (
"errors"
"fmt"
)
var ErrDivideByZero = errors.New("Can't divide by zero")
func main() {
fmt.Println("Divide 1 by 0")
_, err := precheckDivide(1, 0)
if err != nil {
fmt.Printf("Error: %s\n", err)
}
}
fmt.Println("Divide 2 by 0")
divide(2, 0)
Сначала выполняется деление
с помощью функции precheckDivide,
которая возвращает ошибку
Затем выполняется похожая операция
деления с помощью функции divide
func precheckDivide(a, b int) (int, error) {
Функция precheckDivide
if b == 0 {
возвращает ошибку,
return 0, ErrDivideByZero
если делитель равен нулю
}
return divide(a, b), nil
}
Обычная функция divide просто выполняет
деление без каких-либо проверок
func divide(a, b int) int {
return a / b
}
В этом примере определены две функции. Функция divide выполняет обычную
операцию деления, но не проверяет делитель на равенство нулю, считающийся
неопределенным поведением в большинстве языков программирования. В свою
очередь, функция precheckDivide явно проверяет делитель и возвращает ошибку, если тот равен нулю. Чтобы сравнить поведение Go в этих двух случаях,
132
Глава 4. Обработка ошибок и паник
в функции main сначала вызывается precheckDivide, а затем — обычная функция
divide. Результат выполнения этого кода будет таким:
go run zero_divider.go
Divide 1 by 0
Error: Can't divide by zero
Divide 2 by 0
panic: runtime error: integer divide by zero
[signal 0x8 code=0x7 addr=0x22d8 pc=0x22d8]
goroutine 1 [running]:
main.main()
/Users/mbutcher/Code/go-in-practice/chapter4/zero_divider.go:18 +0x2d8
Первое деление с использованием precheckDivide возвращает ошибку, а второе вызывает панику, поскольку делитель не был предварительно проверен
на равенство нулю. С концептуальной точки зрения между этими операциями
существует важное различие:
проверяя значение перед делением, мы предотвращаем ситуацию, в которой
программе пришлось бы выполнять недопустимую операцию;
при делении без проверки мы приводим систему в такое состояние, с которым она не может справиться (именно в таких случаях и следует вызывать
панику).
С практической точки зрения ошибки — это ситуации, к которым разработчик
должен быть готов. В конце концов, они задокументированы в коде. Достаточно
взглянуть на определение функции precheckDivide, чтобы увидеть, при каком
условии она возвращает ошибку, и понять, что эту ситуацию необходимо определенным образом обработать. Хотя ошибка и может означать некое отклонение
от нормы, неожиданной ее назвать нельзя.
В свою очередь, паника, напротив, сигнализирует о непредсказуемой и неконтролируемой проблеме. Она возникает при нарушении некоего ограничения,
приводящем к непредсказуемым последствиям. Таким образом, вызывать панику
стоит только при отсутствии внятного способа обработки ситуации в текущем
контексте. Во всех остальных случаях следует возвращать ошибку.
4.3.2. Обработка паник
У Go-разработчиков существуют определенные, хотя и не всегда четко сформулированные правила относительно того, как корректно использовать механизм
паники. Прежде чем обсуждать способы обработки паник, давайте рассмотрим
технику их генерации, которую должен знать каждый Go-разработчик.
Определение функции panic в Go может быть выражено так:
panic(interface{})
4.3. Механизм паники
133
Это означает, что при вызове функции можно передать практически любой
аргумент. При желании вы можете вызвать даже panic(nil), как показано
в листинге 4.11.
Листинг 4.11. Вызов функции panic с аргументом nil
package main
func main() {
panic(nil)
}
Паника без причины!
При запуске этой программы Go перехватит панику:
$ go run proper_panic.go
panic: nil
goroutine 1 [running]:
main.main()
/chapter4/proper_panic.go:4 +0x32
Однако такое сообщение не особенно информативно. Если бы мы вызвали панику со строкой panic("Oops, I did it again."), то результат выполнения кода
выглядел бы так:
$ go run proper_panic.go
panic: Oops, I did it again.
goroutine 1 [running]:
main.main()
/chapter4/proper_panic.go:4 +0x64
Уже лучше. По крайней мере, у нас есть какая-то информация. Но действительно ли это такой уж хороший способ?
Проблема. Что следует передавать функции panic? Существуют ли полезные
и идиоматические способы ее вызова?
Решение. Наилучшим аргументом для функции panic является значение типа
error. Это упрощает обработку, так как функция восстановления сможет
перехватить ошибку и принять то или иное решение в зависимости от ее типа.
Обсуждение. Из-за наличия аргумента типа interface{} в сигнатуре функции
panic не всегда ясно, что именно ей следует передавать. Можно передать
объект, вызвавший панику, а можно — строку, значение nil или ошибку.
На практике лучшим вариантом (по крайней мере в типичных случаях) является
передача значения, реализующего интерфейс error. На то есть две причины.
Первая — это интуитивность. Что вызывает панику? Очевидно, ошибка. Разумно предположить, что разработчики будут ожидать именно этого. Вторая
причина — упрощение обработки паники. В следующем разделе мы рассмотрим
способы восстановления после паники: вы узнаете, как можно перехватывать
134
Глава 4. Обработка ошибок и паник
ее, устранять последствия и работать с переданным объектом как с обычной
ошибкой. А пока взгляните на листинг 4.12, в котором показан идиоматический
способ генерации паники.
Листинг 4.12. Идиоматический способ генерации паники
package main
import "errors"
func main() {
panic(errors.New("Something bad happened."))
}
Вызывает функцию panic,
передавая ей ошибку
в качестве аргумента
Такой подход позволяет легко вывести сообщение о панике в виде форматированной строки, например: fmt.Printf("Error: %s", thePanic), а также передать
панику обратно в систему обработки ошибок. Именно поэтому передача значения
типа error в функцию panic считается хорошей практикой в Go.
Выход за рамки идиомы
Хотя мы только что сказали, что лучшим аргументом для функции panic является
ошибка, на самом деле эта функция не нуждается в значениях именно такого типа,
поскольку механизм паник в Go изначально задумывался как гибкая система.
В этом отношении Go напоминает язык Java, позволяющий генерировать объекты
Throwable, которые не обязательно являются исключениями. В Go паника служит
одним из способов раскрутки стека.
Очень часто функция panic() становится конечной точкой для ошибок, передаваемых вверх по стеку вызовов. Представьте функцию main(), которая вызывает Initialize() error, а та, в свою очередь, — StartServer() error. Если
запуск сервера невозможен, StartServer возвращает ошибку, которая передается
в Initialize, а затем — в main, где в итоге и вызывается паника.
Теперь, когда вы знаете, как правильно генерировать панику, давайте поговорим
о ее обработке.
4.3.3. Восстановление после паники
Разговор о паниках был бы неполным без обсуждения способов восстановления после них. В Go для этого используются так называемые отложенные
функции (deferred functions). Язык Go гарантирует выполнение такой функции
в момент возврата из родительской функции. При этом сама причина возврата
не имеет значения: будь то срабатывание оператора return, достижение конца
блока или возникновение паники. Листинг 4.13 демонстрирует работу данного
механизма.
4.3. Механизм паники
135
Листинг 4.13. Простой пример использования оператора defer
package main
import "fmt"
Откладывает выполнение
func main() {
функции goodbye
defer goodbye()
fmt.Println("Hello world.")
Выводит строку на экран
}
до вызова goodbye
func goodbye() {
fmt.Println("Goodbye")
}
Без оператора defer эта программа сначала вывела бы строку Goodbye, а затем
Hello world. Однако defer изменяет порядок выполнения операций, откладывая
вызов goodbye до завершения выполнения остального кода функции main. Как
только выполнение main заканчивается, срабатывает отложенный вызов goodbye.
Результат работы этой программы выглядит так:
$ go run simple_defer.go
Hello world.
Goodbye
Оператор defer особенно удобен для закрытия файлов и сетевых соединений,
освобождения ресурсов (например, дескрипторов базы данных) и обработки
паник. В листинге 4.12 был показан корректный способ генерации паники.
Теперь, когда вы познакомились с оператором defer, мы можем сосредоточиться
на способах восстановления после паники.
4.3.4. Перехват паники с помощью оператора defer
Перехват паники в отложенной функции — это стандартная практика в Go.
Мы рассматриваем ее здесь по двум причинам: во-первых, она служит основой
для другой техники, а во-вторых, ее обсуждение позволяет лучше понять принцип работы механизма паники.
Проблема. Одна из функций, вызываемых приложением, генерирует панику,
которая приводит к аварийному завершению работы программы.
Решение. Использовать отложенную функцию с вызовом recover для выяснения причины сбоя и обработки паники.
В левой части рис. 4.1 показано, как необработанная паника приводит к завершению программы. В правой части показано, как функция recover может
остановить раскрутку стека вызовов и позволить программе продолжить
выполнение.
Обсуждение. В Go предусмотрен механизм для получения информации
о панике и остановки ее распространения вверх по стеку вызовов. Для этого
используется функция recover.
136
Глава 4. Обработка ошибок и паник
Рис. 4.1. Восстановление после паники
В листинге 4.14 продемонстрирован процесс генерации и обработки паники.
Листинг 4.14. Восстановление после паники
package main
import (
"errors"
"fmt"
)
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("Trapped panic: %s (%T)\n", err, err)
}
}()
Вызывает функцию,
которая генерирует панику
yikes()
Использует
отложенное замыкание
для восстановления
после паники
}
func yikes() {
panic(errors.New("Something bad happened."))
}
Вызывает панику, передавая
ошибку в качестве аргумента
Эта программа иллюстрирует, пожалуй, самый распространенный способ восстановления после паники. Чтобы перехватить панику, вызванную функцией
yikes, мы определяем отложенную анонимную функцию, которая проверяет
наличие паники и вызывает recover, если та действительно была сгенерирована.
Такая функция называется замыканием и выполняется в конце области видимости родительской функции, будучи вызванной с помощью пары скобок ().
4.3. Механизм паники
137
ПРИМЕЧАНИЕ Замыкания могут использоваться и для других целей. Например, их
можно сохранять в переменные и многократно вызывать, поддерживая таким образом
внутреннее состояние. Мы вернемся к ним при обсуждении конкурентности в главе 5.
Используя оператор defer с замыканием, мы определяем функцию и указываем,
что ее нужно вызвать позднее (в данном случае без аргументов). Стандартная
форма записи выглядит так: defer func(){ /* ТЕЛО ФУНКЦИИ */ }(). Хотя может
показаться, что функция определяется и вызывается одновременно, на самом
деле среда выполнения Go вызывает ее лишь в тот момент, когда очередь дойдет
до выполнения отложенных вызовов. Вскоре вы поймете, чем полезно такое разделение. Разумеется, ту же логику можно было бы реализовать в виде отдельной
функции и вызвать ее через defer someFunction(), однако встроенная функция
делает поток управления более наглядным.
Функция recover в Go возвращает значение (interface{}), если паника действительно произошла. В противном случае она выдает nil. Возвращаемым значением является аргумент, переданный функции panic. Результат выполнения
предыдущего фрагмента кода выглядит так:
$ go run recover_panic.go
Trapped panic: Something bad happened. (*errors.errorString)
Обратите внимание: добавление %T в строку форматирования позволяет получить информацию о типе переменной err, а именно о том, что это тип error,
созданный функцией errors.New. Мы уже встречали этот спецификатор формата
при обсуждении обобщений в главе 3.
Теперь посмотрим, как можно сочетать замыкание и функцию recover для восстановления после паники. Замыкания наследуют область видимости своей
родительской функции. Отложенные замыкания, подобные показанному выше,
наследуют все, что находится в области видимости до их объявления, поэтому
код в листинге 4.15 работает нормально.
Листинг 4.15. Область видимости для отложенных замыканий
package main
import "fmt"
func main() {
var msg string
defer func() {
fmt.Println(msg)
}()
}
msg = "Hello world"
Объявляет переменную
за пределами замыкания
Выводит значение переменной
при выполнении отложенного замыкания
Присваивает значение
переменной
138
Глава 4. Обработка ошибок и паник
Поскольку переменная msg определена до объявления замыкания, оно может
обращаться к ней. Значение переменной msg во время выполнения отложенной
функции будет отражать ее состояние на тот момент. Результат выполнения
приведенного выше фрагмента кода — вывод строки Hello world.
Однако, несмотря на то что оператор defer выполняется после остальной части
функции, замыкание не имеет доступа к переменным, объявленным после него.
Замыкание вычисляется в момент объявления, но выполняется только после
возврата из функции. Именно поэтому выполнение кода из листинга 4.16 приводит к ошибке компиляции.
Листинг 4.16. Переменная msg вне области видимости
package main
import "fmt"
func main() {
defer func() {
fmt.Println(msg)
}()
}
msg := "Hello world"
Выводит значение
переменной
Объявляет и инициализирует переменную.
Компиляция завершится с ошибкой, потому что
объявление находится после функции
Поскольку переменная msg не была объявлена до отложенной функции, во время
проверки кода msg оказывается неопределенной.
Теперь давайте объединим все эти детали и рассмотрим чуть более сложный
пример использования отложенной функции, в рамках которого, помимо обработки паники, выполняется еще и очистка.
Представьте, что вы пишете фрагмент кода для удаления пустых начальных
строк из CSV-файла. В нашем случае функция RemoveEmptyLines будет специально вызывать панику. Такое поведение позволит нам продемонстрировать,
как можно перехватить панику, закрыть проблемный файл и вернуть ошибку
(листинг 4.17).
Листинг 4.17. Очистка и восстановление после паники
package main
import (
"errors"
"fmt"
"io"
"os"
)
func main() {
4.3. Механизм паники
139
var file io.ReadCloser file, err := OpenCSV("data.csv")
Вызывает функцию
if err != nil {
OpenCSV и обрабатывает
fmt.Printf("Error: %s", err)
ошибки. В данной
return
реализации функция
Использует
отложенный
}
всегда возвращает ошибку
вызов, чтобы гарантировать
закрытие файла
Обычно на этом этапе
defer file.Close()
}
// Выполнение некоторого действия с файлом
выполняются дополнительные
действия с файлом
func OpenCSV(filename string) (file *os.File, err error) {
defer func() {
if r := recover(); r != nil {
Отложенная
file.Close()
обработка ошибок
err = r.(error)
происходит здесь
}
}()
file, err = os.Open(filename)
if err != nil {
fmt.Printf("Failed to open file\n")
return file, err
}
}
RemoveEmptyLines(file)
return file, err
Функция OpenCSV
открывает
и обрабатывает файл.
Обратите внимание
на именованные
возвращаемые
значения
Открывает файл и обрабатывает
возможные ошибки (например,
если файл не найден)
Вызывает некорректно работающую
функцию RemoveEmptyLines
func RemoveEmptyLines(f *os.File) {
panic(errors.New("failed parse"))
}
Вместо удаления пустых строк
функция всегда вызывает панику
Еще раз подчеркнем: проблема приведенного выше кода в том, что функция
RemoveEmptyLines всегда вызывает панику. В полноценной реализации она бы
проверяла, есть ли в начале файла пустые строки, и, если да, переходила к первой непустой строке.
В листинге 4.17 отложенные функции используются дважды. В функции main
с их помощью гарантируется закрытие файла, что считается хорошей практикой
при работе с файлами, сетевыми соединениями, подключениями к базе данных
и другими ресурсами, которые необходимо освобождать во избежание утечек
или побочных эффектов. Такой способ очистки актуален даже в случае неожиданного завершения программы. Вторая отложенная функция используется
внутри OpenCSV и призвана выполнить три действия:
перехватить возможную панику;
гарантировать закрытие файла при возникновении паники (хотя в данном
контексте этот прием может показаться избыточным, он считается хорошей
практикой);
извлечь значение, переданное в функцию panic, и передать его как обычную
ошибку через стандартный механизм обработки ошибок.
140
Глава 4. Обработка ошибок и паник
Обратите внимание, что в функции OpenCSV явно указаны имена возвращаемых
значений (file и err). Это позволяет обращаться к ним внутри замыкания и гарантирует возврат корректного значения ошибки из функции panic.
Как мы убедились, оператор defer представляет собой мощное и удобное средство
как для обработки паник, так и для надежной очистки ресурсов. В завершение
описания этой техники приведем несколько полезных рекомендаций по использованию отложенных функций.
Размещайте оператор defer как можно ближе к началу функции.
Отложенные функции не обязательно должны быть встроенными — часто
в качестве аргумента defer выступают методы структур данных.
Простые объявления вроде foo := 1 обычно помещаются перед оператором
defer.
Более сложные переменные (например, var myFile io.Reader) можно объявить до defer, но инициализировать уже после.
Хотя в одной функции может содержаться несколько операторов defer, по
возможности этого следует избегать. Если это необходимо, помните, что отложенные функции будут выполнены в порядке, обратном тому, в котором
они были объявлены.
Хорошей практикой считается закрытие файлов, соединений и прочих
ресурсов внутри defer. Это гарантирует освобождение ресурсов даже при
возникновении ошибок и паник.
В следующем разделе мы обсудим способы предотвращения аварийного завершения программы из-за паники, возникшей в горутине.
4.3.5. Обработка паник, возникших в горутинах
До сих пор мы почти не говорили о конкурентности, реализуемой в Go с помощью горутин. Для запуска горутины используется ключевое слово go. Если у вас
есть функция с именем run, вы можете запустить ее в качестве горутины так: go
run. Согласно официальной спецификации языка Go, оператор go «запускает
выполнение функции в виде независимого конкурентного потока, или горутины,
в рамках того же адресного пространства» (https://go.dev/ref/spec#Go_statements).
Проще говоря, горутину можно рассматривать как выполнение функции в отдельном легковесном потоке.
Чтобы лучше понять принцип работы горутин, представьте себе простой сервер,
функционирующий следующим образом.
1. Функция main вызывает функцию start для запуска сервера.
2. Функция start обрабатывает конфигурационные данные и запускает функцию listen.
4.3. Механизм паники
141
3. Функция listen открывает сетевой порт и ожидает входящих запросов. При
получении запроса она не обрабатывает его сама, а запускает горутину go
handle, передавая необходимые данные функции handle.
4. Функция handle обрабатывает запрос и вызывает функцию response.
5. Функция response отправляет ответ клиенту и разрывает соединение.
Горутины под капотом
На самом деле реализация горутины устроена сложнее, чем запуск функции в отдельном потоке. На странице Go Concurrency Wiki (https://github.com/golang/go/wiki/
LearnConcurrency) приведен обширный список материалов, подробно описывающих
различные аспекты модели конкурентности Go, основанной на концепции CSP (com
municating sequential processes — взаимодействующие последовательные процессы).
Функция listen использует горутины для одновременной обработки нескольких
клиентских соединений. По мере поступления запросов сервер может распределять нагрузку между множеством функций handle, каждая из которых выполняется независимо от других. Этот прием часто применяется в Go-приложениях,
работающих на стороне сервера. Более того, простые HTTP-серверы, которые мы
создавали в главах 1 и 2, использовали пакет http, который задействует горутины
в методе ServeHTTP для маршрутизации запросов. На рис. 4.2 показаны структура такого приложения и стеки вызовов функций при использовании горутин.
Рис. 4.2. Простой сервер
Каждый ряд представляет собой стек функций, а каждая команда go запускает
новый стек. Как только функция listen получает очередной запрос, создается
новый стек вызовов для соответствующего экземпляра handle. После завершения
работы handle (например, при возврате из response) соответствующая горутина
автоматически завершается и освобождает ресурсы.
Горутины представляют собой мощный и изящный инструмент. Они легко пишутся и почти не создают издержек, поэтому Go-разработчики используют их
повсеместно. Однако существует один неприятный и, к сожалению, довольно
распространенный сценарий, когда сочетание горутин и паник может привести
к аварийному завершению программы.
142
Глава 4. Обработка ошибок и паник
При обработке паники среда выполнения Go раскручивает стек вызовов до
тех пор, пока не встретит вызов функции recover. Однако, если она доходит
до вершины стека и recover так и не вызывается, программа аварийно завер
шается. Как вы помните из рис. 4.2, горутина получает собственный стек вызовов. Результат возникновения паники внутри такой горутины показан на
рис. 4.3.
Рис. 4.3. Сбой в горутине
Представьте, что при обработке запроса в функции response возникает неожиданная фатальная ошибка, вызывающая панику. Будучи ответственным разработчиком серверной части, вы добавили в функцию listen весь необходимый
код для обработки ошибок. Однако если в функции handle не реализована логика
перехвата паники, то программа аварийно завершится. Почему? Дело в том, что
если паника не обрабатывается на верхнем уровне стека вызовов, Go прерывает
выполнение программы в аварийном режиме. Паника, возникшая в горутине,
не может «перепрыгнуть» в стек вызовов функции, запустившей эту горутину.
В данном случае от handle нет пути обратно к listen, потому что с точки зрения
вызывающей стороны горутина — это процесс типа «запустил и забыл» (fire and
forget). Именно эту задачу мы будем решать.
Проблема. Если паника, возникшая в горутине, не обрабатывается в пределах
ее собственного стека вызовов, это приводит к аварийному завершению всей
программы. Иногда такое поведение допустимо, но чаще всего попытка восстановления является более предпочтительной.
Решение. Обрабатывать панику внутри каждой горутины, в которой она
может возникнуть. Предлагаемый подход упрощает разработку серверов,
способных справляться с паникой, не требуя ее обработки в каждой отдельной функции handle.
Обсуждение. Особенность этой проблемы заключается в том, что она имеет
очень простое, но громоздкое решение, реализация которого часто ложится
на плечи сторонних разработчиков. Сначала мы рассмотрим простую реализацию, соответствующую рис. 4.3. Затем разберем тривиальное решение
проблемы и увидим, как идиомы Go делают его неудобным. В завершение
будет представлен более изящный способ решения.
4.3. Механизм паники
143
В листинге 4.18 продемонстрирована базовая реализация простого эхо-сервера,
показанного на рис. 4.3. Запустив его, вы можете подключиться к порту 1026,
получить строку текста от клиента и отправить ее обратно.
Листинг 4.18. Эхо-сервер
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
listen()
}
func listen() {
listener, err := net.Listen("tcp", ":1026")
if err != nil {
fmt.Println("Failed to open port on 1026")
return
}
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("Error accepting connection")
continue
}
}
}
go handle(conn)
Запускает новый сервер,
прослушивающий порт 1026
Ожидает подключения
клиентов и обрабатывает
ошибки соединения
При подключении передает
его в функцию handle
func handle(conn net.Conn) {
Пытается считать строку
reader := bufio.NewReader(conn)
данных из соединения
data, err := reader.ReadBytes('\n')
if err != nil {
fmt.Println("Failed to read from socket.")
В случае ошибки чтения выводит
сообщение и закрывает соединение
conn.Close()
}
}
response(data, conn)
После получения строки передает
ее в функцию response
func response(data []byte, conn net.Conn) {
defer func() {
conn.Close()
}()
}
conn.Write(data)
Отправляет данные обратно
в сокет (возвращая их клиенту),
а затем закрывает соединение
144
Глава 4. Обработка ошибок и паник
Выполнение этого кода приводит к запуску сервера, с которым можно взаимодействовать следующим образом:
$ telnet localhost 1026
Trying ::1...
Connected to localhost.
Escape character is '^]'.
test
test
Connection closed by foreign host.
Если ввести слово test (выделено жирным в предыдущем фрагменте кода),
сервер вернет его и закроет соединение.
Этот простой сервер прослушивает порт 1026 в ожидании новых клиентских
подключений. Каждый раз при открытии нового соединения сервер запускает
новую горутину, в которой вызывается функция handle. Благодаря тому что
каждый запрос обрабатывается в отдельной горутине, сервер может эффективно
работать со множеством одновременных подключений.
Функция handle считывает строку текста (в виде необработанных байтов), а затем передает ее вместе с соединением в функцию response. Та, в свою очередь,
отправляет строку обратно клиенту и закрывает соединение.
Разумеется, это далеко не идеальный сервер, но он демонстрирует базовый
принцип работы и потенциальные проблемы. Представьте, что функция response
может вызвать панику. Чтобы смоделировать такую ситуацию, заменим предыдущий код версией из листинга 4.19.
Листинг 4.19. Паника в функции response
func response(data []byte, conn net.Conn) {
panic(errors.New("Failure in response!"))
}
Вместо выполнения полезной
работы вызывает панику
Первое, что бросается в глаза, — соединение так и не закрывается. Однако еще
более серьезной проблемой является аварийное завершение работы сервера изза возникшей паники. А серверы не должны быть настолько уязвимыми, чтобы
отключаться при неудачной обработке одного-единственного запроса. Логичным
решением может показаться добавление логики перехвата паники в функцию
listen, однако это не поможет, поскольку горутина работает в пределах собственного стека вызовов.
Чтобы сделать наш сервер более отказоустойчивым, мы реализуем обработку
паники внутри функции handle. Листинг 4.20 содержит только функции handle
и response — остальная часть кода остается такой же, как в листинге 4.18.
Новая версия handle включает отложенную функцию, использующую recover
для проверки факта возникновения паники. Это предотвращает ее распространение вверх по стеку вызовов. Мы также слегка улучшили управление
4.3. Механизм паники
145
соединением, добавив оператор defer, который гарантирует, что по завершении
выполнения функции handle соединение будет закрыто, что бы ни случилось.
Благодаря этому сервер не будет аварийно завершаться при возникновении
паники в функции response.
Листинг 4.20. Обработка паники в горутине
func handle(conn net.Conn) {
defer func() {
if err := recover(); err != nil {
fmt.Printf("Fatal error: %s", err)
}
conn.Close()
}()
Отложенная функция
обрабатывает панику
и гарантирует закрытие
соединения в любой ситуации
reader := bufio.NewReader(conn)
data, err := reader.ReadBytes('\n')
if err != nil {
fmt.Println("Failed to read from socket.")
}
}
response(data, conn)
func response(data []byte, conn net.Conn) {
conn.Write(data)
panic(errors.New("Pretend I'm a real error"))
}
Здесь снова вызывается
паника для имитации сбоя
Итак, все работает как надо. Однако мы можем внести еще одно улучшение.
В Go распространена практика создания серверных библиотек, которые обес
печивают гибкий способ обработки запросов. Отличным примером служит
библиотека net/http.Server. Как было показано в листинге 1.15, для создания
HTTP-сервера на Go достаточно передать HTTP-системе обработчик (функцию
handler) и запустить его (листинг 4.21).
Листинг 4.21. Небольшой HTTP-сервер
package main
import (
"errors"
"net/http"
)
func main() {
http.HandleFunc("GET /", handler)
}
Передает HTTP-системе
функцию-обработчик
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(fmt.Sprintf("could not start server: %v", err))
}
Запускает
сервер
146
Глава 4. Обработка ошибок и паник
Вся логика запуска и управления сервером реализована внутри пакета net/http.
Однако решение о том, как именно обрабатывать входящие запросы, остается
за разработчиком. Как видите, в предыдущем фрагменте кода у нас есть возможность передать собственную функцию-обработчик. Это может быть любая
функция, соответствующая следующему типу:
type HandlerFunc func(ResponseWriter, *Request)
При получении запроса этот сервер действует примерно так же, как и описанный
выше эхо-сервер: запускает горутину и вызывает в ней функцию-обработчик.
Но что произойдет, если внутри обработчика возникнет паника? Давайте проанализируем листинг 4.22.
Листинг 4.22. Обработчик, вызывающий панику
func handler(res http.ResponseWriter, req *http.Request) {
panic(errors.New("Fake panic!"))
}
Если запустить сервер с этим кодом, то информация о панике будет выведена
в консоль, а сам сервер продолжит работать.
2015/04/08 07:57:31 http: panic serving [::1]:51178: Fake panic!
Goroutine 5 [running]:
net/http.func·011()
/usr/local/Cellar/go/libexec/src/net/http/server.go:1130 +0xbb
main.handler(0x494fd0, 0xc208044000, 0xc208032410)
/chapter4/http_server.go:13 +0xdd
net/http.HandlerFunc.ServeHTTP(0x3191e0, 0x494fd0, 0xc208044000, 0xc208032410)
…
Но ведь наш обработчик никак не обрабатывает панику! Дело в том, что защитный механизм реализован внутри библиотеки — в методе ServeHTTP, который
использует специальный прием восстановления, фиксируя сообщение об ошибке,
но не завершая работу приложения. Учитывая это, если вы хотите превратить
свой эхо-сервер в полноценную библиотеку, его архитектуру следует слегка изменить, реализовав обработку паник непосредственно внутри самой библиотеки.
В свое время мы написали небольшую вспомогательную библиотеку (ныне
являющуюся частью github.com/Masterminds/cookoo), которая обеспечивала защиту приложений от непредусмотренных паник в горутинах. В листинге 4.23
продемонстрирована ее упрощенная версия.
Листинг 4.23. Паники в горутинах
package safely
import (
"log"
)
4.3. Механизм паники
type GoDoer func()
147
GoDoer — это простая функция
без параметров
Go запускает функцию в качестве горутины
и обрабатывает возможные паники
func Go(todo GoDoer) {
Сначала выполняется анонимная функция
go func() {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic in safely.Go: %s", err)
}
}()
Вызывает функцию GoDoer,
todo()
переданную в качестве аргумента
}()
}
Анонимная функция
обрабатывает панику,
используя стандартный
прием с отложенным
восстановлением
Данная библиотека берет на себя обработку паник, избавляя вас от необходимости
делать это вручную. Пример ее использования приведен в листинге 4.24.
Листинг 4.24. Использование пакета safely для перехвата паник
package main
import (
"errors"
"time"
)
"github.com/Masterminds/cookoo/safely"
Импортирует
пакет safely
Определяет функцию обратного вызова,
соответствующую типу GoDoer
func message() {
println("Inside goroutine")
panic(errors.New("Oops!"))
}
Вместо go message
func main() {
используется safely.Go(message)
safely.Go(message)
println("Outside goroutine")
Гарантирует выполнение горутины
time.Sleep(1000)
до завершения работы программы
}
В этом примере мы определяем простую функцию, соответствующую типу
GoDoer (она не принимает параметров и ничего не возвращает). При вызове
safely.Go(message) функция message запускается в новой горутине, но с перехватом паник. Поскольку message вызывает панику, выполнение программы
приводит к следующему результату:
$ go run safely_example.go
Outside goroutine
Inside goroutine
2015/04/08 08:28:00 Panic in safely.Go: Oops!
Вместо того чтобы аварийно завершить работу приложения, safely.Go перехватывает панику и записывает сообщение в журнал.
148
Глава 4. Обработка ошибок и паник
Замыкания приходят на помощь
Помимо использования именованной функции, такой как message , вы можете
прибегнуть к замыканию. Замыкание имеет доступ к переменным, находящимся
в текущей области видимости, что позволяет обойти ограничение, связанное с тем,
что функция GoDoer не принимает аргументов. Однако будьте осторожны, так как
подобный подход может привести к состояниям гонки и другим проблемам, связанным с конкурентностью! В Go предусмотрен флаг -race, который можно добавить
к командам go run или go build. При написании конкурентного кода он служит отличным инструментом для выявления потенциальных состояний гонки.
Возможно, представленная библиотека не в полной мере соответствует вашим
задачам, но она иллюстрирует хорошую практику, суть которой заключается
в проектировании библиотек таким образом, чтобы паника, возникшая в горутине, не приводила к неожиданному или нежелательному завершению работы
всей программы.
Go предоставляет систему обработки ошибок наряду с механизмом вызова
паники во время выполнения. Но, как было показано в этом разделе, паника
может возникнуть в самых неожиданных ситуациях. Если не предусмотреть
их заранее, вас ждут трудности при отладке кода. Именно поэтому мы уделили
столько внимания защитным приемам и порекомендовали использовать простые
превентивные решения, такие как safely.Go, вместо того чтобы полагаться на
предусмотрительность разработчиков.
Резюме
Тема обработки ошибок может показаться скучной, однако понимание идиом
Go, применяемых в этой области, позволяет избежать неожиданных сбоев.
Создание и использование переменных ошибок помогает корректно обрабатывать проблемные случаи.
Определение собственных типов ошибок дает пользователю дополнительную
информацию и больше возможностей для принятия решений.
Понимание того, когда следует использовать механизм паники, а когда — осуществлять обработку ошибок, делает приложение более устойчивым к устранимым сбоям и более надежным при возникновении фатальных ошибок.
Горутины добавляют свои нюансы, связанные с паниками. Зная способы
их обработки, можно избежать взаимных блокировок и сбоев в работе приложения.
5
Конкурентность в Go
В этой главе
3 Модель конкурентности в языке Go.
3 Использование горутин для реализации конкурентной обработки
и взаимодействия.
3 Блокировка данных и ожидание ответов.
3 Применение каналов для обмена данными между горутинами.
3 Стратегическое закрытие каналов.
Эта глава посвящена реализации конкурентности в Go. В отличие от многих
современных процедурных и объектно-ориентированных языков в Go не используется модель конкурентности, основанная на потоках: во главу угла здесь
ставится концепция событийных горутин и каналов, с помощью которых осуществляется взаимодействие между ними. Такой подход к реализации конкурентности является более экономичным с точки зрения потребляемых ресурсов
и более простым в управлении по сравнению с традиционными пулами потоков.
В этой главе мы поговорим о горутинах — функциях, выполняемых в конкурентном режиме, о каналах — основном механизме Go, который обеспечивает
взаимодействие между горутинами, а также о мьютексах — примитивах синхронизации, позволяющих блокировать ресурсы для обеспечения согласованности
в конкурентных системах.
150
Глава 5. Конкурентность в Go
5.1. Модель конкурентности в Go
В общих чертах конкурентность — это способность программы выполнять несколько действий одновременно. Под конкурентными программами мы обычно
понимаем приложения, в которых несколько задач выполняются независимо
друг от друга примерно в одно и то же время.
Такие популярные языки программирования, как Java и Python, реализуют конкурентность с помощью потоков операционной системы либо их облегченных
версий, называемых зелеными потоками. В обоих случаях виртуальная машина
рассматривает подпроцессы как потоки и управляет переключением контекста
между ними. В Go используется другой подход. Следуя модели, предложенной
известным ученым Тони Хоаром, Go реализует конкурентность на основе модели взаимодействующих последовательных процессов (communicating sequential
processes, CSP). В этой главе мы сосредоточимся на практическом применении
данной модели, но для более глубокого понимания рекомендуем ознакомиться
с теоретическими основами CSP на сайте go.dev.
Обзор паттернов конкурентности
Один из создателей Go, Роб Пайк, рассказал о разработке языка и о паттернах конкурентности, повлиявших на добавление горутин. Вы можете ознакомиться с его
докладом в формате видео: https://www.youtube.com/watch?v=yE5Tpp2BSGw — или
презентации: https://mng.bz/6eNy.
В основе модели конкурентности Go лежат два ключевых понятия.
Горутина — функция, выполняющаяся независимо от той функции, которая
ее вызвала. Иногда горутину описывают как функцию, работающую так,
будто она выполняется в отдельном потоке.
Канал — конвейер передачи и получения данных. Каналы можно представить
как встроенные в программу сокеты, предназначенные для отправки и приема
сигналов. Каналы позволяют одной горутине передавать структурированные
данные другой. Это основной способ взаимодействия между горутинами
и вызывающим их кодом.
В этой главе мы сосредоточимся на практическом применении горутин и каналов,
не углубляясь в теорию или внутреннее устройство этих механизмов.
Подход Go к реализации конкурентности интуитивно понятен и применяется
во многих библиотеках и инструментах. Здесь мы рассмотрим несколько ключевых тем, связанных с конкурентностью, сделав акцент на отличии модели
Go от моделей других популярных языков, а также обсудим лучшие практики. Горутины и каналы относятся к тем немногим элементам языка Go, при
5.2. Работа с горутинами
151
использовании которых программист может столкнуться с утечкой памяти или
состоянием гонки. Избежать этого помогут определенные паттерны, о которых
мы и поговорим далее.
5.2. Работа с горутинами
С синтаксической точки зрения горутина — это любая функция, вызванная
с использованием ключевого слова go. Теоретически в виде горутины можно
запустить любую функцию, хотя на практике такой подход не имеет особого
смысла. Чаще всего горутины используются для выполнения функции в фоновом режиме параллельно с основной программой. В качестве примера напишем
небольшую программу, которая будет повторять любой введенный вами текст,
но только в течение 30 секунд. По истечении этого времени программа завершит
работу (листинг 5.1).
Листинг 5.1. Использование горутины для выполнения задачи
package main
import (
"fmt"
"io"
"os"
"time"
)
func main() {
fmt.Println("Type anything below for up to 30 seconds")
go echo(os.Stdin, os.Stdout)
Вызывает функцию echo как горутину,
возвращая управление обратно в main
time.Sleep(30 * time.Second)
Приостанавливает выполнение на 30 секунд
Выводит сообщение об истечении
fmt.Println("Timed out.")
времени ожидания
os.Exit(0)
Завершает выполнение программы,
}
останавливая работу горутины
func echo(in io.Reader, out io.Writer) {
Функция echo представляет
io.Copy(out, in)
собой обычную функцию
Copy копирует данные
}
из os.Reader в os.Writer
В этой программе используется горутина для фонового выполнения функции
echo параллельно с работой таймера в основном потоке. Если вы запустите про-
грамму и введете какой-нибудь текст, то результат будет примерно следующим:
$ go run echoback.go
Type anything below for up to 30 seconds
Hello.
152
Глава 5. Конкурентность в Go
Hello.
My name is
My name is
You killed
You killed
Prepare to
Prepare to
Timed out.
Inigo Montoya
Inigo Montoya
my father
my father
die
die
Принцип работы довольно прост. Каждая строка, которую вы вводите, отображается в оболочке по мере набора текста и немедленно дублируется программой, как только та ее считает (то есть после нажатия Enter, добавляющей
символ новой строки). Этот цикл продолжает выполняться в фоновом режиме
на протяжении 30 секунд, в течение которых функция main «спит». Как видно
из примера, функция echo сама по себе ничем не примечательна, но при вызове
с ключевым словом go она выполняется как горутина. Функция main запускает
горутину, после чего управление возвращается к ней.
Запустив горутину, main ждет 30 секунд. Когда main завершается, это фактически
приводит к остановке главной горутины, а значит — и всей программы. Вызов
time.Sleep в данном случае играет ключевую роль: даже если какая-то горутина
все еще выполняется, завершение функции main автоматически прерывает выполнение всей программы, поскольку горутины работают асинхронно, а main
определяет жизненный цикл всего приложения.
На практике для таких задач лучше использовать пакет context, который предоставляет полезные абстракции для написания конкурентного кода, в частности
дедлайны и тайм-ауты. Например, для реализации поведения из листинга 5.1
можно было бы применить функцию context.WithDeadline(). Однако это упражнение позволило нам продемонстрировать основные принципы и особенности
конкурентного выполнения.
5.2.1. Ожидание завершения горутин
Иногда бывает необходимо запустить несколько горутин и приостановить выполнение основного кода до тех пор, пока эти горутины не завершат свою работу.
В Go для этого предусмотрен простой и интуитивно понятный механизм —
группы ожидания (sync.WaitGroup), позволяющий явно указать количество
вызовов их метода Done.
Проблема. Горутина должна запустить одну или несколько других горутин
и дождаться завершения их работы. В этом практическом примере мы рассмотрим задачу максимально быстрого сжатия нескольких файлов с последующим выводом сводки.
Решение. Выполнить отдельные задачи в горутинах. Использовать механизм
sync.WaitGroup, чтобы сообщить внешнему процессу о том, что горутины
завершили свою работу и выполнение основного кода можно возобновить.
5.2. Работа с горутинами
153
На рис. 5.1 представлена общая схема: один управляющий процесс распределяет задачи между рабочими процессами и ожидает их завершения.
Обсуждение. Стандартная библиотека Go содержит множество полезных
инструментов для синхронизации. Одним из самых часто используемых является механизм sync.WaitGroup, позволяющий приостановить выполнение
основного процесса до тех пор, пока не завершится указанное количество
горутин.
Рис. 5.1. Запуск нескольких рабочих процессов и ожидание их завершения
Начнем с простого инструмента, предназначенного для сжатия произвольного
количества файлов. В листинге 5.2 для сжатия каждого из файлов используется
встроенная библиотека Gzip (compress/gzip).
Листинг 5.2. Простое сжатие файлов с помощью Gzip
package main
import (
"compress/gzip"
"io"
"os"
)
func main() {
for _, file := range os.Args[1:] {
compress(file)
}
}
func compress(filename string) error {
in, err := os.Open(filename)
if err != nil {
return err
}
defer in.Close()
Собирает файлы, переданные
через командную строку, в список
Открывает исходный
файл для чтения
154
Глава 5. Конкурентность в Go
out, err := os.Create(filename + ".gz")
Создает новый файл, добавляя к имени
if err != nil {
исходного файла расширение .gz
return err
}
defer out.Close()
Объект gzip.Writer выполняет сжатие
данных и записывает их в целевой файл
gzout := gzip.NewWriter(out)
_, err = io.Copy(gzout, in)
gzout.Close()
}
Функция io.Copy автоматически
копирует данные
return err
Этот инструмент принимает список файлов через командную строку и сжимает
каждый из них, создавая новый файл с тем же именем, но с расширением .gz.
Допустим, у вас есть каталог со следующим содержимым:
$ ls -1 exampledata
example1.txt
example2.txt
example3.txt
В нем находятся три текстовых файла. С помощью упомянутого выше инструмента их можно сжать следующим образом:
$ go run simple_gz.go exampledata/*
$ ls -1 exampledata
example1.txt
example1.txt.gz
example2.txt
example2.txt.gz
example3.txt
example3.txt.gz
Как видите, программа simple_gz.go создала сжатую версию каждого файла
в формате Gzip.
Но давайте поговорим о производительности. В текущем виде программа
не выполняет сжатие файлов в конкурентном режиме. Вряд ли она эффективно использует доступную пропускную способность. Хотя программа работает
корректно, ее производительность далека от идеала. Поскольку каждый файл
можно сжать независимо от других, линейное выполнение кода имеет смысл
заменить на конкурентное или параллельное.
Приведенную выше программу можно переписать так, чтобы каждый файл
сжимался в рамках отдельной горутины. Хотя такой подход не является оптимальным для обработки тысяч файлов (из-за ограничений системы ввода/
вывода), он отлично работает в тех случаях, когда количество файлов не превышает нескольких сотен.
Итак, нам нужно, чтобы родительская горутина (main) запускала параллельное
сжатие файлов и дожидалась завершения всех дочерних горутин. Это можно
5.2. Работа с горутинами
155
легко реализовать с помощью группы ожидания. В листинге 5.3 показано, как
модифицировать код, не внося изменений в функцию compress. Такое решение
является более удачным, поскольку обеспечивает четкое разделение обязанностей и избавляет рабочую функцию (compress) от необходимости взаимодействовать с группой ожидания в тех случаях, когда файлы требуется сжимать
последовательно.
Листинг 5.3. П
араллельное сжатие файлов с использованием
группы ожидания
package main
import (
"compress/gzip"
"fmt"
"io"
"os"
"sync"
)
func main() {
var wg sync.WaitGroup
}
Объявляем переменную WaitGroup,
не требующую инициализации
При добавлении каждого файла счетчик группы
for i, file := range os.Args[1:] {
ожидания увеличивается на единицу
wg.Add(1)
go func(filename string) {
Эта функция вызывает compress,
compress(filename)
а затем уведомляет группу ожидания
о завершении выполнения задачи
wg.Done()
}(file)
Поскольку мы запускаем горутину внутри цикла
}
for, параметр следует передавать в замыкание
Внешняя горутина (main)
дожидается, пока все
wg.Wait()
горутины, выполняющие
fmt.Printf("Compressed %d files\n", len(os.Args[1:]))
сжатие, вызовут
метод wg.Done()
func compress(filename string) error {
// Этот код остался без изменений
}
В обновленной версии программы основное изменение касается функции main.
Во-первых, в нее была добавлена группа ожидания sync.WaitGroup, указывающая
число горутин, завершения которых необходимо дождаться перед продолжением
выполнения программы.
Группа ожидания — это механизм передачи сигналов, который оповещает
ожидающую горутину о том, что она может продолжить выполнение. Чтобы
им воспользоваться, необходимо сначала указать количество операций, выполнения которых необходимо дождаться, а затем сигнализировать о завершении
каждой из них. В данном случае мы добавляем новую горутину в группу ожидания на каждой итерации цикла for с помощью wg.Add(), а по завершении
соответствующей задачи вызываем wg.Done(). Функция wg.Wait() блокирует
156
Глава 5. Конкурентность в Go
выполнение основного кода до завершения всех зарегистрированных задач. Этот
процесс показан на рис. 5.2.
Рис. 5.2. Работа группы ожидания
Теоретически мы могли бы добавить в группу ожидания сразу всю очередь задач,
исходя из длины списка аргументов. Но при таком подходе, если одна из задач
в цикле завершится с ошибкой, метод Wait() будет блокировать выполнение
основного кода бесконечно. Поэтому надежнее добавлять задачу в группу ожидания в момент запуска соответствующей горутины.
Именно по этой причине метод wg.Done() вызывается внутри горутины. Такой
подход позволяет сосредоточить код, связанный с инициализацией и завершением работы горутины, в одном месте. Отложенный вызов Done() уменьшает
риск зависания WaitGroup при возникновении ошибок. Горутина принимает
имя файла и применяет к нему функцию compress. Обратите внимание: вместо
захвата переменной file замыканием мы передаем имя файла в функцию в виде
аргумента filename. Несмотря на кажущуюся избыточность, такой подход вполне
оправдан и обусловлен особенностями работы планировщика Go.
Как уже было показано, объявление горутины не означает ее немедленного
запуска. Если цикл выполнится пять раз, будет запланировано пять горутин,
но не факт, что какая-то из них сразу начнет работу. Тем временем переменная
file в цикле может измениться. В результате все горутины получат одно и то же
(последнее) значение строки file. Чтобы этого избежать, мы передаем значение
file в качестве параметра функции, тем самым фиксируя его в каждой горутине
в момент планирования ее запуска.
Хотя эта проблема может показаться надуманной, она встречается довольно
часто. Всякий раз, когда цикл запускает горутины, необходимо следить за тем,
чтобы переменные, которые они используют, не изменялись самим циклом,
а данные передавались в каждую горутину копией, предотвращая использование
перезаписанных или устаревших значений.
5.2. Работа с горутинами
157
5.2.2. Блокировка с помощью мьютекса
Когда две и более горутины работают с одним и тем же фрагментом изменяемых
данных, существует риск возникновения состояния гонки — ситуации, в которой
несколько процессов «соревнуются» за право чтения или записи одной и той же
информации. Одновременное взаимодействие этих процессов с данными создает
проблему: одна горутина еще не закончила модифицировать значение, а другая
уже попытается его считать или использовать. Невозможность удостовериться
в корректности данных, с которыми работает программа, влечет непредсказуемые последствия. А нарушение ожидаемого порядка изменений данных может
приводить к явным ошибкам.
Проблема. Нескольким процессам или горутинам необходимо получить доступ к одному и тому же фрагменту данных или изменить его.
Решение. Один из простейших способов предотвращения проблем в таких
ситуациях — заставить горутину блокировать ресурс, с которым она работает, и разблокировать его по завершении. Заметив блокировку, остальные
горутины будут дожидаться своей очереди. Для этого в Go используется
тип sync.Mutex.
Обсуждение. Стандартный пакет sync предоставляет интерфейс sync.Locker
и несколько реализаций механизма блокировки, необходимых для безопасной работы с общими ресурсами.
В листинге 5.4 показан пример программы, в которой возникает состояние
гонки. Эта простая программа считывает произвольное количество файлов
и подсчитывает число вхождений каждого слова. По завершении она выводит
список слов, которые встречаются в файлах более одного раза.
Листинг 5.4. Подсчет слов и состояние гонки
package main
import (
"bufio"
"fmt"
"os"
"strings"
"sync"
)
func main() {
var wg sync.WaitGroup
w := newWords()
Здесь снова используется
группа ожидания для
отслеживания горутин
if len(os.Args) < 2 {
fmt.Println("no files provided")
os.Exit(1)
}
158
Глава 5. Конкурентность в Go
for _, f := range os.Args[1:] {
wg.Add(1)
go func(file string) {
if err := tallyWords(file, w); err != nil {
fmt.Println(err.Error())
}
wg.Done()
}(f)
}
В основном цикле
используется
прием, описанный
в предыдущем
разделе
wg.Wait()
}
fmt.Println("Words that appear more than once:")
for word, count := range w.found {
if count > 1 {
fmt.Printf("%s: %d\n", word, count)
}
}
type words struct {
found map[string]int
}
По завершении работы
программа выводит
результат
Слова сохраняются в структуре. Можно было бы использовать ассоциативный
массив напрямую, но структура упрощает дальнейший рефакторинг
Создает новый
экземпляр words
func newWords() *words {
return &words{found: map[string]int{}}
}
Отслеживает количество
вхождений конкретного слова
func (w *words) add(word string) {
count, ok := w.found[word]
if !ok {
Если слово еще не зафиксировано,
w.found[word] = 1
оно добавляется в список;
return
в противном случае увеличивается
}
соответствующий счетчик
Открывает файл, парсит его
w.found[word] = count + 1
содержимое и подсчитывает
}
вхождения слов. Функция
Copy выполняет копирование
func tallyWords(filename string, dict *words) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanWords)
for scanner.Scan() {
word := strings.ToLower(scanner.Text())
dict.add(word)
}
}
return scanner.Err()
Scanner — это удобный
инструмент для такого
парсинга файлов
5.2. Работа с горутинами
159
Функция main перебирает все файлы, переданные через командную строку,
и последовательно собирает статистику по каждому из них. Ожидается, что
программа будет считывать текст из файлов и выводить список слов, встречающихся в них более одного раза. Давайте проверим ее работоспособность на
конкретном файле:
$ go run race.go 1.txt
Words that appear more than once:
had: 2
down: 2
the: 5
have: 2
that: 3
would: 3
…
Этот результат вполне ожидаем. Однако, если передать в программу сразу несколько имен файлов, каждый из них будет обрабатываться в отдельной горутине.
Давайте попробуем:
$ go run race.go *.txt
fatal error: concurrent map writes
goroutine 8 [running]:
runtime.throw(0x115890, 0xd)
/usr/local/go/src/runtime/panic.go:527 +0x90
fp=0x82029cbf0 sp=0x82029cbd8
runtime.evacuate(0xca600, 0x8202142d0, 0x16)
/usr/local/go/src/runtime/hashmap.go:825 +0x3b0
fp=0x82029ccb0 sp=0x82029cbf0
runtime.growWork(0xca600, 0x8202142d0, 0x31)
/usr/local/go/src/runtime/hashmap.go:795 +0x8a
fp=0x82029ccd0 sp=0x82029ccb0
runtime.mapassign1(0xca600, 0x8202142d0, 0x82029ce70, 0x82029cdb0)
/usr/local/go/src/runtime/hashmap.go:433 +0x175
fp=0x82029cd78 sp=0x82029ccd0
…
В некоторых случаях программа будет завершаться с ошибкой concurrent map
writes (одновременная запись в ассоциативный массив). Если запустить ту же
команду с флагом --race, мы поймем, в чем дело:
$ go run --race race.go *.txt
==================
WARNING: DATA RACE
Read by goroutine 8:
runtime.mapaccess2_faststr()
/chapter5/hashmap_fast.go:281 +0x0
main.tallyWords()
/chapter5/race/race.go:62 +0x3ed
main.main.func1()
/chapter5/race/race.go:18 +0x66
160
Глава 5. Конкурентность в Go
Previous write by goroutine 6:
runtime.mapassign1()
/chapter5/hashmap.go:411 +0x0
main.tallyWords()
/chapter5/race/race.go:62 +0x48a
main.main.func1()
/chapter5/race/race.go:18 +0x66
Goroutine 8 (running) created at:
main.main()
/chapter5/race/race.go:22 +0x238
Goroutine 6 (running) created at:
main.main()
/chapter5/race/race.go:22 +0x238
==================
Итак, проблема возникает при вызове метода words.add. Несколько горутин
одновременно обращаются к одной и той же области памяти — ассоциативному
массиву words.found (обратите внимание на выделенные строки) и пытаются
ее изменить, что и вызывает состояние гонки.
В Go встроена система обнаружения состояний гонки
Многие инструменты Go, включая go run и go test, поддерживают использование
флага --race, который активирует режим обнаружения состояний гонки. Данный
режим существенно замедляет выполнение кода, но оказывается очень полезным
для выявления подобных ошибок на этапе разработки. Использование этого флага
в процессе рецензирования кода или в рамках системы непрерывной интеграции
является отличной практикой.
Если взглянуть на оригинальный код, можно легко найти причину проблемы.
При одновременном вызове add из нескольких горутин запускаются параллельные операции над одним и тем же ассоциативным массивом. Это прямой путь
к повреждению данных. Если не принять меры, результат работы программы
будет непредсказуемым или ошибочным.
Простейшее решение — заблокировать ассоциативный массив перед его модификацией и разблокировать сразу после завершения данной операции. Для
этого достаточно внести в код небольшие изменения, показанные в листинге 5.5.
Листинг 5.5. Подсчет слов с использованием блокировки
package main
import (
// Без изменений…
"sync"
)
5.2. Работа с горутинами
161
func main() {
var wg sync.WaitGroup
w := newWords()
if len(os.Args) < 2 {
fmt.Println("no files provided")
os.Exit(1)
}
for _, f := range os.Args[1:] {
wg.Add(1)
go func(file string) {
if err := tallyWords(file, w); err != nil {
fmt.Println(err.Error())
}
wg.Done()
}(f)
}
wg.Wait()
fmt.Println("Words that appear more than once:")
w.Lock()
defer w.Unlock()
}
Блокирует и разблокирует ассоциативный массив
после завершения итерации. Строго говоря,
в этом нет необходимости, поскольку данный
фрагмент кода будет выполнен только после
обработки всех файлов
for word, count := range w.found {
if count > 1 {
fmt.Printf("%s: %d\n", word, count)
}
}
Структура words теперь
type words struct {
включает в себя мьютекс
sync.Mutex
found map[string]int
}
func newWords() *words {
return &words{found: map[string]int{}}
}
func (w *words) add(word string) {
w.Lock()
Блокирует объект, изменяет ассоциативный
defer w.Unlock()
массив, а затем снимает блокировку
}
count, ok := w.found[word]
if !ok {
w.found[word] = 1
return
}
w.found[word] = count + 1
func tallyWords(filename string, dict *words) error {
// Код остался таким же, как в предыдущей версии
}
162
Глава 5. Конкурентность в Go
В обновленной версии кода структура words содержит анонимное поле, которое
ссылается на sync.Mutex, благодаря чему этой структуре становятся доступны
методы words.Lock() и words.Unlock(). Такой способ предоставления доступа
к мьютексу в рамках структуры применяется в Go довольно широко. (Эти методы
используются при переборе слов в конце main.)
Теперь в методе add мы блокируем объект, модифицируем ассоциативный
массив, а затем снимаем блокировку. Когда несколько горутин одновременно
вызывают add, первая осуществляет блокировку, а остальные дожидаются освобождения объекта. Это гарантирует, что в каждый момент времени только одна
горутина сможет вносить изменения в ассоциативный массив.
Важно понимать, что блокировки работают только в том случае, если весь доступ
к данным контролируется одним и тем же мьютексом. Если какие-то операции
выполняются без блокировки, состояние гонки все равно возможно.
Иногда бывает полезно разрешить одновременное выполнение нескольких
операций чтения одних и тех же данных, но ограничить запись только одной операцией, запретив любое чтение во время ее выполнения. Для этого
в Go предусмотрен тип sync.RWMutex. При использовании RWMutex можно применять метод RLock для установки блокировки, разрешающей неэксклюзивное
чтение, и RUnlock — для ее снятия. По сути, это замена методам Lock и Unlock.
В пакете sync есть и другие полезные инструменты для координации работы
горутин.
А теперь давайте обсудим еще один ключевой элемент модели конкурентности
Go — каналы.
5.3. Работа с каналами
Каналы позволяют передавать данные между горутинами в виде сообщений.
В этом разделе рассматриваются различные способы использования каналов
для решения типичных задач и распространенных проблем, возникающих при
формировании конкурентного кода.
В некотором смысле каналы напоминают сетевые сокеты. Две программы могут установить соединение через сетевой сокет. В зависимости от того, как они
написаны, передача данных по сети может быть однонаправленной или двунаправленной. Такие соединения бывают как короткоживущими, так и долгоживущими. «Умные» приложения способны даже использовать несколько сетевых
соединений, каждое из которых получает и отправляет различные типы данных.
Через сетевой сокет можно передавать любые данные, но перед этим их необходимо преобразовать в необработанные байты.
Каналы в Go работают подобно сокетам, позволяя горутинам внутри одного приложения обмениваться данными в асинхронном режиме. Как и сетевые сокеты,
каналы бывают одно- или двунаправленными, коротко- или долгоживущими.
5.3. Работа с каналами
163
В одном приложении часто используется несколько каналов — каждый для
своей категории данных. Однако, в отличие от сетевых соединений каналы
типизированы и могут передавать структурированные данные. При этом их
маршалинг, как правило, не требуется, поскольку Go самостоятельно управляет
процессом коммуникации. Давайте подробнее разберемся в устройстве каналов,
задействовав их в одном из рассмотренных ранее примеров.
5.3.1. Использование каналов
Go-разработчики часто подчеркивают, что каналы представляют собой средство
коммуникации, с помощью которого одна горутина может передавать данные
другой. Иногда лучший способ решения проблем, связанных с конкурентностью, — передавать больше информации, что часто означает использование
большего числа каналов.
Проблема. Мы хотим передавать данные от одной горутины другой через
канал и иметь возможность при необходимости прервать этот процесс.
Решение. Задействовать оператор select и несколько каналов. В Go каналы
часто используют, чтобы сигнализировать о завершении работы или готовности к закрытию того или иного ресурса.
Обсуждение. Чтобы познакомиться с принципом работы каналов, вернемся
к первому примеру из этой главы, в котором программа в течение 30 секунд
повторяла введенный пользователем текст. Это достигалось с помощью горутины, которая выводила полученную информацию, в то время как функция
main задерживала завершение программы с помощью time.Sleep. Давайте
перепишем программу, задействовав каналы наряду с горутинами.
Мы не стремимся добавить новую функциональность или улучшить первоначальный пример. Наша цель — предложить иной способ решения той же задачи,
но с использованием нового инструмента. Далее мы обсудим несколько идиоматических приемов работы с каналами.
Прежде чем перейти к коду в листинге 5.6, учтите следующее:
каналы инициализируются с помощью функции make, так же как ассоциативные массивы и срезы;
оператор <- используется и для указания направления передачи данных по
каналу (например, out chan<- []byte), а также для отправки или получения
данных через канал (buf := <-echo), — если направление не указано, канал
считается двунаправленным;
оператор select может отслеживать несколько каналов (от нуля и более).
Пока не произойдет какое-либо событие, он будет ждать (или выполнит
блок default, если он предусмотрен). Когда в одном из каналов произойдет
событие, select выполнит соответствующий блок кода. Подробнее о каналах
мы поговорим далее в главе.
164
Глава 5. Конкурентность в Go
Листинг 5.6. Использование каналов
package main
import (
"fmt"
"os"
"time"
)
Создает новый канал
для передачи байтов
из стандартного потока ввода
в стандартный поток вывода.
Так как размер канала не задан,
он может передавать только
одно сообщение за раз
func main() {
echo := make(chan []byte)
go readStdin(echo)
}
Запускает горутину, которая считывает
данные из стандартного потока ввода,
и передает их в созданный канал
Использует оператор select для передачи данных
из стандартного потока ввода в стандартный поток
вывода при их получении либо для завершения
работы по истечении времени ожидания
for {
select {
case buf := <-echo:
os.Stdout.Write(buf)
case <-time.After(30 * time.Second):
break
}
Считывает значение таймера time.After,
}
который запускается заново на 30 секунд
при получении нового сообщения
Принимает канал, предназначенный
только для записи (chan<–), и отправляет
в него все полученные данные
func readStdin(out chan<- []byte) {
Копирует данные из стандартного потока
for {
ввода в переменную data. Обратите
data := make([]byte, 1024)
внимание, что File.Read блокирует
l, _ := os.Stdin.Read(data)
выполнение до получения данных
if l > 0 {
out <- data
Передает считанные
}
данные через канал
}
}
Результат выполнения предыдущего фрагмента кода выглядит так:
$ go run echoredux.go
test 1
test 1
test 2
test 2
test 3
test 3
Timed out
Как и в предыдущей версии программы, если ввести текст test 1, он будет дублироваться, а через 30 секунд бездействия программа завершит работу.
Теперь же задействуются каналы. Первый канал создается с помощью пакета
time . Функция time.After формирует канал, который отправляет сообщение (объект time.Time ) по истечении заданного интервала времени. Вызов
time.After(30 * time.Second) возвращает канал <-chan time.Time (предназначенный только для чтения), получающий сообщение по истечении 30 секунд. Таким
5.3. Работа с каналами
165
образом, с практической точки зрения два способа приостановки выполнения,
реализованные в листинге 5.7, функционально эквивалентны. Если вынести
этот вызов в отдельное объявление переменной перед оператором select, можно
создать канал, реализующий тайм-аут для всей программы.
Листинг 5.7. Приостановка выполнения с помощью Sleep и After
package main
import (
"time"
)
Блокирует выполнение
func main() {
на пять секунд
time.Sleep(5 * time.Second)
sleep := time.After(5 * time.Second)
Создает канал, в который через пять секунд
<-sleep
будет отправлено уведомление, и блокирует
}
выполнение до его получения
Некоторые функции (в данном случае time.After) создают и инициализируют
каналы автоматически. Однако вы также можете создать канал самостоятельно
с помощью встроенной функции make.
По умолчанию каналы являются двунаправленными. Но, как показано в предыдущем примере, у нас есть возможность задать направление при передаче канала
в функцию (или при выполнении других операций присваивания). Например,
функция readStdin может только записывать данные в канал out. Попытка
чтения из этого канала приведет к ошибке компиляции. Обычно направление
передачи данных рекомендуется явно указывать в сигнатуре функции.
Еще одним важным элементом программы является оператор select, синтаксически напоминающий оператор switch. Он может содержать любое число веток
case, а также одну необязательную ветку default.
Оператор select проверяет каждую ветку и выясняет, может ли в ней быть выполнена операция отправки или получения данных. Если такая ветка только
одна, то выполняется именно она. Если их несколько, оператор select выбирает
одну случайным образом, а если ни одной — переходит к default. При отсутствии
ветки default оператор select блокирует выполнение до тех пор, пока одна из
ветвей не будет готова к отправке или получению данных.
В рассматриваемом примере оператор select ожидает получения сообщений
по двум каналам. Если сообщение поступает по каналу echo, оно сохраняется
в переменную buf (buf := <-echo), после чего записывается в стандартный поток
вывода. Таким образом, операция получения данных может сопровождаться
присваиванием полученного значения переменной.
Вторая ветвь, за которой следит оператор select, — это сообщение, передаваемое по каналу done. Поскольку содержимое сообщения нас не интересует,
166
Глава 5. Конкурентность в Go
то полученное значение не сохраняется в переменную. Его можно рассматривать
в качестве булева значения, интерпретируемого как «истинное», которое мы
просто считываем из канала, а затем отбрасываем (<-done).
СОВЕТ Если вам необходимо прослушивать только одну горутину, то вместо использования конструкции for/select вы можете перебирать значения в каналах
с помощью оператора range. Это особенно удобно в случае с буферизованными каналами. На практике такой прием встречается редко, поскольку обычно приходится
координировать работу нескольких каналов. Тем не менее некоторые инструменты
статического анализа рекомендуют избегать цикла select, если вы имеете дело только
с одним каналом.
В нашем примере у оператора select отсутствует ветка default, поэтому выполнение будет заблокировано, пока не придет сообщение по каналу <-echo или
<-done. После получения сообщения оператор select выполнит соответствующий
блок case и возвратит управление. Поскольку select обернут в бесконечный,
безусловный цикл for, его выполнение будет продолжаться, пока канал <-done
не получит сообщение, после чего программа завершится.
Одной из тем, которую мы еще не затрагивали, является закрытие каналов по
завершении работы с ними. В рассмотренном выше примере программа работает
не так долго, чтобы это имело значение, поэтому в данном случае мы полагаемся
на автоматическую очистку ресурсов средой выполнения. В следующем разделе
мы рассмотрим стратегию явного закрытия каналов.
5.3.2. Закрытие каналов
Как правило, Go-разработчики полагаются на сборщик мусора для освобождения
памяти. Когда переменная выходит за пределы области видимости, связанная
с ней память автоматически очищается. Однако при работе с горутинами и каналами следует проявлять осторожность. Например, если у вас есть горутинаотправитель и горутина-получатель и отправитель уже завершил передачу
данных, то получатель и сам канал не будут автоматически очищены. Сборщик
мусора удаляет только те значения, которые гарантированно больше не будут
использоваться. А в случае с открытым каналом и продолжающей работу горутиной такой гарантии нет.
Представьте, что некий код является частью более масштабной программы,
а функция main — обычной функцией, вызываемой многократно в течение жизни
приложения. Каждый раз при ее вызове создается новый канал и запускается
новая горутина. Но канал так и не закрывается, а горутина не завершает работу.
Такая программа неизбежно столкнется с утечкой ресурсов.
По этой причине очень важно правильно и безопасно освобождать ресурсы при
использовании каналов и горутин. Игнорирование этого вопроса может привести
5.3. Работа с каналами
167
к утечкам памяти, а также самих каналов и горутин, если они будут продолжать
потреблять ресурсы, не выполняя при этом полезной работы.
Проблема. Мы хотим предотвратить ситуацию, при которой неиспользуемые
каналы и горутины расходуют ресурсы и приводят к утечкам. Нам нужен
безопасный способ закрытия каналов и завершения работы горутин.
Решение. Чтобы избежать утечки ресурсов недостаточно просто закрыть канал и завершить выполнение горутины. Это необходимо сделать правильно,
иначе в программе может возникнуть паника или утечка горутин.
Самый надежный способ — использовать дополнительные каналы для уведомления горутин о том, что канал можно безопасно закрыть.
Обсуждение. Для безопасного закрытия каналов в Go существует несколько
идиоматических приемов.
Для начала рассмотрим пример, приведенный в листинге 5.8. Взяв за основу
код из листинга 5.6, мы создадим программу, в которой управление каналом
реализовано неправильно.
Листинг 5.8. Неправильное закрытие канала
package main
import (
"fmt"
"time"
)
func main() {
msg := make(chan string)
until := time.After(5 * time.Second)
go send(msg)
}
Запускает горутину send, использующую канал
для отправки сообщений
Цикл с оператором select ожидает сообщения от send или срабатывания таймера
for {
select {
При получении сообщения
case m := <-msg:
от send выводит его на экран
fmt.Println(m)
case <-until:
По истечении заданного срока завершает
close(msg)
работу программы. Добавление паузы
time.Sleep(500 * time.Millisecond)
позволяет заметить ошибку до завершения
return
работы главной горутины
}
}
Отправляет строку "hello"
в канал каждые полсекунды
func send(ch chan string) {
for {
ch <- "hello"
time.Sleep(500 * time.Millisecond)
}
}
168
Глава 5. Конкурентность в Go
Этот пример иллюстрирует проблему, которая может возникнуть в серверных
и других долго работающих приложениях. От такой программы можно было бы
ожидать вывода порядка десяти строк "hello" и завершения работы. Однако
при ее запуске вы увидите следующее:
$ go run bad.go
hello
hello
hello
hello
hello
hello
hello
hello
hello
hello
panic: send on closed channel
goroutine 20 [running]:
main.send(0x82024c060)
/chapter5/closing/bad.go:28 +0x4c
created by main.main
/chapter5/closing/bad.go:12 +0x90
goroutine 1 [sleep]:
time.Sleep(0x1dcd6500)
/usr/local/go/src/runtime/time.go:59 +0xf9
main.main()
/chapter5/closing/bad.go:20 +0x24f
exit status 2
Как видите, в программе возникает паника, поскольку функция main закрывает
канал msg, в то время как горутина send все еще передает в него данные. Попытка
отправить значение в закрытый канал вызывает панику. Чтобы избежать подобных ситуаций, функцию close следует вызывать только на стороне отправителя,
причем с использованием защитных механизмов.
Если закрыть канал на стороне отправителя, паника не возникнет, но произойдет
нечто интересное (листинг 5.9).
Листинг 5.9. Закрытие канала на стороне отправителя
package main
import (
"log"
"time"
)
func main() {
ch := make(chan bool)
timeout := time.After(600 * time.Millisecond)
go send(ch) //
Цикл с оператором select, который
for { //
обрабатывает два канала и ветку default
select {
case m, ok := <-ch: //
При получении сообщения
через основной канал
выводит его на экран
5.3. Работа с каналами
}
}
169
if !ok { //
Если канал к этому моменту
log.Println("Channel closed.")
уже закрыт, выводится
return
соответствующее сообщение
}
log.Println("Got message:", m)
case <-timeout: //
При срабатывании таймера
log.Println("Time out")
программа завершает работу
return
Ветвь
default реализует небольшую
default: //
паузу, упрощающую работу с примером
log.Println("*yawn*")
time.Sleep(100 * time.Millisecond)
}
Отправляет одно сообщение
по каналу, а затем закрывает его
func send(ch chan bool) { //
time.Sleep(120 * time.Millisecond)
ch <- true
close(ch)
log.Println("Sent and closed")
}
После запуска этого кода можно было бы ожидать, что основной цикл несколько
раз обработает ветку default, получит одно сообщение от горутины send, еще
несколько раз обработает ветку default, после чего сработает тайм-аут и программа завершит работу. Однако на практике мы увидим следующее:
$ go run sendclose.go
*yawn*
*yawn*
*yawn*
*yawn*
Got message.
Got message.
Sent and closed
*yawn*
Sent and closed
*yawn*
Got message.
Got message.
Got message.
Got message.
… # и еще тысячи подобных сообщений
Time out
Это происходит потому, что при чтении из закрытого канала всегда возвращается нулевое значение соответствующего типа. В нашем случае горутина send
отправляет одно значение true, а затем закрывает канал. Каждый раз, когда
оператор select обращается к каналу ch после его закрытия, он получает false
(значение nil для логического типа).
Чтобы обойти эту проблему, можно выйти из цикла for/select, как только будет
получено false через канал ch. Иногда это необходимо. Однако более надежный
170
Глава 5. Конкурентность в Go
способ заключается в том, чтобы явно сообщить о завершении работы с каналом,
а затем закрыть его.
Мы можем переписать листинг 5.8, добавив канал для передачи сигнала о завершении работы, позволяющем обеим сторонам корректно закрыть канал
(листинг 5.10).
Листинг 5.10. И
спользование канала для передачи сигнала
о завершении работы
package main
import (
"log"
"time"
)
Создает канал
для передачи
сообщений
func main() {
msg := make(chan string)
//
done := make(chan bool)
//
go send(msg, done)
//
for {
select {
case m := <-msg:
//
log.Println(m)
case <-time.After(5 * time.Second): //
done <- true
return
}
}
}
Создает логический
канал, сигнализирующий
о завершении работы
Передает оба канала горутине send
Выводит сообщения
по мере их поступления
По истечении заданного
времени уведомляет send
о завершении процесса
func send(ch chan<- string, done <-chan bool) {
for {
При получении сигнала по каналу
select {
done завершает работу
case <-done:
//
log.Println("Done")
close(ch)
Через канал ch передаются
return
сообщения, а через канал done —
default:
сигнал о завершении работы
ch <- "hello"
time.Sleep(500 * time.Millisecond) //
Завершает работу при получении
}
сигнала по каналу done
}
}
Данный пример демонстрирует широко распространенный в Go прием: использование отдельного канала (часто называемого done) для передачи сигнала
между горутинами. Обычно в рамках этой схемы одна горутина получает сообщения, а другая — их отправляет. При наступлении условий для завершения
работы получатель должен уведомить об этом отправителя. Такую схему можно
распространить и на другие типы сообщений, передачу которых необходимо
обособить от основного потока данных, например, при реализации логирования.
5.3. Работа с каналами
171
В листинге 5.10 функция main определяет момент завершения обработки, но при
этом она же выступает в роли получателя. А как уже было сказано, получатель
не должен закрывать канал. Вместо этого он отправляет сигнал по каналу done,
сообщая о завершении своей работы. Получив сигнал, функция send может закрыть канал и завершить выполнение.
Не злоупотребляйте каналами
Каналы — это отличный инструмент для организации взаимодействия между горутинами. Они просты в использовании и существенно облегчают конкурентное программирование по сравнению с моделями многопоточности, принятыми в других
популярных языках.
Однако здесь важно не переусердствовать, поскольку использование каналов влечет
за собой дополнительные накладные расходы и усложняет архитектуру программы.
И, что особенно важно, каналы являются основным источником проблем, связанных
с управлением памятью в Go. Как и любой другой инструмент, каналы следует использовать только тогда, когда в этом действительно есть необходимость.
5.3.3. Блокировка с помощью буферизованных каналов
До сих пор мы рассматривали небуферизованные каналы, которые вмещают
по одному значению за раз и создаются следующим образом: make(chan ТИП).
Если в такой канал отправлено значение, которое еще не было считано, то при
попытке отправить следующее операция блокируется. При этом отправитель
останется в заблокированном состоянии вплоть до фактического считывания
данных из канала.
Изменить это поведение можно с помощью буферизованных каналов.
Проблема. В особенно чувствительном фрагменте нашего кода необходимо
заблокировать доступ к определенным ресурсам. Учитывая активную работу
с каналами в нашем проекте, мы хотим реализовать блокировку именно через
них, а не с помощью пакета sync.
Решение. Использовать канал с размером буфера 1 и сделать его общим для
всех горутин, подлежащих синхронизации.
Обсуждение. В листинге 5.4 для реализации блокировки применялись интерфейс sync.Locker и тип sync.Mutex. Пакет sync входит в стандартную
библиотеку Go, он хорошо отлажен и активно поддерживается. Однако
в некоторых случаях, особенно если в коде уже используются каналы, реализовать блокировку бывает предпочтительнее именно через них, а не через
мьютекс. Часто это бывает обусловлено стилистическими соображениями
и стремлением обеспечить максимальное единообразие кода. Кроме того,
такой подход может повысить производительность за счет блокировки на
уровне буфера.
172
Глава 5. Конкурентность в Go
Использование канала в качестве блокировки предполагает следующее поведение функции.
1. Она «захватывает» блокировку, отправляя сообщение в канал.
2. Выполняет необходимые операции с данными.
3. «Освобождает» блокировку, считывая сообщение из канала.
4. Любая другая функция, попытавшаяся захватить блокировку до ее освобо
ждения, будет дожидаться возможности это сделать.
Такой сценарий невозможно реализовать с помощью небуферизованного канала.
Уже на первом шаге функция заблокируется, поскольку небуферизированные
каналы при отправке всегда ждут, пока получатель не прочитает переданное
сообщение.
Буферизованный канал, напротив, позволяет отправить сообщение и не блокирует операцию отправки, если в буфере есть свободное место. Если же буфер
заполнен, отправитель будет ждать, пока оно не освободится.
Это как раз то поведение, которое нам нужно. Мы создаем канал с буфером,
размер которого равен 1. Функция отправляет в него значение, выполняет необходимые действия, а затем извлекает значение из буфера, тем самым снимая
блокировку. Такой сценарий реализован в листинге 5.11.
Листинг 5.11. Простая блокировка с помощью каналов
package main
import (
"log"
"time"
)
func main() {
lock := make(chan bool, 1)
for i := 1; i < 7; i++ {
go worker(i, lock)
}
time.Sleep(10 * time.Second)
}
//
//
//
Создает канал с размером
буфера, равным 1
Запускает до шести горутин,
использующих общий канал
Одна из горутин захватывает блокировку,
отправляя сообщение в канал. Остальные
func worker(id int, lock chan bool) {
дожидаются своей очереди
log.Printf("%d wants the lock\n", id)
lock <- true
//
log.Printf("%d has the lock\n", id) //
Участок кода между lock <- true
<-lock
//
и <-lock считается заблокированным
log.Printf("%d is releasing the lock\n", id)
этой горутиной
}
Снимает блокировку, считывая значение из канала. Это освобождает
буфер и позволяет следующей горутине захватить блокировку
5.3. Работа с каналами
173
Схема очень проста и предусматривает один шаг для захвата блокировки и один
шаг для ее снятия. Запустив программу, вы получите примерно следующий
вывод:
$
2
1
2
5
6
4
3
2
1
1
5
5
6
6
3
3
4
4
go run lock.go
wants the lock
wants the lock
has the lock
wants the lock
wants the lock
wants the lock
wants the lock
is releasing the
has the lock
is releasing the
has the lock
is releasing the
has the lock
is releasing the
has the lock
is releasing the
has the lock
is releasing the
lock
lock
lock
lock
lock
lock
В примере видно, как шесть горутин поочередно захватывают и освобождают
блокировку. В течение первых нескольких миллисекунд после запуска программы все шесть горутин пытаются получить доступ к каналу. Однако это
удается только горутине с номером 2. Через несколько сотен миллисекунд она
освобождает блокировку, и ее получает горутина 1. Такой обмен продолжается
до тех пор, пока последняя горутина (с номером 4) не снимет блокировку. (Стоит
отметить, что сборщик мусора в Go автоматически очистит канал после того,
как на него перестанут ссылаться.)
Этот пример демонстрирует способ, позволяющий нескольким конкурентным
операциям совместно обрабатывать один и тот же фрагмент данных, избегая
возникновения состояний гонки, но без использования мьютексов, о которых
мы говорили ранее (см. листинг 5.5).
В листинге 5.11 показано одно из преимуществ использования буферизованных
каналов: они предотвращают блокировку операций отправки, пока в буфере
остается свободное место. Указав размер буфера, мы задаем желаемую длину
очереди. Например, сценарий, в котором блокировка может одновременно
захватываться двумя горутинами, можно реализовать с помощью канала
с буфером размером 2. Буферизованные каналы также часто применяются
для построения очередей сообщений и конвейеров обработки данных. Они
не являются полноценной заменой мьютексов, но используются для решения
схожих задач.
174
Глава 5. Конкурентность в Go
Резюме
Для реализации конкурентности в Go используется модель взаимодействующих последовательных процессов (CSP).
Горутины предоставляют базовый механизм для создания и управления
конкурентными функциями.
Использование пакета sync для приостановки выполнения кода и блокировки ресурсов позволяет обеспечить целостность данных при выполнении
нескольких операций над ними в конкурентном режиме.
Каналы служат механизмом передачи данных между горутинами и вызывающим кодом.
Корректное закрытие каналов предотвращает утечки памяти. В этой главе мы
рассмотрели идиоматический прием с использованием специальных каналов
done для передачи сигнала о том, что другой канал должен быть закрыт.
6
Форматирование,
тестирование, отладка
и оценка производительности
В этой главе
3 Создание хорошо отформатированного и удобочитаемого кода
с актуальными зависимостями.
3 Формирование надежных и структурированных логов
для приложения.
3 Написание модульных тестов и разработка стратегий построения
тестовых наборов.
3 Анализ производительности кода и выявление узких мест
и проблемных участков.
Язык Go предусматривает целый набор встроенных инструментов, помогающих
поддерживать чистоту, производительность и актуальность кода. Вы уже знаете,
что утилита go позволяет собирать и запускать пакеты, однако, помимо этого, она
предлагает множество других возможностей для повышения надежности кода
и его подготовки к использованию в рабочей среде. Как правило, это предполагает тщательное тестирование его функциональности и производительности.
Все нужные инструменты для этого уже входят в стандартный набор Go, что
избавляет вас от необходимости использовать стороннее ПО.
В этой главе мы подробно рассмотрим способы решения таких задач, как статический анализ кода (линтинг), актуализация импортов, ведение логов в соответствии с лучшими практиками, а также тестирование и оценка производительности
176
Глава 6. Форматирование, тестирование, отладка и производительность
(бенчмаркинг). Мы также обсудим возможности популярных интегрированных
сред разработки (integrated development environments, IDE), которые помогают
подготовить приложения к развертыванию в рабочей среде и минимизировать
количество ошибок, возникающих на этапе эксплуатации.
Другие инструменты Go
В этой главе мы поговорим об инструментах benchmark, cover, fmt, test и vet,
однако в арсенале Go есть и другие средства статического анализа, с которыми вы
можете ознакомиться самостоятельно по ссылке https://pkg.go.dev/golang.org/x/tools.
6.1. Поддержание чистоты кода
и структуры проекта
Встроенные инструменты Go помогают не только собирать и распространять
код, но и поддерживать его чистоту и единообразие. В отличие от многих других
языков Go предлагает готовые решения для форматирования и поддержания
высокого качества кода. Если вам доводилось участвовать в бесконечных спорах по поводу стиля оформления кода, написанного на других языках, то вы
наверняка оцените подход Go. В сообществе Go хватает тем для дискуссий, но
вы вряд ли увидите запрос на фиксацию изменений с замечаниями о правилах
форматирования или расположения кода.
6.1.1. Форматирование кода
Мы упоминали go fmt в первой главе и надеемся, что вы уже активно используете этот инструмент для организации и форматирования кода в характерном
для Go стиле. Если вы до сих пор не включили go fmt в повседневный рабочий
процесс, сейчас самое время это сделать. Правила написания идиоматического
Go-кода уже согласованы, так что, если вы планируете делиться своим кодом
с другими разработчиками, их соблюдение является фактически обязательным.
Далее мы расскажем, как интегрировать этот и другие аналогичные инструменты
в рабочий процесс вашей IDE, но для начала давайте просто познакомимся с fmt
и его возможностями.
Инструмент go fmt помогает устранять мелкие и на первый взгляд незаметные
недочеты из вашего кода, например убирает лишние пробелы внутри блоков.
Что касается более субъективных решений, вроде добавления дополнительных
символов новой строки, их оставляют на усмотрение программиста.
Стоит отметить, что команда go fmt фактически является псевдонимом отдельного инструмента gofmt, который предоставляет дополнительные возможности
для просмотра вносимых в код изменений. Хотя вы всегда можете воспользоваться системой контроля версий, например Git, чтобы просмотреть и при необходимости отменить изменения, связанные с форматированием, gofmt также
6.1. Поддержание чистоты кода и структуры проекта
177
предусматривает флаг -d, с помощью которого можно отобразить различия между
исходной и отформатированной версиями без внесения правок в код. Давайте
рассмотрим пример неудачно отформатированного кода и то, что предложит
инструмент fmt (листинг 6.1).
Листинг 6.1. Форматирование неудачно оформленного кода
package main
import "fmt"
import "os"
func main() {
fmt.Println( "checking for environment variable CLEANUP")
Лишний
пробел
}
if envvar := os.GetEnv("CLEANUP"); envvar != "" {
fmt.Println(" did not find it, value is:"
,
envvar);
Лишняя точка
}
с запятой
Вот тот же фрагмент кода после применения команды gofmt --d myfile.go:
-- myfile.go.orig
+++ myfile.go
@@ -1,16 +1,13 @@
package main
import "fmt"
import "os"
-
func main() {
fmt.Println( "checking for environment variable CLEANUP")
+
+
+
}
if envvar := os.GetEnv("CLEANUP"); envvar != "" {
fmt.Println(" did not find it, value is:", envvar);
}
fmt.Println("checking for environment variable CLEANUP")
if envvar := os.GetEnv("CLEANUP"); envvar != "" {
fmt.Println(" did not find it, value is:", env var)
}
Лишний пробел
Лишняя точка
с запятой
Пробел удален
Точка с запятой
удалена
Как видите, все лишние пробелы и ненужные точки с запятой удалены. Отступы
заданы с помощью табуляции в соответствии со спецификацией Go, благодаря
чему код будет выровнен одинаково у всех, кто использует инструмент fmt
в рамках своего рабочего процесса. Обратите внимание, что некоторые решения,
такие как раздельное объявление импортов и добавление пустых строк внутри
функций, оставлены на усмотрение разработчика. В то же время избыточные
пустые строки между импортами или функциями удаляются автоматически.
Поначалу может показаться непривычным, что стиль написания кода диктует сам
язык, однако на практике это очень полезно, поскольку исчезают бесконечные
178
Глава 6. Форматирование, тестирование, отладка и производительность
споры по поводу оформления. Как выразился соавтор языка Go Роб Пайк на
конференции Gopherfest в 2015 году: «Стиль gofmt никому не нравится, но сам
gofmt нравится всем». Отсутствие необходимости задумываться о форматировании, линтинге и стилистике существенно облегчает задачу быстрого написания
чистого кода.
Использование goimports
Как было сказано в главе 1, go fmt — это отличный инструмент, но его можно заменить
на goimports — отдельную утилиту, разработанную командой Go. Она предоставляет
весь функционал gofmt, а также автоматически подключает недостающие сторонние
пакеты и удаляет неиспользуемые, предотвращая потенциальные ошибки компиляции, связанные с рефакторингом или устаревшим кодом.
Для установки этого инструмента используйте команду: go install golang.org/x/
tools/cmd/goimports@latest. А для использования: goimports [FILE_PATTERN].go.
Интегрировать gofmt или goimports в рабочий процесс IDE довольно легко.
В GoLand от JetBrains для этого нужно выбрать пункт меню PreferencesToolsFile
Watchers (НастройкиИнструментыСредства для отслеживания изменений
файлов) и настроить автоматический запуск goimports при сохранении любого
файла с расширением .go. Поддержка языка Go в IDE обеспечивается с помощью
gopls — отдельного компонента, предназначенного для IDE, работающих с протоколом LSP (Language Server Protocol — протокол языкового сервера) (рис. 6.1).
Рис. 6.1. Добавление goimports в Goland
6.1. Поддержание чистоты кода и структуры проекта
179
В Visual Studio Code (VS Code) для достижения аналогичного результата достаточно перейти в раздел: SettingsText EditorFormatting (НастройкиТекстовый
редакторФорматирование) и установить флажок Format on Save (Форматировать при сохранении) (рис. 6.2).
Рис. 6.2. Включение форматирования при сохранении в VS Code
6.1.2. Выявление типичных ошибок с помощью go vet
Помимо go fmt и gofmt, в наборе инструментов Go есть еще одна полезная
утилита — go vet , которая позволяет выявлять в коде ошибки и неэффективные конструкции, не мешающие его компиляции, но способные привести
к неожиданному поведению. Например, go vet может обнаружить проблему
маршалинга, когда вместо указателя было передано значение, некорректно
написанные тесты, необходимость замены небуферизованных каналов буферизованными, а также использование неправильных спецификаторов формата
в командах printf.
Эта утилита обладает широкими возможностями, но подвержена ложным срабатываниям. В связи с этим ее стоит использовать не на постоянной основе, а на
этапе финальной проверки кода перед его сборкой и публикацией.
Вы можете выполнить отдельные проверки на предмет наличия конкретных
ошибок или применить инструмент vet ко всей кодовой базе с помощью команды
go tool vet yourcode.go, где yourcode — файл с вашим кодом.
Рассмотрим два примера ошибок, которые нелегко обнаружить, поскольку они
не всегда проявляют себя. Первый связан с контекстами — их используют в Go
для управления разделяемым состоянием и соответствующими сигналами. Контексты позволяют задавать дедлайны, тайм-ауты и посылать сигналы отмены,
чтобы контролировать жизненный цикл операций. Чаще всего они применяются
в конкурентном коде, особенно в серверных приложениях. Поскольку контексты хранятся в памяти, несвоевременная отмена может привести к ее утечке,
заметить которую будет непросто.
180
Глава 6. Форматирование, тестирование, отладка и производительность
В листинге 6.2 мы оборачиваем обработчики HTTP-сервера в контекст, чтобы
получить возможность передавать конфигурационные данные без использования глобальных переменных. Такой подход нередко применяется при создании
промежуточного слоя веб-сервера (middleware), который мы реализуем в главе 8
с помощью данного приема.
Листинг 6.2. Утечка контекста
package main
import (
"context"
"fmt"
"net/http"
)
type config struct {
HomepageDescription string
Pageviews
int64
}
Создает
func main() {
конфигурацию
c := config{
HomepageDescription: "my 1997-style personal web site",
Pageviews:
0,
}
Создает контекст
Добавляет конфигурацию
в контекст в качестве
ctx, _ := context.WithCancel(context.Background())
значения
ctx = context.WithValue(ctx, "webConfig", c)
}
http.HandleFunc("/home", homeHandler(ctx))
http.HandleFunc("/guestbook", guestbookHandler(ctx))
http.ListenAndServe(":8081", nil)
Извлекает значение
из контекста и приводит
его к типу конфигурации
func homeHandler(ctx context.Context) http.HandlerFunc {
myValue := ctx.Value("webConfig").(config) // #D
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, fmt.Sprintf("welcome to %s",
➯ myValue.HomepageDescription))
}
}
func guestbookHandler(ctx context.Context) http.HandlerFunc {
myValue := ctx.Value("webConfig").(config) // #D
return func(w http.ResponseWriter, r *http.Request) {
myValue.Pageviews++
fmt.Fprintln(w, fmt.Sprintf("welcome to my guestbook. hit counter since
➯ server restart: %v", myValue.Pageviews))
}
}
6.1. Поддержание чистоты кода и структуры проекта
181
В этом примере используется несколько новых приемов. Вместо того чтобы явно
определять функцию-обработчик handlerFunc для каждого маршрута, мы применяем метод-обертку, который вызывается для каждого запроса и возвращает
соответствующую функцию handlerFunc. Внутри обертки мы извлекаем значение из контекста, приводим его к нужному типу конфигурации и используем
в качестве общих конфигурационных данных. Это далеко не лучший способ
передачи общего состояния, но он позволяет проиллюстрировать проблему
утечки контекста. В данном случае у нас есть контекст с возможностью отмены,
который мы так и не отменяем:
ctx, _ := context.WithCancel(context.Background())
Если запустить инструмент go vet в каталоге проекта, он выдаст предупреждение:
$ ./context_noclose.go:19:7: the cancel function returned by context.WithC
Чтобы избежать утечки контекста, следует вызвать функцию cancel, а не игнорировать ее. В листинге 6.3 продемонстрирована еще одна проблема, часто
возникающая при кодировании данных, для корректного выполнения которого
необходимо использовать публичные (экспортируемые) поля.
Листинг 6.3. Некорректный маршалинг JSON
package main
import (
"encoding/json"
"fmt"
)
type user struct {
username string `json:"username"`
Email
string `json:"email"`
firstName string
}
Наша структура содержит
два приватных поля
func main() {
m := user{
username: "manning_go",
Email:
"email@example.com",
firstName: "Joe",
}
Функция маршалинга
выполняется без ошибок
out, err := json.Marshal(m)
if err != nil {
panic("could not marshal")
}
}
fmt.Println(string(out))
182
Глава 6. Форматирование, тестирование, отладка и производительность
Инструмент go vet успешно обнаруживает проблему:
$ ./vet_marshal.go:9:2: struct field username has json tag but is not exported
и поясняет, почему получившийся JSON-файл не соответствует ожиданиям: поле
структуры username содержит тег json, но не является экспортируемым (публичным). При этом go vet не считает проблемным поле firstName, поскольку оно
не имеет тега json. Запуск go vet может сэкономить вам немало времени и избавить
от мелких, но досадных ошибок. Так что его стоит периодически задействовать
в процессе разработки или даже включить в состав стандартного инструментария.
6.1.3. Обновление зависимостей
Наконец, стоит упомянуть о важных, но порой упускаемых из виду функциях
go mod. Ранее мы уже говорили о команде go mod init, с помощью которой создается файл зависимостей go.mod. Но что, если вам нужно обновить или выбрать
конкретную версию зависимости?
Если вам требуется не самая последняя версия библиотеки, то можно просто
указать нужный тег в формате семантического версионирования, например 0.1.1.
Это можно сделать в любой момент после использования go mod init с помощью команды: go get http://[yourpackage]/here@0.1.1, где yourpackage — ваш
пакет. Если такой тег существует в выбранном репозитории, будет загружена
соответствующая версия.
Для простого обновления всех зависимостей используются две команды: go get
-u и go mod tidy. Флаг -u означает update («обновление»), а go mod tidy повторно
обходит граф зависимостей, проверяя их совместимость и соответствие требованиям проекта. Если вы хотите обновить только одну конкретную зависимость,
введите команду: go get -u http://[yourpackage], где yourpackage — ваш пакет.
Учтите, что при этом могут быть обновлены и транзитивные зависимости. Чтобы
просмотреть результат изменения, выполните команду go mod graph.
ПРИМЕЧАНИЕ Инструмент go get также поддерживает флаг -t, который учитывает
пакеты, необходимые для тестирования.
6.2. Логирование
Прежде чем перейти к теме тестирования, стоит поговорить о таком важном
условии эффективной отладки и анализа кода, как ведение журналов, или логирование, которое помогает быстрее находить сведения о работе программы.
Включение средства для логирования в стандартную библиотеку языка является
довольно необычным решением. В большинстве языков способы форматирования и хранения логов оставлены на усмотрение разработчика. Конечно, и в Go
можно реализовать собственную систему ведения журналов. Однако создатели
6.2. Логирование
183
языка прекрасно понимали важность этой задачи и потому предусмотрели для
ее решения целый набор встроенных инструментов.
Для эффективной отладки приложений важно сохранять информацию об их
работе не только в консоль, но и в постоянные хранилища. В этом разделе мы
поговорим о записи логов в файл и в оперативную память, а также обсудим
способы взаимодействия с такими системами инициализации, как systemd,
systemctl и Supervisor.
Пакет log из стандартной библиотеки предоставляет простую систему логирования, которая использует интерфейсы, аналогичные методам вывода из пакета
fmt. Это означает, что многие вызовы fmt.Println можно заменить на log.Println
для отправки отформатированных сообщений журнала в стандартный поток
вывода (листинг 6.4).
Листинг 6.4. Простая система логирования
package main
import (
"fmt"
"log"
)
func main() {
fmt.Println("This is fmt package output")
log.Println("This is log package output")
}
Как видите, благодаря такой замене сообщения журналов становятся более
информативными:
$ go run logging2.go
This is fmt package output
2023/08/29 15:52:24 This is log package output
Мы рекомендуем использовать log вместо fmt при выводе сообщений в консоль,
даже если вы не собираетесь их сохранять. Чем информативнее сообщение, тем
проще потом понять, что пошло не так и когда.
Формат сообщений, используемый по умолчанию, выглядит неплохо, но что,
если вы хотите задать собственный? Метод SetFlags позволяет объединить несколько флагов, чтобы добиться желаемого результата (листинг 6.5).
Листинг 6.5. Настройка формата логов
package main
import (
"log"
)
184
Глава 6. Форматирование, тестирование, отладка и производительность
Выводит время в виде префикса
func main() {
к сообщению журнала
log.SetFlags(log.Ltime)
log.Println("Only show the time")
Выводит имя файла
с полным путем
log.SetFlags(log.Llongfile)
log.Println("Show the full filename")
}
Выводит только
имя файла
log.SetFlags(log.LUTC | log.Lshortfile)
log.Println("Display in UTC and use a short filename")
Этот код предоставляет следующие варианты оформления сообщений:
$ go run logging_formats.go
09:46:29 Only show the time
/usr/go/code/logging/logging_formats.go:12: Show the full filename
logging_formats.go:15: Display in UTC and use a short filename
Комбинируя флаги, можно получить большинство популярных форматов. В сообщения можно включить, например, номера строк и имена файлов. Кроме того,
для обеспечения большей гибкости можно создать собственный логгер, реализовав
интерфейс Writer. В листинге 6.6 показано, как сделать вывод более выразительным.
Листинг 6.6. Подробный лог с цветовой кодировкой
package main
import (
"fmt"
"log"
"runtime"
"strings"
"time"
)
type myLogger struct{}
func (l myLogger) Write(msg []byte) (int, error) {
pc := make([]uintptr, 50)
n := runtime.Callers(0, pc)
pc = pc[:n]
frames := runtime.CallersFrames(pc)
caller := ""
frameCount := 0
Использует пакет runtime для определения
вызывающей функции
for {
frameCount++
fr, hasMore := frames.Next()
Присваивает имя
if hasMore {
вызывающей функции
caller = fr.Function
} else {
break
}
}
6.2. Логирование
185
output := fmt.Sprintf("%s%s - %s%s (called from %s%s)",
"\033[32m",
time.Now().Format("2006/01/02 3:04:05 pm"),
"\033[0m",
strings.TrimSpace(string(msg)),
"\033[35m",
caller,
Выводит сообщение в пользовательском формате
)
с указанием вызывающей функции и цветовыми кодами
}
return fmt.Println(output)
func main() {
myLog := new(myLogger)
log.SetFlags(0)
log.SetOutput(myLog)
Сбрасывает все прочие флаги
форматирования журнала
go concurrentLog()
}
for i := 0; i < 10; i++ {
log.Println(fmt.Sprintf("run #%d", i+1))
time.Sleep(1 * time.Second)
}
func concurrentLog() {
for i := 0; i < 2; i++ {
log.Println(fmt.Sprintf("concurrent run #%d", i+1))
time.Sleep(5 * time.Second)
}
}
Здесь мы добавили цветовую разметку вывода в терминале через управляющие
ASCII-коды, указали собственный формат временной метки и извлекли имя
вызывающей функции. В терминалах, поддерживающих цветовое оформление
вывода, это помогает визуально отделять важную информацию и облегчает
восприятие кода (рис. 6.3).
Рис. 6.3. Красиво оформленный вывод
186
Глава 6. Форматирование, тестирование, отладка и производительность
Обратите внимание на фрагмент SetFlags(0). Он означает полный отказ от
встроенного форматирования и позволяет нам задать все параметры оформления вывода самостоятельно. Получившийся результат демонстрирует гибкость,
доступную при настройке собственного формата журнала. На практике в большинстве случаев вам будет достаточно стандартного форматирования. А если
вы планируете распространять свое программное обеспечение, то вам следует
придерживаться таких общепринятых форматов, как RFC 5424.
ПРИМЕЧАНИЕ Рабочая группа IETF (Internet Engineering Task Force — Инженерный совет Интернета) разрабатывает и поддерживает стандарты ведения журналов.
Подробнее о формате RFC 5424 можно узнать по адресу https://datatracker.ietf.org/
doc/html/rfc5424.
СОВЕТ Имейте в виду, что не все терминалы поддерживают управляющие цветовые
коды. При принудительном включении цветового оформления пользователи могут
столкнуться с «шумом» из управляющих последовательностей, а при сохранении
логов в файл эти коды будут записаны буквально. Если вы все же решите добавить
поддержку цветов, сделайте ее по умолчанию неактивной и предусмотрите возможность ее включения, например, с помощью флага командной строки.
Как правило, в логах по умолчанию формируются лишь два типа сведений: время
и место события. Для настройки временных меток доступны следующие флаги:
Ldate — добавляет дату;
Ltime — добавляет время;
Lmicroseconds — добавляет микросекундную точность и автоматически
устанавливает флаг Ltime;
LstdFlags — включает одновременно Ldate и Ltime.
Для указания места события используются следующие флаги:
Llongfile — выводит полный путь к файлу и номер строки (например, /foo/
bar/baz.go:123);
Lshortfile — выводит только имя файла и номер строки (например,
baz.go:123).
Хотя флаги можно комбинировать с помощью логического оператора ИЛИ (|),
некоторые из них несовместимы, — например, Llongfile и Lshortfile.
6.2.1. Отправка логов в разные каналы вывода
До сих пор мы выводили логи только на экран. Но, чтобы иметь возможность вернуться к ним позднее, нам следует рассмотреть способы их долговременного хранения.
Самый простой и распространенный способ сохранения логов — это их запись
в файл. Ранее мы использовали метод SetOutput для вывода логов на экран, но
6.2. Логирование
187
поскольку он работает с любым типом, реализующим интерфейс Writer, мы
можем перенаправить вывод в файл.
ПОДСКАЗКА Согласно принципам методологии Twelve-Factor App, журнал стоит
рассматривать как поток событий, например направляя его в стандартный поток
вывода. Запись в файл (или в память) тоже может быть полезной, поэтому мы и рассматриваем эти варианты, однако в большинстве случаев следует перенаправлять
вывод средствами оболочки или использовать специализированных демонов, отвечающих за обработку журналов. Простейшее перенаправление можно реализовать
так: go run [file].go &> file.log, где file — имя файла. Попробуйте применить это
к нашему логгеру с поддержкой цветовой кодировки, чтобы увидеть, какие проблемы могут возникнуть, если не предусмотреть возможность отключения цветового
оформления вывода.
Любой логгер представляет собой просто объект Writer, то есть тип, реализующий метод Write, принимающий срез байтов и возвращающий число записанных
байтов и ошибку (при ее наличии). В Go есть несколько встроенных типов, реа
лизующих Writer, включая обработчики файлов. Это означает, что для записи
логов в файл достаточно просто указать этот файл в качестве получателя вывода.
Проблема. Мы хотим сохранять журнал без перенаправления вывода в оболочке и использования сторонних демонов. В идеале он должен быть доступен
через файловую систему и/или в оперативной памяти.
Решение. Использовать для вывода логов любой тип Writer с помощью метода
Write, например os.File.
Обсуждение. Создавая собственные типы (см. главу 3), мы можем гибко
настраивать формат и способ хранения логов. При этом сообщения могут
одновременно записываться в несколько мест и оформляться именно так,
как нам нужно.
Листинг 6.7 демонстрирует простейшую альтернативу выводу логов на экран,
а именно их сохранение в файл.
Листинг 6.7. Простой файловый логгер package main
package main
import (
"log"
"os"
)
func main() {
file, err := os.OpenFile("logging.log",
os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
panic("could not open log file")
}
Создает или открывает файл
для чтения и записи
188
}
Глава 6. Форматирование, тестирование, отладка и производительность
Задает файл в качестве
Добавляет к каждой записи
места хранения логов
дату и время в формате UTC
log.SetOutput(file)
и короткое имя файла
log.SetFlags(log.LUTC | log.Lshortfile)
log.Println("Display in UTC and use a short filename")
В данном примере мы указываем в качестве получателя вывода файловый обработчик, возвращаемый функцией OpenFile. Однако метод SetOutput принимает
любой объект, реализующий интерфейс Writer, поэтому изменить место сохранения логов не составит труда. Журналы также можно сохранять во временное
хранилище типа «ключ — значение» вроде Redis.
6.2.2. Структурированное логирование
Хотя стандартный пакет log предоставляет все необходимое для отслеживания
ключевых событий в коде, команда разработчиков Go учла запрос пользователей
на более мощные средства и добавила в версию 1.21 поддержку структурированного логирования.
Структурированное логирование отличается от обычного тем, что позволяет
различать сообщения по уровню важности, например info или error. Кроме
того, как следует из названия, оно поддерживает структурированные форматы
файлов, что особенно полезно при работе с инструментами для анализа логов.
Помните, мы обсуждали форматы и стандарты логирования? Так вот, нам
не нужно парсить данные вручную! Многие современные приложения работают
с логами в формате JSON, что устраняет множество проблем. Все это позволяет
быстрее находить нужные данные в журналах.
Проблема. Сохранение логов в виде обычного текста может приводить к неоднозначности форматов и типов сообщений. Структурированное логирование обеспечивает лучшую читаемость и более удобный парсинг данных
за счет единообразного формата и возможности включать дополнительный
контекст. Именно поэтому такой подход широко используется в автоматизированных инструментах.
Решение. Заменить стандартную библиотеку log на новую библиотеку slog,
поддерживающую уровни логирования и автоматический маршалинг данных
в распространенные форматы.
Обсуждение. Можно ли реализовать все вышеперечисленное средствами
стандартной библиотеки log? Конечно. Но это потребовало бы значительных
усилий или подключения стороннего пакета. Сегодня пользователи ожидают,
что логи будут удобны для поиска, анализа и автоматической обработки.
Структурированное логирование позволяет сделать их именно такими.
Чтобы оценить возможности библиотеки slog, создадим несколько сообщений
с разными уровнями важности (листинг 6.8).
6.2. Логирование
189
Листинг 6.8. Уровни логирования
package main
import (
"log/slog"
)
func main() {
slog.Info("this is default logging")
slog.Warn("keep an eye on this, it might be an issue")
slog.Error("oh no, an error happened here!")
slog.Debug("this is good while developing ...")
}
Запустив этот код, мы увидим, что вывод стал более подробным:
$ go run structured_logging.go
2023/08/31 11:37:30 INFO this is default logging
2023/08/31 11:37:30 WARN keep an eye on this, it might be an issue
2023/08/31 11:37:30 ERROR oh no, an error happened here!
Любопытно, что в логе отсутствуют сообщения slog.Debug. Это и есть ключевое преимущество slog: мы можем указать, какие сообщения следует включать
в журнал, а какие — нет. Обычно сообщения уровня debug выводятся только
в промежуточной или локальной среде. Мы можем задать нужный уровень,
создав собственный логгер по аналогии с тем, как это делалось в стандартной
библиотеке. Кроме того, мы можем сразу настроить вывод в формате JSON
(листинг 6.9).
Листинг 6.9. Структурированное логирование в формате JSON
package main
import (
"log/slog"
"os"
)
func main() {
Создает файл, в который
file, err := os.OpenFile("structured.log",
будут записываться логи
os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
panic("could not open log file for writing")
}
logger := slog.New(slog.NewJSONHandler(file,
&slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
Задает файл и уровень
логирования
190
Глава 6. Форматирование, тестирование, отладка и производительность
slog.Info("this is default logging")
slog.Warn("keep an eye on this, it might be an issue")
slog.Error("oh no, an error happened here!")
slog.Debug("this is good while developing ...")
}
Пример вложенных
slog.Info(
лог-сообщений
"this is a more complex message",
slog.String("accepted_values", "key/value pairs with
specific types for marshalling"),
slog.Int("an int:", 30),
slog.Group("grouped_info",
slog.String("you_can", "do this too"),
),
)
В этом примере используется несколько новых концепций. В первую очередь
мы создаем пользовательский логгер (как делали ранее с помощью стандартной
библиотеки log) и задаем для HandlerOptions уровень debug, что гарантирует
включение в журнал всех отладочных сообщений.
В последней строке с Info показано, как можно легко собирать JSON-структуры
без необходимости создавать отдельные структуры Go и вручную прописывать
JSON-теги. Пакет slog позволяет группировать записи по типам и уровням
иерархии, не оформляя все это в виде структуры с JSON-тегами, что существенно
упрощает и ускоряет процесс логирования в формате JSON.
Наконец, обратите внимание, что выходной файл является не полноценным
JSON-документом, а потоком сообщений в формате JSON, также называемым
JSONS (JSON Streaming — потоковая передача JSON). В нашем случае это
просто последовательность строк JSON, разделенных символами новой строки,
но существуют и другие варианты. Такой потоковый формат поддерживается
многими платформами для логирования и аналитики.
СОВЕТ Если вам нужен простой способ работы с JSON и JSONS, мы настоятельно
рекомендуем инструмент jq, который предлагает мощные и быстрые средства для выборки и обработки данных в этих форматах. Более подробные сведения и инструкцию
по установке можно найти здесь: https://jqlang.github.io/jq.
6.2.3. Получение доступа к трассировке стека
и ее захват
Многие языки программирования позволяют получить доступ к стеку вызовов.
Трассировка (или дамп) стека — это удобный для чтения список функций, находящихся в активном состоянии в момент захвата стека. Представьте себе программу, в которой функция main вызывает foo, а та, в свою очередь, — bar. Если
функция bar выведет трассировку стека, то она будет состоять из трех вызовов:
текущей функции bar, вызванной foo, которую вызвала main.
6.2. Логирование
191
Трассировки стека дают разработчику важную информацию о текущем состоянии системы. Они особенно полезны при логировании и отладке. Язык Go
позволяет получить трассировку стека на любом этапе выполнения программы.
Проблема. Требуется получить трассировку стека в критический момент
выполнения программы.
Решение. Использовать пакет runtime, который предоставляет соответствующие инструменты.
Описание. Генерация дампов стека в Go не является сложной задачей, если
знать, как это сделать. Но сам вопрос «Как его получить?» возникает довольно часто. Если вам нужна простая трассировка для отладки, вы можете
отправить ее в стандартный поток вывода с помощью функции PrintStack
из пакета runtime/debug, как показано в листинге 6.10.
Листинг 6.10. Отправка трассировки стека в стандартный поток вывода
package main
import (
"runtime/debug"
)
func main() {
foo()
}
func foo() {
bar()
}
Определяет несколько функций
для включения в трассировку
func bar() {
debug.PrintStack()
}
Выводит трассировку стека
При запуске программа выведет трассировку стека наподобие следующей:
$ go run trace.go
/chapter6/stack/trace.go:20 (0x205b)
bar: debug.PrintStack()
/chapter6/stack/trace.go:13 (0x203b)
foo: bar()
/chapter6/stack/trace.go:9 (0x201b)
main: foo()
/usr/local/Cellar/go/1.22/libexec/src/runtime/proc.go:63 (0x12983)
main: main_main()
/usr/local/Cellar/go/1.22/libexec/src/runtime/asm_amd64.s:2232 (0x37711)
goexit:
Такой способ весьма полезен для простой отладки. Однако, если нужно сохранить трассировку или передать ее кому-то другому, воспользуйтесь функцией
Stack из пакета runtime, как показано в листинге 6.11.
192
Глава 6. Форматирование, тестирование, отладка и производительность
Листинг 6.11. Использование функции Stack
package main
import (
"fmt"
"runtime"
)
func main() {
foo()
}
func foo() {
bar()
}
Создает
func bar() {
буфер
buf := make([]byte, 1024)
Записывает стек в буфер
runtime.Stack(buf, false)
fmt.Printf("Trace:\n %s\n", buf)
Выводит результат на экран
}
В этом примере трассировка отправляется в стандартный поток вывода, но вы
с таким же успехом можете сохранить ее в лог или отправить в хранилище. Вот
результат выполнения приведенного выше кода:
$ go run trace.go
Trace:
goroutine 1 [running]:
main.bar()
/Users/mbutcher/Code/go-in-practice/chapter5/stack/trace.go:18 +0x7a
main.foo()
/Users/mbutcher/Code/go-in-practice/chapter5/stack/trace.go:13 +0x1b
main.main()
Обратите внимание: трассировка, полученная таким способом, обычно короче
из-за отсутствия системных вызовов более низкого уровня. Приведем несколько
важных замечаний по поводу этого метода.
При использовании функции Stack необходимо заранее выделить буфер
фиксированного размера. Однако определить, какой объем памяти потребуется для захвата всей трассировки, непросто. (Иногда вывод может оказаться
настолько объемным, что вы, возможно, не захотите сохранять его целиком.)
Иначе говоря, объем памяти под буфер придется задавать, исходя из предполагаемой необходимости.
Функция Stack принимает два аргумента. Вторым является логический флаг,
который в данном примере установлен в значение false. Если установить его
в true, функция возвратит трассировки для всех работающих горутин. Это
бывает очень полезно при отладке проблем, связанных с конкурентным выполнением кода, но приводит к значительному увеличению объема вывода.
Например, для приведенного выше кода он может занять целую страницу.
6.3. Модульное тестирование в Go
193
Если и этих средств окажется недостаточно, можно воспользоваться функциями
Caller, Callers и CallersFrames из пакета runtime, которые мы уже применяли
ранее для программного анализа стека. Несмотря на то что такие вызовы требуют
больше кода для получения и форматирования информации, они позволяют
более гибко подходить к исследованию конкретного стека. Кроме того, пакеты
runtime и runtime/debug содержат множество других функций для анализа
уровня потребления памяти, работы горутин, потоков и других аспектов работы
вашей программы.
6.3. Модульное тестирование в Go
Тестирование — это обширный и многогранный этап процесса разработки приложений. Существуют разные виды тестирования, направленные на проверку
поведения, доступности, интеграции и многого другого. В этом разделе основное
внимание мы уделим модульному тестированию и встроенным средствам Go,
позволяющим создавать и запускать тесты прямо в коде.
6.3.1. Создание тестового набора
с использованием табличного подхода
Если не считать нескольких соглашений, упрощающих поиск тестов и тестовых
файлов, язык Go не навязывает строгих правил использования пакета testing.
Вы можете писать отдельные тестовые функции для разных сценариев, задействовать как статические данные, так и случайно сгенерированные (например,
для фаззинг-тестирования), останавливать весь процесс тестирования при
непрохождении одного из тестов, включенных в набор, или продолжать его
выполнение.
Одной из рекомендуемых практик является использование так называемого табличного подхода, в рамках которого тестовые сценарии описываются
в виде среза входных данных, необходимых для передачи функции и оценки
ее результатов. Это позволяет описать сразу множество ситуаций и проверить
работу функции в каждой из них в рамках одного цикла. Такие тесты называются табличными из-за их наглядной структуры. Соблюдение принципа «одна
функция — один тест» заметно упрощает сопровождение подобных тестов.
Нередко для каждого .go-файла (а иногда и для отдельной функции) создается
собственный файл с тестами.
Рассмотрим этот подход на примере простой задачи Fizz Buzz, хорошо знакомой
многим разработчикам. Вот ее условия.
1. Функция принимает целое число.
2. В цикле перебираются значения от 1 до этого числа.
3. Создается массив строк.
194
Глава 6. Форматирование, тестирование, отладка и производительность
4. Для каждого числа в последовательности выполняется проверка:
если оно делится на 3 и 5, в массив добавляется строка Fizz Buzz;
если оно делится только на 3, в массив добавляется строка Fizz;
если оно делится только на 5, в массив добавляется строка Buzz;
в остальных случаях в массив добавляется само число, преобразованное
в строку.
5. В результате возвращается массив строк или объединенная строка.
В листинге 6.12 приводится базовая реализация этой функции, которую мы
будем использовать для построения модульного теста.
Листинг 6.12. Простой алгоритм Fizz Buzz
package main
import (
"fmt"
"strings"
"strconv"
)
func main() {
var input string
fmt.Println("Enter a number for fizzbuzz")
fmt.Scanln(&input)
Получает пользовательский
ввод в виде строки, подлежащей
преобразованию в целое число
numInput, err := strconv.ParseInt(input, 10, 16)
if err != nil {
panic("that's not a number!")
}
}
result := fizzbuzz(numInput)
fmt.Println("result:", result)
Вызывает функцию
func fizzbuzz(n int64) string {
var fizzbuzzes []string
for i := int64(0); i < n; i++ {
v := ""
isThree := i % 3 == 0
isFive := i % 5 == 0
if isThree && isFive {
v = "Fizz Buzz"
} else if isThree {
v = "Fizz"
} else if isFive {
v = "Buzz "
Алгоритм Fizz Buzz
6.3. Модульное тестирование в Go
195
} else {
v = fmt.Sprintf("%d", i)
}
}
}
fizzbuzzes = append(fizzbuzzes, v)
return strings.Join(fizzbuzzes, " ")
Если запустить эту функцию и проверить результат, на первый взгляд он может
показаться корректным:
Enter a number for fizzbuzz
11
result: Fizz Buzz 1 2 Fizz 4 Buzz
Fizz 7 8 Fizz Buzz
Число 3 преобразуется в Fizz, 5 — в Buzz и так далее. Однако такой визуальный
способ проверки не отличается надежностью. Чтобы убедиться в корректной
работе функции, нам следует создать модульный тест с использованием табличного подхода. Для начала нам потребуется срез структур, каждая из которых
будет содержать входное значение и ожидаемый результат. В данном случае
все очень просто: мы подаем нашей функции целое число и получаем строку.
Возьмем пример вывода из статьи Википедии, посвященной алгоритму Fizz Buzz:
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19,
Buzz, Fizz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz, ...
Судя по всему, придется остановиться на числе 36. Поэтому первый тест в нашей
таблице будет выглядеть так:
tests := []struct {
input int64
expected string
} {
{37, "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14,
Fizz Buzz, 16, 17, Fizz, 19, Buzz, Fizz, 22, 23, Fizz, Buzz, 26, Fizz,
28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz"},
}
У нас пока только один тест, но для начала этого достаточно. Теперь нам нужно импортировать пакет testing и задать имя тестовой функции с префиксом
Test. Обычно это делается так: TestFunctionName или Test_FunctionName, где
FunctionName — имя функции. В листинге 6.13 приведен весь тестовый файл,
содержащий один модульный тест.
Листинг 6.13. Тест для алгоритма Fizz Buzz
package main
import (
"testing"
)
196
Глава 6. Форматирование, тестирование, отладка и производительность
func Test_FizzBuzz(t *testing.T) {
tests := []struct {
Структура tests с входными данными
input
int64
и ожидаемым результатом
expected string
}{
{
37,
"1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14,
➯ Fizz Buzz, 16, 17, Fizz, 19, Buzz, Fizz, 22, 23, Fizz, Buzz, 26,
➯ Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz",
},
}
}
Выполняет каждый тест
for i := range tests {
test := tests[i]
res := fizzbuzz(test.input)
if res != test.expected {
t.Fatalf("\ngot \n%s \nexpected \n%s", res, test.expected)
}
Фатальная ошибка при непрохождении
}
любого из тестов
Запустить этот код можно с помощью команды go test или go test [TARGET_FILE],
где TARGET_FILE — имя целевого файла, либо через IDE. В листинге 6.14 приведен
результат его выполнения.
Листинг 6.14. Результат тестирования
$ go test
--- FAIL: Test_FizzBuzz (0.00s)
tabletests_test.go:20:
got
FizzBuzz 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBu
zz 16 17 Fizz 19 Buzz Fizz 22 23 Fizz Buzz 26 Fizz 28 29 FizzBuz
z 31 32 Fizz 34 Buzz Fizz
expected
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fiz
z Buzz, 16, 17, Fizz, 19, Buzz, Fizz, 22, 23, Fizz, Buzz, 26, Fizz
, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz
FAIL
exit status 1
FAIL
tabletests
0.112s
Как видите, мы сразу получаем сообщение о фатальной ошибке. Вообще, выбрасывать такую ошибку имеет смысл только тогда, когда провал теста делает
невозможным прохождение последующих тестов или когда они зависят друг от
друга. Но, поскольку у нас всего один тест, лучше вывести более лаконичный
вывод при первом же сбое.
Итак, в нашем коде есть две ошибки. Во-первых, цикл начинает перебор с элемента с индексом 0, тогда как в примере из Википедии последовательность начинается с 1. Во-вторых, мы не включаем целевое число в диапазон перебираемых
6.3. Модульное тестирование в Go
197
в цикле значений, что приводит к возникновению так называемой ошибки «на
единицу» — одной из самых распространенных в программировании. Также у нас
был лишний пробел после слова Buzz, выводимого вместо числа, кратного пяти.
И наконец, при объединении строк мы использовали пробел в качестве разделителя, но забыли про запятую. После устранения этих мелких недочетов и повторного запуска теста мы получаем гораздо более обнадеживающий результат.
$ go test
PASS
ok
tabletests
0.102s
Теперь добавление новых тестов сводится к простому расширению среза с входными значениями и ожидаемыми результатами.
{
}
{37, "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14,
Fizz Buzz, 16, 17, Fizz, 19, Buzz, Fizz, 22, 23, Fizz, Buzz, 26, Fizz,
28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz, 37"},
{5, "1, 2, Fizz, 4, Buzz"},
{12, "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz"},
В данном случае добавление новых тестов не имеет смысла, поскольку все
типичные сценарии уже учтены. Однако если бы функция принимала еще и начальное значение, то подобный подход мог бы оказаться более полезным. Так или
иначе приведенный выше срез уже представляет собой полноценную таблицу
тестов — набор сценариев, с помощью которых можно убедиться в корректной
работе функции.
6.3.2. Фаззинг-тестирование
Предыдущие тесты работают нормально, поскольку программист заранее знает,
какой результат должен быть получен при заданных входных данных. Однако
число возможных вариантов в таких тестах ограниченно, и эффективность этого
подхода полностью зависит от способности разработчика формировать тестовые
случаи, опираясь на свою ментальную модель.
Для задач вроде FizzBuzz подобный метод вполне приемлем, поскольку они
практически полностью исключают возможность возникновения нетипичных
граничных случаев. Однако было бы неплохо, если бы мы могли генерировать
тестовые случаи автоматически, учитывая то, что в реальных условиях заранее
предугадать все попросту невозможно. Как говорится, вы не знаете того, чего
не знаете.
Фаззинг-тестирование (fuzz testing) — это стратегия, позволяющая подготовиться к таким непредвиденным ситуациям и минимизировать их последствия.
Она предполагает автоматическую генерацию/модификацию входных данных
функции с целью выявления неожиданных граничных случаев.
198
Глава 6. Форматирование, тестирование, отладка и производительность
Ценность фаззинг-тестирования
Фаззинг особенно полезен в качестве средства защиты от потенциально вредоносных входных данных. Хотя полагаться только на него не стоит, с его помощью
можно сгенерировать строки, позволяющие эксплуатировать уязвимости, например
чрезмерно длинные, содержащие специальные символы или особые команды (как
в случае SQL-инъекций).
Генерируя случайные входные данные, мы имеем больше шансов воспроизвести
поведение, характерное для злоумышленников, стремящихся обнаружить потенциально эксплуатируемые проблемы. Если программа дает сбой при получении
конкретных входных данных, это явный признак наличия уязвимости.
Как вы, вероятно, уже догадались, основная проблема фаззинг-тестов заключается в том, что для них нельзя задать ожидаемый результат. Здесь нас интересуют не конкретные ответы, а ситуации, при которых возникают ошибки или
неожиданные форматы выходных данных.
В листинге 6.15 реализован потенциально проблемный сценарий и создается
функция, которая последовательно перебирает все символы строки, получает
их код (или руну), суммирует эти коды и возвращает итоговый результат.
Листинг 6.15. Функция, суммирующая коды символов строки
package main
import (
"fmt"
"strings"
)
func summedRuneCodes(input string) int16 {
value := 0
inRunes := strings.Map(func(r rune) rune {
return r
}, input)
for r := range inRunes {
value += int(inRunes[r])
}
}
Получает ASCII-код
символа
return int16(value)
func main() {
var testString = "i am a test string"
output := summedRuneCodes(testString)
fmt.Println(output)
}
Преобразует строку
в срез рун
6.3. Модульное тестирование в Go
199
Этот алгоритм является крайне хрупким. Мы предполагаем, что итоговая сумма
всегда уместится в типе int16, но даже если заменить его на int32 или int64,
проблема никуда не денется. Если итоговая сумма превысит максимальное
число, допускаемое типом int16, произойдет переполнение. Похожие ошибки
возникают и при использовании чисел с плавающей точкой.
В наших тестах данная ошибка не проявляется, потому что мы не задействуем
очень длинные строки. В этом заключается типичная проблема ручного тестирования. (Чуть позже мы еще к ней вернемся.) Несмотря на приложенные
усилия, нам так и не удалось спровоцировать сбой программы, как показано
в листинге 6.16.
Листинг 6.16. Неудачные попытки вызвать сбой
func TestSummedRuneCodes(t *testing.T) {
tests := []struct {
name
string
input
string
expected int16
}{
{ "test 1", "i am trying things", 1729 },
{ "test 2", "doing my best to find a way to break this!", 3772 },
{ "test 3", "even adding emojis or unicode doesn't break it, I'm
sure I'm fine
", 6065 },
}
}
t.Run("my rune sum tests", func(t *testing.T) {
for k := range tests {
test := tests[k]
t.Run(test.name, func(t *testing.T) {
if got := summedRuneCodes(test.input); got != test.expected {
t.Errorf("expected %d, got %d", test.expected, got)
}
})
}
})
Мы могли бы добавить еще десятки тестов и все равно упустить граничные
случаи. И вот здесь нам на помощь приходит фаззинг. Достаточно сделать лишь
одно предположение о возвращаемом функцией значении и выявить те ошибки,
которые нам не удалось воспроизвести вручную.
В данном примере мы можем обоснованно предположить, что итоговая сумма
всегда должна быть больше или равна нулю (в случае пустой строки). Фаззингтестирование позволяет быстро выявить ошибку. Чтобы его реализовать, мы
добавим в тестовый файл функцию Fuzz, как показано в листинге 6.17.
200
Глава 6. Форматирование, тестирование, отладка и производительность
Листинг 6.17. Выявление ошибок с помощью фаззинг-тестирования
func FuzzSummedRuneCodes(f *testing.F) {
tests := []string{
"i am trying things",
"doing my best to find a way to break this!",
"even adding emojis or unicode doesn't break it, I'm sure I'm fine
}
",
for t := range tests {
f.Add(tests[t])
}
}
f.Fuzz(func(t *testing.T, seed string) {
got := summedRuneCodes(seed)
if got < 0 {
t.Errorf("how did this happen? somehow we got %d from string
%s", got, seed)
}
})
Главное отличие заключается в том, что нас интересует не конкретный результат,
а обобщенный проблемный случай. Для запуска фаззинг-тестирования можно
использовать команду go test --fuzz=Fuzz. Уже при первом запуске вы, скорее
всего, увидите ошибку, показанную на рис. 6.4.
Рис. 6.4. Фаззинг приходит на помощь
Это именно то, что нам было нужно! Мы вряд ли смогли бы сами придумать эту
случайную, «шумную» тестовую строку. Но Go перебрал множество вариантов
за нас и нашел ошибку.
Обратите внимание еще на один важный момент в последнем тесте: хотя риск переполнения существует при любом размере типа int и Go никак этому не препятствует, существуют пакеты, позволяющие заранее выявлять такие ошибки. Однако
в нашем примере самый быстрый способ — привести значение к следующему по
размеру целочисленному типу и проверить, совпадает ли оно с исходным. Разумеется,
также можно использовать для возвращаемого значения беззнаковый тип uint16.
6.3. Модульное тестирование в Go
201
Генерация тестовых случаев с помощью больших
языковых моделей
Мы уже говорили о том, что искусственный интеллект и большие языковые модели
(LLM) могут помочь при написании кода, и тестирование — еще одна область, где
эта технология может оказаться крайне полезной. Если задать ожидаемый результат
работы функции, количество и формат тестовых случаев, ИИ-инструмент запросто
сгенерирует множество разнообразных вариантов, причем порой лучше и быстрее,
чем сам разработчик.
Для задачи Fizz Buzz модели вроде ChatGPT успешно возвращают тестовые случаи
в нужном формате, в том числе в виде структур Go, если правильно сформулировать
запрос.
Примеры тестовых случаев, предложенных моделью ChatGPT
Кроме того, мы можем попросить LLM сгенерировать потенциальные граничные
случаи, чтобы потом вручную составить соответствующие тесты. Разумеется, любые
результаты, полученные от такой модели, следует проверять, поскольку она может
ошибиться.
202
Глава 6. Форматирование, тестирование, отладка и производительность
6.3.3. Именование тестов
Вы, вероятно, заметили, что в листинге 6.16 каждому тестовому случаю было
присвоено имя. Удобный способ сделать вывод результатов тестирования нагляднее — воспользоваться методом Run, позволяющим рассматривать каждый
тест в качестве именованного подтеста. Это дает возможность организовать один
большой набор проверок с вложенными независимыми подтестами. Давайте
обернем выполнение нашей предыдущей таблицы тестов в такую конструкцию
и посмотрим на результат (листинг 6.18).
Листинг 6.18. Улучшение оформления и именование тестов
tests := []struct {
name
string
Добавляет имя для каждого тестового случая
input
int64
expected string
}{
{"fizz buzz test1", 37, "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz,
Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19, Buzz, Fizz,
22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34,
Buzz, Fizz, 37"},
{"fizz buzz test2", 5, "1, 2, Fizz, 4, Buzz"},
{"fizz buzz test3", 12, "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz,
Buzz, 11, Fizz"},
}
for i := range tests {
test := tests[i]
t.Run(test.name, func(t *testing.T) {
Оборачивает подтесты в t.Run
res := fizzbuzz(test.input)
if res != test.expected {
t.Fatalf("\ngot \n%s \nexpected \n%s", res, test.expected)
}
})
}
Если мы запустим тест с флагом -v, то получим вот такой красиво оформленный
результат:
% go test -v
=== RUN
Test_FizzBuzzSubTest
=== RUN
Test_FizzBuzzSubTest/fizz_buzz_test1
=== RUN
Test_FizzBuzzSubTest/fizz_buzz_test2
=== RUN
Test_FizzBuzzSubTest/fizz_buzz_test3
--- PASS: Test_FizzBuzzSubTest (0.00s)
--- PASS: Test_FizzBuzzSubTest/fizz_buzz_test1 (0.00s)
--- PASS: Test_FizzBuzzSubTest/fizz_buzz_test2 (0.00s)
--- PASS: Test_FizzBuzzSubTest/fizz_buzz_test3 (0.00s)
PASS
ok
subtests
0.129s
Мы можем видеть имена отдельных тестов в общем списке результатов их выполнения. Во многих случаях вместо малоинформативного названия вроде fizz
6.3. Модульное тестирование в Go
203
buzz test 1 имеет смысл использовать более описательные имена, например should
generate [some result], где some result — некоторый результат. Основная причина,
по которой это стоит делать, — наличие поддержки со стороны IDE. Современные
среды, такие как GoLand от IntelliJ или VS Code, умеют выделять и отображать
такие имена по отдельности при запуске тестов прямо из интерфейса.
6.3.4. Проверка тестового покрытия с помощью go cover
Наличие тестов крайне важно. В наших примерах рассматривались только
одна функция и соответствующий ей модульный тест. Однако в большинстве
серьезных проектов используются сотни, а порой и тысячи независимых функций. Выбор функций для тестирования — это целая наука. Однако определить
степень покрытия кода тестами можно довольно легко с помощью флага cover.
В листинге 6.19 показано простейшее приложение, содержащее три фиктивные
функции, каждая из которых возвращает некоторое целое число.
Листинг 6.19. Приложение с тремя фиктивными функциями
package main
func main() {}
func foo() int {
return 1
}
func bar() int {
return 2
}
func baz() int {
return 3
}
Соответствующий тестовый код выглядит следующим образом:
package main
import (
"testing"
)
func TestFoo(t *testing.T) {
t.Run("testing foo", func(t *testing.T) {
foo()
})
}
func TestBar(t *testing.T) {
t.Run("testing foo", func(t *testing.T) {
bar()
})
}
204
Глава 6. Форматирование, тестирование, отладка и производительность
А результат запуска команды go test -v -cover — так:
=== RUN
TestFoo
=== RUN
TestFoo/testing_foo
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/testing_foo (0.00s)
=== RUN
TestBar
=== RUN
TestBar/testing_foo
--- PASS: TestBar (0.00s)
--- PASS: TestBar/testing_foo (0.00s)
PASS
coverage: 66.7% of statements
ok
covertest
0.320s
Степень покрытия показывает, насколько полно функции и реализации вашего
приложения охвачены тестами. Это дает некоторое представление о том, что уже
протестировано, а что — нет. Однако мы можем пойти еще дальше и применить
флаг -coverprofile, который позволяет сохранить отчет о покрытии в специальный файл и затем открыть его в браузере (или в другом инструменте) для
более наглядного анализа.
В нашем случае мы использовали команду go test -v -cover -coverprofile
mycover.out для генерации отчета и команду go tool cover -html mycover.out
для его отображения. Окно браузера открывается автоматически (рис. 6.5).
Рис. 6.5. Отчет о покрытии кода тестами, открытый в браузере
Такое представление позволяет визуально оценить степень покрытия кода
тестами. Стремиться к 100%-ному охвату, возможно, и не стоит (и даже существуют доводы в пользу того, чтобы этого не делать), но такой способ помогает
в общих чертах понять, какие части приложения уже проверены, а какие — нет.
Скорее всего, ваша IDE поддерживает аналогичные возможности, однако иногда тестирование бывает полезно мысленно отделить от остального процесса
разработки, и Go предоставляет встроенные средства, позволяющие быстро
выявлять непокрытые тестами участки кода.
6.4. Оценка и оптимизация производительности
205
6.3.5. Подождите, а где отладчик?
Когда речь заходит об отладке, излюбленным инструментом многих разработчиков, как ни странно, является отладчик. Этот замечательный инструмент
позволяет выполнять код пошагово в удобном для вас темпе.
Но прежде, чем углубиться в эту тему, стоит отметить один нюанс. Несмотря
на богатый набор средств для разработчиков, язык Go до сих пор не имеет
полноценного встроенного отладчика. Создатели Go сосредоточились на других
аспектах, а ближайшим к «официальному» решением считается подключаемый
модуль GNU Debugger (GDB). С его помощью действительно можно отлаживать
Go-код, однако его надежность оставляет желать лучшего.
СОВЕТ Хорошее руководство по настройке GDB для дальнейшей отладки Go-про
грамм доступно на официальном сайте Go https://go.dev/doc/gdb.
В тех редких случаях, когда официальная сборка Go не предусматривает нужных инструментов, на помощь приходит сообщество. Одним из отладчиков для
кода на Go является Delve (https://github.com/go-delve/delve). Его можно собрать
вручную, но он также встроен в большинство современных IDE. Например, он
входит в состав расширения Go для VS Code.
6.4. Оценка и оптимизация производительности
В завершение обсуждения темы тестирования и отладки имеет смысл коснуться
практик обеспечения качества Go-кода. В состав инструментария Go входят
средства для тестирования и оценки производительности. Они особенно полезны в тех случаях, когда функция выполняет нечто потенциально трудоемкое
с алгоритмической точки зрения. В качестве демонстрации давайте рассмотрим
реализацию одного из наименее эффективных алгоритмов сортировки (листинг 6.20).
Листинг 6.20. Пузырьковая сортировка
package main
import (
"fmt"
)
func main() {
sorted := bubbleSort([]int{20, 19, 3, 75, 1, 7, 4, 17})
fmt.Println(sorted)
}
func bubbleSort(in []int) []int {
sorted := false
Алгоритм пузырьковой сортировки многократно перебирает массив и меняет
for !sorted {
местами пары элементов, расположенных в неправильном порядке
206
Глава 6. Форматирование, тестирование, отладка и производительность
sorted = true
for i := 1; i < len(in); i++ {
if in[i-1] > in[i] {
in[i-1], in[i] = in[i], in[i-1]
sorted = false
}
}
}
}
return in
Хотя мы и так знаем, что этот алгоритм не отличается высокой эффективностью,
нам важно иметь возможность количественно оценить его работу. Для этого
нужно добавить тест производительности (бенчмарк) в соответствующий файл
_test.go. В листинге 6.21 мы выполним небольшую настройку и подготовим
вспомогательную функцию. Затем мы применим алгоритм к срезам случайных
целых чисел длиной 10, 100, 1000, 10 000 и 100 000. Имейте в виду, что генерация
требует времени, однако по сравнению с затратами на саму сортировку это несущественные издержки. Для каждого случая мы запустим соответствующую
функцию тестирования производительности.
Листинг 6.21. Н
абор бенчмарков для алгоритма пузырьковой
сортировки
package main
import (
"math/rand"
"testing"
"time"
)
func runBenchmark(arr []int, runs int) {
for i := 0; i < runs; i++ {
bubbleSort(arr)
}
}
func generateRandoms(num int) []int {
out := make([]int, num)
for k := range out {
out[k] = rand.Intn(num-1) + 1
}
return out
}
func BenchmarkBubbleSort10(b *testing.B) {
runBenchmark(generateRandoms(10), b.N)
}
Вызов функции
пузырьковой сортировки
Функция, создающая срез
случайных целых чисел
Первый бенчмарк со срезом
из десяти элементов
6.4. Оценка и оптимизация производительности
207
func BenchmarkBubbleSort100(b *testing.B) {
runBenchmark(generateRandoms(100), b.N)
}
func BenchmarkBubbleSort1000(b *testing.B) {
runBenchmark(generateRandoms(1000), b.N)
}
func BenchmarkBubbleSort10000(b *testing.B) {
runBenchmark(generateRandoms(10000), b.N)
}
func BenchmarkBubbleSort100000(b *testing.B) {
runBenchmark(generateRandoms(100000), b.N)
}
Для запуска бенчмарков используется все тот же инструмент go test, но с флагом -bench PATTERN, где PATTERN — регулярное выражение для поиска функций
бенчмарка, которые нужно выполнить. Точка (.) указывает на необходимость
запустить все бенчмарки. Чтобы выполнить тест с наименьшим срезом чисел из
нашего примера, можно использовать команду: -bench=BenchmarkBubbleSort10$.
Запуск всего набора позволяет точно оценить степень неэффективности алгоритма:
BenchmarkBubbleSort10-8
BenchmarkBubbleSort100-8
BenchmarkBubbleSort1000-8
BenchmarkBubbleSort10000-8
BenchmarkBubbleSort100000-8
248036485
36479984
3704248
288595
1
4.703 ns/op
33.06 ns/op
323.5 ns/op
3517 ns/op
13268521541 ns/o
Как видите, за выделенный период времени последний тест производительности
был выполнен лишь один раз, а версия с десятью элементами — почти 250 миллионов раз. Для управления продолжительностью теста можно использовать
флаг -benchtime, задающий минимальное время выполнения бенчмарка (по
умолчанию пять секунд). Увеличив это значение (например, до десяти секунд),
мы получим больше данных. Если алгоритм выполняет множество операций,
связанных с выделением памяти, то для сбора соответствующей статистики
имеет смысл добавить флаг -benchmem.
СОВЕТ В экспериментальном пакете Go есть утилита benchstat, позволяющая
проводить A/B-тестирование и выполнять статистический анализ результатов нескольких прогонов тестов производительности. Подробнее об этом инструменте
можно узнать здесь: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat.
В следующих главах мы поговорим о работе с файловой системой и сетевых
взаимодействиях, после чего перейдем к созданию полноценных HTTP-серверов,
пригодных для эксплуатации.
208
Глава 6. Форматирование, тестирование, отладка и производительность
Резюме
Go предусматривает целый набор инструментов, помогающих форматировать
код и поддерживать его в упорядоченном и актуальном состоянии.
Средства логирования позволяют разработчикам создавать как стандартные,
так и пользовательские записи, а также структурированные и произвольно
оформленные логи.
Тестирование — полноценный элемент инструментария Go, позволяющий
с легкостью писать и запускать модульные тесты.
Помимо простейших модульных тестов, этот инструмент позволяет выполнять фаззинг-тестирование, то есть генерировать входные данные, которые
разработчик мог не предусмотреть.
Для дополнительной оценки эффективности и ресурсоемкости алгоритмов
можно использовать тесты производительности или бенчмарки.
7
Доступ к файлам и основы
сетевого взаимодействия
В этой главе
3 Управление файлами локально и по сети.
3 Организация сетевого взаимодействия с помощью протоколов TCP
и UDP.
3 Реализация двустороннего обмена данными в веб-чате с помощью
веб-сокетов.
3 Реализация однонаправленного сетевого взаимодействия для
передачи сообщений от сервера клиенту через постоянное
соединение.
Изначально Go задумывался как язык системного программирования, однако
еще до своего первого релиза приобрел более универсальный характер. Благодаря встроенной поддержке конкурентности Go занял особое место среди языков программирования и быстро стал популярным инструментом для решения
серверных и сетевых задач. В этой главе, а также в главах с 8-й по 11-ю мы
рассмотрим роль Go в веб-разработке, охватив некоторые специализированные
сценарии его применения.
В главе 6 мы работали с журналами, взаимодействуя с файловой системой
с помощью средств из пакета log. Однако мы не обсуждали низкоуровневые
механизмы Go, используемые для создания, чтения и изменения файлов.
210
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Для начала нам нужно научиться открывать файлы и работать с их содержимым.
В данной главе вы узнаете, как открывать, читать и записывать файлы. Затем мы
перейдем на более низкий уровень и поработаем с соединениями UDP и TCP
без использования стандартного пакета net/http. В завершение мы создадим
несколько небольших веб-приложений, которые поддерживают постоянные
сетевые соединения для работы в режиме реального времени.
7.1. Работа с файлами
В главе 6 мы говорили о ведении журналов и выводе информации в различные
места назначения. Одним из наиболее распространенных мест является файловая
система, поэтому в ходе работы нам пришлось создавать файлы и записывать
в них данные. Взаимодействие с файловой системой — это универсальная задача, и она особенно актуальна в контексте разработки веб-сервера, которой
мы займемся в следующих главах. Крупные веб-приложения активно взаимодействуют с файловой системой, например, при чтении и записи шаблонов,
кэшировании данных, загрузке и скачивании файлов. Поэтому, прежде чем
перейти к обсуждению сетевых взаимодействий, давайте рассмотрим основы
работы с файлами в Go. В дальнейшем мы объединим способы доступа к сети
и файловой системе, построив простую сетевую файловую службу, позволяющую
читать файлы с другого сервера.
7.1.1. Чтение файлов
Хотя задача открытия и чтения файла кажется интуитивно понятной, стандартная библиотека Go предлагает несколько способов для этого, каждый из
которых имеет свои особенности и подводные камни. Многие методы чтения
файлов опираются на интерфейс io.Reader, который тесно связан с уже знакомым нам io.Writer.
Первый способ сводится к использованию функции os.ReadFile, которая возвращает срез байтов и при необходимости ошибку. Если вам нужно получить все
содержимое файла целиком, этот подход является самым прямолинейным. Его
недостаток заключается в том, что неожиданный конец файла (end of file, EOF)
во время чтения не будет обнаружен, что приведет к аварийному завершению
программы. Поскольку такая ситуация возникает редко, функция os.ReadFile
остается простым и удобным инструментом для получения содержимого файла.
Однако, если нужно обработать случаи с неожиданным EOF, можно воспользоваться отдельной проверкой ErrUnexpectedEOF. В листинге 7.1 мы откроем файл
и отобразим его содержимое в стандартном выводе в виде строки.
Поскольку этот метод загружает весь файл целиком, стоит учитывать, что
ReadFile заранее выделяет память под срез байтов ([]byte), даже если при
попытке получить доступ к первым байтам возникает ошибка. Это может
стать проблемой при работе с особенно крупными файлами, размер которых
7.1. Работа с файлами
211
иногда превышает доступный объем оперативной памяти или даже свободного
места на диске.
Листинг 7.1. Вывод содержимого файла
package main
import (
"log"
"os"
)
Попытка открыть
func main() {
файл myfile.txt
data, err := os.ReadFile("myfile.txt")
if err != nil {
panic(err)
}
Преобразует содержимое в строку
и отправляет в стандартный вывод
log.Println(string(data))
}
Функция ReadFile выполняет большую часть работы за нас, однако, если вам
требуется более тонкий контроль, можно воспользоваться функцией os.Open,
которая позволяет получить информацию о файле и обрабатывать его содержимое последовательно. Возможно, вы помните лог-файлы в формате JSON из
главы 6, которые выглядят примерно так:
{"time":"2023-08-31T12:09:23.32325-04:00","level":"INFO","msg":"this is
default logging"}
{"time":"2023-08-31T12:09:23.32354-04:00","level":"WARN","msg":"keep an eye
on this, it might be an issue"}
{"time":"2023-08-31T12:09:23.323548-04:00","level":"ERROR","msg":"oh no, an
error happened here!"}
{"time":"2023-08-31T12:09:23.323551-04:00","level":"DEBUG","msg":"this is
good while developing ..."}
{"time":"2023-08-31T12:09:23.323555-04:00","level":"INFO","msg":"this is a
more complex message","accepted_values":"key/value pairs with specific
types for marshalling","an int:":30,"grouped_info":{"you_can":"do this too"}}
В этом случае каждая запись хранится в виде отдельной строки, а не в составе
одного большого JSON-файла. Когда данные можно обрабатывать в потоковом
режиме или по частям, имеет смысл разбивать их на блоки и работать с ними.
Конечно, можно было бы считать весь файл в память и затем разделить его на
строки, но такой подход неэффективен и по мере увеличения объема входных
данных негативно скажется на производительности. Поэтому лучше реализовать
поэтапную обработку данных. Для начала давайте откроем файл и получим его
дескриптор, как показано в листинге 7.2.
В этом примере мы получаем и выводим информацию о файле, позволяющую
выбрать способ его обработки. Если файл небольшой, его можно целиком загрузить в память без особых последствий. Но если файл очень объемный, стоит
212
Глава 7. Доступ к файлам и основы сетевого взаимодействия
применить более рациональную стратегию. В случае с JSON-файлом мы знаем,
что у нас есть возможность обрабатывать его построчно, и каждая строка будет
действительной. Конечно, мы могли бы использовать буфер для разбивки данных на фрагменты, но тогда пришлось бы дополнительно парсить JSON, чтобы
определить момент завершения очередного блока. В листинге 7.3 показана
модифицированная версия кода из листинга 7.2.
Листинг 7.2. Извлечение дополнительной информации о файле
package main
import (
"fmt"
"log"
"os"
)
func main() {
file, err := os.Open("structured.log")
if err != nil {
panic(err)
}
defer file.Close()
Получает метаданные
об открытом файле
info, err := file.Stat()
if err != nil {
panic(err)
}
Выводит некоторые из наиболее
}
часто используемых атрибутов
log.Println(fmt.Sprintf(
"File: name is %s, mode is %v, size is %d. Is directory: %v",
info.Name(), info.Mode(), info.Size(), info.IsDir(),
))
Листинг 7.3. Поиск JSON-структур в потоке данных
lineJSON := make(map[string]interface{})
var bChunk []byte
for {
b := make([]byte, 2)
_, err := file.Read(b)
if err != nil {
break
}
bChunk = append(bChunk, b[0:]...)
}
Создает простейшую структуру,
подходящую для представления
любого допустимого типа JSON
Добавляет данные в текущий
блок по два байта за раз
if err := json.Unmarshal(bChunk, &lineJSON); err == nil {
log.Println(lineJSON)
Пытается провести валидацию
bChunk = []byte{}
поступающих данных; в случае успеха
}
массив байтов обнуляется
7.1. Работа с файлами
213
В этой реализации мы читаем по два байта за раз (минимально допустимыми
JSON-структурами являются [] или {}) до тех пор, пока не получим корректный
JSON-объекта. Затем процесс повторяется и продолжается до возникновения
ошибки чтения файла или достижения его конца. Такой метод крайне ненадежен:
если в потоке встретится поврежденный JSON, программа будет накапливать
байты, пока не обнаружит допустимую структуру. В нашем примере мы можем
спокойно читать по два байта за раз, поскольку в худшем случае нам попадется
символ новой строки. Однако в других ситуациях это может привести к выходу
за границы допустимого формата. К тому же здесь не учитываются четырехбайтовые символы Юникода, что в других контекстах может привести к сбоям.
По этим и другим причинам в реальном приложении при выборе такого способа
обработки данных следовало бы использовать полноценный парсер.
Очистка ассоциативных массивов
В листинге 7.3 видно, что мы пересоздаем байтовый срез с помощью []byte{}.
В версии Go 1.21 была добавлена встроенная функция clear для очистки срезов
и ассоциативных массивов. В случае с ассоциативными массивами эта функция удаляет все элементы, а в случае со срезами — устанавливает каждый элемент в нулевое
значение соответствующего типа. Если бы мы использовали clear в этом примере, то
каждый байт в срезе стал бы равным нулю, что привело бы к появлению некорректной структуры JSON уже после первой итерации. Поэтому здесь мы переназначаем
переменную. Тем не менее функция clear весьма полезна, например, для быстрой
очистки существующего множества. Подробнее о ней можно узнать в спецификации
языка: https://go.dev/ref/spec#Clear.
Поскольку мы знаем, что записи в JSON-файлах разделены символами новой
строки, мы можем отказаться от громоздкого парсера и использовать другой
распространенный метод чтения файла — bufio.ScanLines. Пакет bufio предоставляет обертки для объектов Reader и Writer, добавляя буферизацию для потоковой обработки. Это особенно удобно, когда нужно работать с построчным
вводом: такой подход актуален не только для JSON, но и для CSV и других
форматов с разделением строк. Если в файле встретится некорректная JSONзапись, ее можно просто пропустить и продолжить обработку оставшихся строк.
Проблема. Мы хотим открывать файлы и обрабатывать их в потоковом режиме, чтобы избежать проблем с масштабируемостью.
Решение. Реализовать буферизованный ввод/вывод с помощью пакета bufio,
который оборачивает объекты Reader и Writer, позволяя эффективно обрабатывать текстовые данные.
Обсуждение. При желании мы можем дополнительно оптимизировать процесс обработки. Подобные задачи легко распараллелить, если существует
централизованный механизм распределения строк между обработчиками.
Кроме того, в многопоточной среде выполнение таких операций в конкурентном режиме иногда позволяет получить заметный прирост скорости.
214
Глава 7. Доступ к файлам и основы сетевого взаимодействия
В листинге 7.4 мы считываем содержимое структурированного лог-файла в формате JSON и обрабатываем его в потоковом режиме.
Листинг 7.4. Построчное чтение файла с помощью bufio
package main
import (
"bufio"
"encoding/json"
"log"
"os"
)
func main() {
file, err := os.Open("structured.log")
if err != nil {
panic(err)
}
Создает сканер — объект, отслеживающий
defer file.Close()
положение в файле и принимающий
функцию разделения содержимого
scan := bufio.NewScanner(file)
scan.Split(bufio.ScanLines)
Разделяет содержимое с помощью функции bufio.ScanLines
lineJSON := make(map[string]interface{})
}
Построчно обрабатывает содержимое файла
for scan.Scan() {
if err := json.Unmarshal([]byte(scan.Text()), &lineJSON); err != nil {
log.Println(err)
} else {
log.Println(lineJSON["level"])
}
}
Этот подход позволяет более эффективно разбить задачу на мелкие части. Как
уже было сказано, при работе с особенно крупными файлами его можно дополнительно оптимизировать, распределив части файла между различными
процессами и/или серверами.
7.1.2. Запись в файлы
Создание и запись файлов мало чем отличаются от процесса их чтения. Мы можем
записывать данные, уже находящиеся в памяти, либо постепенно передавать
их в целевой файл в потоковом режиме. Простейший процесс записи в файл
состоит из нескольких этапов, как показано в листинге 7.5.
Здесь все достаточно просто: функция Create возвращает обработчик нового
или уже существующего файла. Затем метод WriteString из интерфейса Writer
передает строку непосредственно в файл.
7.1. Работа с файлами
215
Листинг 7.5. Создание файла и запись данных в него
package main
import (
"os"
)
Пытается создать файл и в случае
func main() {
успеха получает его обработчик
file, err := os.Create("test.txt")
if err != nil {
panic(err)
Помните, что обработчик
}
файла должен быть закрыт
defer file.Close()
}
file.WriteString("test")
Записывает строку
напрямую в файл
Однако мы сталкиваемся с той же проблемой, что и при чтении: для записи данных в файл нам требуется строка или срез байтов в памяти. А что, если мы хотим
записывать данные в файл в потоковом режиме, как при их построчном чтении?
К счастью, в пакете io есть функция Copy, которая использует буфер для чтения
из любого объекта Reader и записи прочитанных байтов в любой объект Writer.
Это избавляет от необходимости вручную управлять буферным обменом между
источником и приемником. В листинге 7.6 показано, как использовать метод
Copy для реализации чтения и записи на основе буфера.
Листинг 7.6. Копирование буферизованных данных из Reader в Writer
package main
import (
"io"
"os"
)
func main() {
src, err := os.Open("test.txt")
if err != nil {
panic(err)
}
defer src.Close()
}
Открывает исходный файл
и получает его содержимое
dest, err := os.Create("test2.txt")
if err != nil {
panic(err)
}
Копирует байты в целевой файл,
defer dest.Close()
игнорируя первое возвращаемое значение
(количество записанных байтов)
_, err = io.Copy(dest, src)
if err != nil {
panic(err)
}
216
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Таким образом, мы избавляемся от большого объема шаблонного кода, который
обычно требуется для организации потокового чтения. Далее мы перейдем к обсуждению сетевых взаимодействий и снова задействуем журналы — на этот раз
для передачи данных через собственные TCP- и UDP-серверы.
7.2. Организация сетевого взаимодействия
через TCP
Помимо узкоспециализированных протоколов, большая часть сетевых взаи
модействий в приложениях, написанных на Go (и других языках), будет
основана на TCP (Transmission Control Protocol — протокол управления передачей). Этот протокол составляет основную часть стека TCP/IP, и именно
с его помощью реализуется большая часть сетевых операций при разработке
веб-приложений.
Чтобы построить простой TCP-сервер, мы продолжим работу с журналами из
раздела 7.1 и главы 6, но теперь будем передавать данные по сети, а не записывать их в стандартный поток, на диск или в память. При этом имейте в виду,
что приемы, описанные в текущем разделе, можно применять для создания
любых пользовательских серверов, использующих такие функции TCP, как
установка соединения, обнаружение ошибок и автоматическое восстановление
подключения.
7.2.1. Логирование в сетевой ресурс
В главе 6 мы записывали сообщения журнала в обычный файл. Однако современные приложения, особенно серверы, часто запускаются в облаке — внутри
Docker-контейнеров, виртуальных машин или других сред с временным хранилищем. Кроме того, серверы часто запускаются в составе кластера, а в таких
ситуациях имеет смысл агрегировать журналы всех узлов в одной системе логирования. С учетом этого давайте воспользуемся сетевым ресурсом в качестве
места назначения для сохранения файлов — в нашем случае логов.
Многие популярные системы логирования, такие как Logstash (https://www.elas
tic.co/logstash) и Heka (http://hekad.readthedocs.org/en/v0.9.2), позволяют агрегиро-
вать журналы. Обычно подобные сервисы открывают порт, к которому можно
подключиться и передавать лог-сообщения в потоковом режиме. Такой подход
к логированию стал особенно популярен благодаря методологии Twelve-Factor
App (https://12factor.net/). Как было сказано в главе 2, ее одиннадцатым принципом
является обработка журналов как потоков событий. Несмотря на кажущуюся
простоту, передача журналов в виде потока сопряжена с неожиданными сложностями.
Проблема. Мы хотим минимизировать риск потери лог-сообщений при потоковой передаче файлов журналов в сетевой сервис.
7.2. Организация сетевого взаимодействия через TCP
217
Решение. Использовать каналы и буферы, которые значительно повышают
надежность передачи.
Обсуждение. Прежде чем приступать к реализации, нам нужно выбрать
инструмент, который сможет имитировать поведение лог-сервера. Вместо
полноценных решений вроде Logstash или Heka мы воспользуемся простым
Unix-инструментом Netcat (nc). Он входит в состав большинства систем на
базе UNIX и Linux, включая macOS; существует версия и для ОС Windows.
Чтобы запустить простой TCP-сервер, принимающий текстовые сообщения
и выводящий их в консоль, достаточно выполнить команду Netcat:
nc -lk 1902
Итак, у нас есть слушатель (-l), который непрерывно принимает соединения (-k)
на порте 1902. (В некоторых версиях Netcat может понадобиться флаг -p.) Эта
простая команда вполне подойдет для имитации поведения лог-сервера. Теперь
можно адаптировать пример из главы 6, чтобы передавать данные в сетевой
сокет (листинг 7.7).
Листинг 7.7. Клиент для передачи логов по сети
package main
import (
"log"
"net"
)
func main() {
conn, err := net.Dial("tcp", "localhost:1902")
if err != nil {
panic("Failed to connect to localhost:1902")
}
Гарантирует закрытие соединения
defer conn.Close()
даже при возникновении паники
}
Устанавливает соединение
с лог-сервером
Задает битовые значения для параметров логирования
f := log.Ldate | log.Lshortfile
logger := log.New(conn, "example ", f)
Передает лог-сообщения
через сетевое соединение
logger.Println("This is a regular message.")
logger.Panicln("This is a panic.")
Записывает сообщение и вызывает панику.
Здесь не следует использовать функцию Fatalln
Как видите, для передачи логов по сети вместо их записи в файл требуется внести совсем немного изменений. Сетевая библиотека Go отличается простотой
и удобством. Мы создаем TCP-соединение с помощью net.Dial, указывая порт,
открытый через Netcat. Соединение всегда следует закрывать в блоке defer. Даже
если в программе возникнет паника (как в этом демонстрационном примере),
буфер будет очищен при закрытии и вы с меньшей вероятностью потеряете
критически важные сообщения, в которых говорится о причинах ошибки.
218
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Как и в главе 6, здесь мы применяем пакет log для отправки журналов на удаленный сервер. Это дает несколько преимуществ.
Мы автоматически получаем временную метку. При передаче журналов на
сетевой сервер лучше использовать локальное время хоста, а не полагаться
на временную метку самого сервера. Это помогает восстановить хронологию
событий, даже если сообщения приходят с задержкой.
Сравнение примеров из главы 6 с листингом 7.7 показывает, что использование пакета log позволяет легко менять механизм хранения логов. Напомним,
что для этого достаточно подставить вместо одного интерфейса Writer любой
другой тип, реализующий метод Write. Возможность настраивать место назначения для логов особенно полезна при тестировании и разработке.
Вы, вероятно, заметили, что в этом примере мы также заменили log.Fatalln на
log.Panicln. Причина в том, что функции log.Fatal* имеют один неприятный
побочный эффект: при их вызове отложенные функции (defer) не выполняются.
Так происходит потому, что log.Fatal* вызывает функцию os.Exit, которая
немедленно завершает выполнение программы, не раскручивая стек вызовов.
Мы уже обсуждали это в главе 6. Поскольку отложенные функции пропускаются, сетевое соединение не успевает корректно закрыться. В свою очередь,
паники перехватить гораздо легче. В реальных приложениях (если речь не идет
о простых CLI-утилитах) вызовов Fatal следует избегать. Как было показано
в главе 6, существуют ситуации, когда стоит вызывать функцию panic, например,
если ошибка в состоянии системы делает невозможной дальнейшую обработку. Учитывая все это, при выполнении приведенного выше кода экземпляр nc
должен принять несколько лог-сообщений:
$ nc -lk 1902
example 2015/05/27 log_client.go:23: This is a regular message.
example 2015/05/27 log_client.go:24: This is a panic.
Эти сообщения были переданы от клиента простому серверу, запущенному
с помощью nc. Таким образом, мы получили небольшой и удобный инструмент
для сетевого логирования. Однако в такой системе может возникнуть проблема,
называемая обратным давлением.
7.2.2. Обратное давление при сетевом логировании
В предыдущем разделе мы обсудили способ передачи сообщений журнала на
сетевой сервер. Такой подход дает ряд весомых преимуществ, таких как:
централизация — сбор логов разных сервисов в одном хранилище;
надежность — возможность сохранения даже логов серверов с временным
хранилищем (например, в облачных средах);
безопасность — улучшенный аудит и защита данных;
гибкость: независимая настройка лог-серверов и серверов приложений.
7.2. Организация сетевого взаимодействия через TCP
219
Однако передача логов на удаленный сервер имеет и существенный недостаток:
она зависит от состояния сети. Когда сервер не успевает обрабатывать входящие запросы, возникает так называемое обратное давление (back pressure). Оно
может проявляться в любой системе, принимающей и обрабатывающей данные,
но особенно заметно в системах, использующих сеть или, как в нашем случае,
файловую систему, где чтение файлов почти всегда происходит быстрее, чем
их запись. В текущем разделе мы поговорим о том, как справляться с сетевыми
проблемами, возникающими в процессе логирования.
Проблема. Сетевые сервисы логирования подвержены разрывам соединений
и обратному давлению, которые приводят к потере лог-сообщений, а иногда
и к сбоям в работе самого сервиса.
Решение. Создать более устойчивый механизм логирования с буферизацией
данных.
Обсуждение. Скорее всего, вы столкнетесь с двумя основными сетевыми
проблемами:
с обрывом соединения, по которому передаются логи (из-за неполадок
в сети или на стороне сервера);
падением скорости передачи лог-сообщений по этому соединению.
Первая проблема всем хорошо знакома и, безусловно, требует решения. Вторая менее очевидна.
В листинге 7.7 описан примерный сценарий передачи логов, который схематически представлен на рис. 7.1. (На самом деле механизмы сетевого взаимодействия
устроены несколько сложнее, но для понимания ситуации вдаваться в детали
необязательно.)
Рис. 7.1. Последовательность команд при передаче сообщений между
клиентом (приложением) и сервером (логгером) по протоколу TCP.
Подтверждение (Ack) отправляется при успешной установке соединения
и после получения каждого сообщения
220
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Наше приложение устанавливает TCP-соединение, отправляет сообщения, а затем закрывает соединение. Однако в коде мы не видим, как именно лог-сервер
реагирует на эти сообщения, так как все взаимодействие происходит на уровне
TCP и не отражается непосредственно в коде приложения.
При отправке сообщения через TCP/IP получатель обязан сообщить об успешном получении пакета, отправив подтверждение (Ack). Вполне возможно (и даже
вероятно), что одно лог-сообщение будет разбито на несколько сетевых пакетов.
Допустим, сообщение разделилось на два пакета. В этом случае сервер логирования получит первую часть и отправит Ack. После этого клиент отправит вторую
часть сообщения, получение которой сервер подтвердит тем же способом. Такая
схема позволяет клиенту убедиться в том, что отправленные им данные были
получены удаленным сервером.
7.3. Основы сетевого взаимодействия по UDP
Наш пример с TCP-сервером будет работать хорошо ровно до тех пор, пока
удаленный хост не начнет тормозить. Представьте себе лог-сервер, который
одновременно принимает тысячи сообщений от множества клиентов. Если
пропускной способности окажется недостаточно, сервер начнет работать с задержками. При этом поток логов не уменьшится — они будут скапливаться,
как вода за плотиной. При использовании протокола TCP лог-сервер обязан
отправлять подтверждение (Ack) после получения каждого входящего сообщения. Если сервер задерживает это подтверждение, клиенту приходится ждать.
Его ресурсы оказываются заняты, и он тоже вынужден замедлять работу. Так
и возникает проблема обратного давления.
Одним из решений является переход с TCP на UDP (User Datagram Protocol —
протокол пользовательских датаграмм), позволяющий избавиться от издержек,
связанных с установкой и поддержанием соединения. А главное — приложению
не придется дожидаться подтверждений от лог-сервера (рис. 7.2).
Рис. 7.2. Протокол UDP упрощает процесс логирования, так как серверу не нужно
подтверждать получение сообщений
7.3. Основы сетевого взаимодействия по UDP
221
Протокол UDP не требует сопровождения сетевого соединения. Клиент может
отправлять данные серверу по мере их готовности. Чтобы адаптировать код из
листинга 7.7 под UDP, достаточно внести совсем небольшие изменения, как
показано в листинге 7.8.
Листинг 7.8. Логирование по протоколу UDP
package main
import (
"log"
"net"
"time"
)
Задает явный
func main() {
тайм-аут
timeout := 30 * time.Second
conn, err := net.DialTimeout("udp", "localhost:1902", timeout)
if err != nil {
Устанавливает
panic("Failed to connect to localhost:1902")
соединение,
}
используя UDP
defer conn.Close()
вместо TCP
f := log.Ldate | log.Lshortfile
logger := log.New(conn, "example ", f)
}
logger.Println("This is a regular message.")
logger.Panicln("This is a panic.")
Как видите, изменения в коде минимальны. Вместо обычного вызова net.Dial
применяется net.DialTimeout, который позволяет задать тайм-аут подключения. Здесь он установлен на 30 секунд. В случае с TCP тайм-аут охватывает
как время отправки сообщения, так и время ожидания подтверждения (Ack).
При использовании UDP тайм-аут в основном касается только времени,
необходимого приложению для разрешения адреса и отправки сообщения.
Установка тайм-аута обеспечивает некую страховку на случай нестабильной
работы сети.
Для выполнения приведенного выше фрагмента кода нужно перезапустить
сервер nc в режиме UDP: nc -luk 1902. Использование протокола UDP для
логирования дает несколько преимуществ:
устойчивость приложения к обратному давлению и временной недоступности лог-сервера (если сервер даст сбой, отдельные пакеты данных могут
быть потеряны, но клиент не пострадает);
более быструю отправку логов, даже при отсутствии обратного давления;
простоту кода.
222
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Однако у такого подхода есть и серьезные недостатки. В зависимости от ваших
потребностей они могут оказаться критическими:
лог-сообщения легко теряются (протокол UDP не позволяет приложению
удостовериться в получении отправленного им сообщения);
лог-сообщения могут приходить в неправильном порядке, поскольку крупные
сообщения часто разбиваются на пакеты и перемешиваются при передаче
(добавление временной метки помогает решить проблему, но не устраняет
ее полностью);
активная передача логов по протоколу UDP может перегрузить удаленный сервер, поскольку он не управляет подключениями и не в состоянии
контролировать скорость обработки данных (даже если ваше приложение
не испытывает на себе эффектов обратного давления, сервер логирования
может оказаться в уязвимом положении).
В определенных условиях быстрое и эффективное UDP-логирование вполне
уместно. Если вы можете достаточно точно оценить потенциальный уровень нагрузки на лог-сервер, этот метод станет самым простым и надежным вариантом.
Однако от UDP-логирования лучше отказаться, если невозможно точно спро
гнозировать объем данных, которые будут передаваться с сервера приложения
на лог-сервер, или если даже редкая потеря отдельных сообщений для вас недопустима.
Логирование по протоколу TCP сопряжено с проблемой обратного давления,
а UDP не гарантирует точность доставляемых данных. Эта дилемма напоминает
компромисс между высоким качеством и компактностью изображения при выборе его формата. С логами ситуация похожая. Но это не означает, что ничего
нельзя улучшить. В частности, проявление эффекта обратного давления можно
отсрочить, создав большой буфер для временного хранения логов, например, на
случай перегрузки сети.
7.4. Веб-сокеты и события, отправляемые сервером
Создание веб-серверов на языке Go — задача прикладного уровня, которую
мы подробно рассмотрим в главах 8–10. По мере развития Интернета растет
число приложений и сайтов, использующих постоянные двунаправленные соединения для более эффективной передачи данных в реальном времени. Хотя
взаимодействие с клиентами в режиме опроса (polling) по-прежнему довольно
распространено, неэффективность на HTTP/TCP-уровне значительно затрудняет передачу данных, которые можно было бы доставлять мгновенно.
В главе 8 мы приступим к построению надежного веб-сервера на базе стандартных HTTP-серверов. Но перед этим давайте рассмотрим способы реализации
постоянных соединений в приложениях с помощью веб-сокетов и событий,
отправляемых сервером.
7.4. Веб-сокеты и события, отправляемые сервером
223
7.4.1. Реализация онлайн-чата с использованием веб-сокетов
Веб-сокеты — это уникальная особенность стека TCP/IP, которая решает
ключевую задачу эпохи Web 2.0: поддержание активного соединения для
двустороннего обмена сообщениями. Управление состоянием в протоколе,
не хранящем его, всегда создавало массу издержек. Поскольку каждый запрос
представляет собой независимую сущность, приходилось многократно передавать одни и те же данные о намерениях и сопутствующем контексте. Например, в случае с цепочкой REST-вызовов при каждом обращении необходимо
заново передавать информацию об идентификаторе и параметрах запроса, даже
если изменяется лишь одна его деталь вроде страницы или позиции курсора.
Веб-сокеты были призваны сократить эту избыточность за счет поддержания
одного TCP-соединения между клиентом и сервером. Хотя они и не являются
полноценной заменой REST, веб-сокеты служат отличным инструментом для
эффективной передачи данных в реальном времени.
Проблема. Использование традиционных HTTP-методов и подходов к передаче сообщений сопряжено с большими накладными расходами и опирается
на периодические опросы сервера, что приводит к задержкам в обновлении
данных и ухудшает отзывчивость приложений.
Решение. Использовать WebSocket — протокол, позволяющий поддерживать
соединения в активном состоянии и распределять входящие сообщения по
нескольким клиентам.
Обсуждение. Веб-сокеты — это решение для вполне конкретных задач.
В большинстве случаев они могут и не понадобиться, но если вам требуется
мгновенная передача данных сразу нескольким пользователям, лучшего
инструмента не найти.
ПРИМЕЧАНИЕ
Как отмечается в официальной документации, встроенный пакет
websocket из вложенного репозитория x/net может не содержать некоторых функ-
ций, которые предлагают сторонние библиотеки. Однако для наших целей его будет
вполне достаточно. Информацию об альтернативных пакетах можно найти по адресу
https://pkg.go.dev/golang.org/x/net/websocket.
Основное изменение по сравнению со стандартным HTTP-сервером заключается в использовании обертки websocket.Handler, которая управляет обменом
данными и переходом с HTTP на WebSocket. В листинге 7.9 показан простой
чат-сервис, позволяющий нескольким клиентам обмениваться сообщениями
в реальном времени.
Листинг 7.9. Реализация чата с использованием веб-сокетов
package main
import (
"encoding/json"
224
Глава 7. Доступ к файлам и основы сетевого взаимодействия
"fmt"
"log"
"math/rand"
"net/http"
)
"golang.org/x/net/websocket"
var chars =
[]rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
var clients map[string]*websocket.Conn
Список символов для генерации
func init() {
clients = make(map[string]*websocket.Conn)
}
случайных идентификаторов
func generateId() string {
Функция генерации случайного
r := make([]rune, 16)
идентификатора
for i := range r {
r[i] = chars[rand.Intn(len(chars))]
}
return string(r)
}
func chatHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `
<!DOCTYPE html>
<html>
<head>
<title>Let's Chat</title>
<style>
#chat {
max-width: 400px;
margin: auto;
font-family: system-ui, sans-serif;
}
.message {
padding: 1rem 0.25rem;
border: 1px solid black;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div id="chat">
<h1>Chat</h1>
<div id="messages"></div>
<input id="message" autofocus type="text"
placeholder="Enter message ..." />
<div>
<p>Chat members:</p>
<ul id="chat-members"></ul>
</div>
</div>
<script>
const text = document.getElementById('message');
Главная публичная
веб-страница чата,
отображающая
HTML и JavaScript
7.4. Веб-сокеты и события, отправляемые сервером
225
const messages = document.getElementById('messages');
const members = document.getElementById('chat-members');
const ws = new WebSocket('ws://localhost:8081/ws');
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.message_type == 'joinleave') {
members.innerHTML = '';
msg.chat_members.forEach(member => {
const li = document.createElement('li');
li.innerHTML = member;
members.appendChild(li);
});
return;
}
if (msg.message_type === 'message') {
const message = document.createElement('div');
message.classList.add('message');
message.innerHTML = msg.sender_id + " said: " + msg.message;
messages.appendChild(message);
return;
}
};
document.getElementById('message').addEventListener('keyup', e => {
if (e.key == 'Enter') {
ws.send(e.target.value);
message.value = '';
}
});
</script>
</body>
</html>
`)
}
type servermsg struct {
MessageType string
Message
string
Id
string
SenderId
string
ChatMembers []string
}
`json:"message_type"`
`json:"message,omitempty"`
`json:"id,omitempty"`
`json:"sender_id,omitempty"`
`json:"chat_members"`
Функция, возвращающая список всех
текущих участников чата в виде среза
func compileChatMembers() []string {
var chatMembers []string
for k := range clients {
chatMembers = append(chatMembers, k)
}
return chatMembers
}
Глобальная функция рассылки
сообщений всем пользователям
func sendToClients(msg servermsg) error {
msgJSON, err := json.Marshal(msg)
if err != nil {
return err
}
226
}
Глава 7. Доступ к файлам и основы сетевого взаимодействия
for k := range clients {
if err := websocket.Message.Send(clients[k], string(msgJSON));
err != nil {
return err
}
}
return nil
Функция для отключения пользователя
по его идентификатору (ID)
func disconnectClient(id string) error {
delete(clients, id)
if err := sendToClients(servermsg{
MessageType: "joinleave",
Message:
"",
Id:
id,
SenderId:
"",
ChatMembers: compileChatMembers(),
}); err != nil {
return err
}
return nil
}
Основная функция-обработчик
для веб-сокета
func ws(ws *websocket.Conn) {
id := generateId()
clients[id] = ws
join := servermsg{
MessageType: "joinleave",
Message:
"",
Id:
id,
SenderId:
"",
ChatMembers: compileChatMembers(),
}
sendToClients(join)
Цикл для обработки
входящих сообщений
for {
var incoming string
if err := websocket.Message.Receive(ws, &incoming); err != nil {
if err := disconnectClient(id); err != nil {
log.Println(err)
Если не удается получить данные через
}
веб-сокет, скорее всего, клиент отключился,
break
поэтому удаляем его из списка
}
if err := sendToClients(servermsg{
Если не удается отправить
MessageType: "message",
сообщение, также удаляем клиента
Message:
incoming,
Id:
"",
SenderId:
id,
ChatMembers: compileChatMembers(),
}); err != nil {
if err := disconnectClient(id); err != nil {
log.Println(err)
}
7.4. Веб-сокеты и события, отправляемые сервером
}
}
}
227
break
func main() {
Обертка для нашего
http.HandleFunc("/chat", chatHandler)
обработчика
http.Handle("/ws", websocket.Handler(ws))
if err := http.ListenAndServe(":8081", nil); err != nil {
panic(err)
}
}
Если код кажется вам слишком объемным, учтите, что в этих 200 строках содержится полноценный многопользовательский чат (включая клиентскую
и серверную части), предоставляющий возможность подключения пользователей, глобальной рассылки сообщений и динамического обновления списка
активных участников.
В данном случае строковое представление шаблона встроено непосредственно в код,
но у нас есть и другие варианты — например, встраивание данных прямо в скомпилированный бинарный файл. Более подробно об этом мы поговорим в главе 10.
Обеспечение безопасности
Как и HTTP, протокол WebSocket поддерживает шифрование с помощью TLS, позволяя
установить защищенное соединение между клиентом и сервером. На локальной
машине, где нет действующего сертификата, используется схема ws://, а в рабочем
окружении — wss://.
Общая логика приложения довольно проста: при подключении клиенту присваи
вается случайный ID и отправляется сообщение с этим ID и текущим списком
участников чата. Список активных пользователей отображается в коде JavaScript.
В качестве ID можно использовать все что угодно, например последовательные
числа или уникальные имена, заданные самими участниками. Мы используем
ID для отслеживания соединения, чтобы при необходимости отправить остальным сообщение о том, что конкретный пользователь вышел из чата (рис. 7.3).
Любое полученное сообщение рассылается всем подключенным клиентам. Если
в ходе отправки или получения сообщения возникает ошибка (Message.Send,
Message.Receive), клиент удаляется из списка, а остальным рассылается сообщение joinleave, служащее сигналом для обновления списка пользователей
на веб-странице.
Реализация подобного функционала с помощью REST или других традиционных HTTP-механизмов потребовала бы значительно больших усилий и вряд ли
обеспечила бы такую же отзывчивость. Использование веб-сокетов избавляет
нас от издержек, связанных с установкой непостоянных соединений.
228
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Рис. 7.3. Несколько сообщений в чате и список его участников,
обновляемый в реальном времени
Разумеется, до полноценного чата нашему простому примеру еще далеко, но он
уже дает пользователям возможность общаться. Добавление таких функций,
как задание имени пользователя или загрузка истории сообщений при подключении, не представляет особой сложности. При этом приложение не является
реентерабельным, что подчеркивает важное отличие веб-сокетов от HTTP:
при отправке запросов не передаются никакие заголовки и cookie-файлы. Это
означает, что повторное установление личности пользователя должно осуществляться другими способами.
Веб-токены JSON
Хотя в запрос можно включить идентификатор сеанса, более безопасным вариантом
является использование подписанного веб-токена JSON Web Token (JWT), снижающего риск перехвата сеанса. У JWT есть свои недостатки, в частности невозможность
аннулирования, однако в целом это более надежное решение для установления
личности, особенно при использовании протоколов вроде WebSocket.
При работе с веб-сокетами важно учитывать несколько моментов.
Управление соединением может оказаться непростой задачей как на стороне
сервера, так и на стороне клиента. Когда сервер разрывает соединение, клиент может не узнать об этом сразу, поэтому на стороне фронтенда необходимо
реализовывать логику переподключения. Поскольку WebSocket-соединения
7.4. Веб-сокеты и события, отправляемые сервером
229
сохраняют состояние, в некоторых случаях их придется восстанавливать
с использованием тех же логики и процедур, что и при первоначальном
подключении.
Для устаревших браузеров, не поддерживающих веб-сокеты, не существует
полноценных заменителей. Доступные решения обычно эмулируют их функциональность с помощью стандартных HTTP-запросов и ответов.
7.4.2. События, отправляемые сервером
Хотя веб-сокеты отлично подходят для двунаправленной передачи данных в реальном времени, у них есть свои ограничения и подводные камни. В некоторых
случаях эта технология может оказаться не лучшим выбором, особенно если
речь идет лишь о периодической передаче сообщений для поддержания активного соединения. Например, в чат-приложении важно, чтобы и клиент, и сервер могли хранить состояние. Но что, если мы хотим отправлять уведомления
только в одну сторону — от сервера к клиенту? Здесь нам на помощь приходят
события, отправляемые сервером (Server-Sent Events, SSE, или EventSource
в JavaScript). Подобно веб-сокетам, SSE представляют собой долговременные
HTTP-соединения, но позволяют передавать данные только в одном направлении — от сервера к клиенту. Этот легковесный механизм требует минимальных
усилий по поддержанию соединения со стороны клиента.
Проблема. Нам не требуется двусторонняя передача данных, но мы хотим
регулярно отправлять события клиентам в реальном времени без использования механизма опроса и реализации отдельных последовательных запросов.
Решение. С помощью уже знакомых нам базовых приемов работы с сетью мы
можем изменить HTTP/TCP-сервер так, чтобы он передавал SSE в ответ на
событие или обновление. Это позволит серверу поддерживать постоянное
соединение и отправлять сообщения напрямую клиенту.
Обсуждение. Хотя этот подход пока не получил широкого распространения,
он имеет множество вариантов практического применения в веб-разработке.
Например, он идеально подходит для рассылки уведомлений вроде тех, что
приходят при публикации ответа на пост в социальной сети. В данном случае
мы создадим простое веб-приложение, которое будет уведомлять клиента об
изменении файлов, хранящихся на сервере.
Это наглядный пример однонаправленной коммуникации, свойственной для
инструментов совместной работы, в которых сервер выступает в роли центрального источника истины. Подобные системные сообщения отправляются самим
сервером, а не клиентами. Для решения поставленной задачи мы воспользуемся сторонним пакетом fsnotify, который позволяет отслеживать изменения
в файлах с помощью горутины. Внутри этой горутины мы будем наблюдать за
изменениями в файловой системе и передавать соответствующие уведомления
230
Глава 7. Доступ к файлам и основы сетевого взаимодействия
клиенту. В листинге 7.10 показан веб-сервис, отслеживающий изменения в некой директории и обновляющий интерфейс сайта через SSE.
Листинг 7.10. Веб-сайт для отправки уведомлений об изменении файлов
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
"github.com/fsnotify/fsnotify"
var directory string
var watcher *fsnotify.Watcher
Структура нашего
сообщения
type FileUpdateInfo struct {
Name
string `json:"name"`
Op
string `json:"operation"`
SizeBytes int
`json:"size"`
}
func init() {
if osdir := os.Getenv("SSE_DIRECTORY"); osdir == "" {
panic("SSE_DIRECTORY environment variable not set")
} else {
directory = osdir
}
}
func main() {
var err error
watcher, err = fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
Инициализирует средство
отслеживания изменений
в файловой системе
watcher.Add(directory)
http.HandleFunc("/sse", sseHandler)
http.HandleFunc("/files", filesHandler)
}
http.ListenAndServe(":8080", nil)
Интерфейс Flusher
используется для отправки
потока байтов и определения
совместимости клиента
func sseHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "byte streams not supported by your client",
7.4. Веб-сокеты и события, отправляемые сервером
}
http.StatusInternalServerError)
return
w.Header().Set("Content-Type", "text/event-stream")
changes := make(chan FileUpdateInfo)
go fileListener(r.Context(), changes)
for change := range changes {
changeJSON, err := json.Marshal(change)
if err != nil {
log.Println(err)
continue
}
}
}
231
Устанавливает заголовок,
приказывающий браузеру
продолжать прослушивать
поток сообщений
Запускает горутину,
отслеживающую
изменения в файлах
response := fmt.Sprintf("event: file-update\ndata: %s\n\n", changeJSON)
fmt.Fprint(w, response)
flusher.Flush()
Отправляет
отформатированные
данные клиенту
func filesHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head><title>File changes</title></head>
<style>
body > div {
margin: auto;
max-width: 800px;
}
.message {
padding: 1rem;
background-color: grey;
margin-bottom: 0.5rem;
}
.message.create {
background-color: palegreen;
}
.message.remove {
background-color: tomato;
}
</style>
<body>
<div>
<h1>File changes</h1>
<div id="files"></div>
</div>
<script>
const sse = new EventSource('/sse');
const files = document.getElementById('files');
const createMessage = (message) => {
const div = document.createElement('div');
div.classList.add('message');
div.classList.add(message.operation.toLowerCase());
232
}
Глава 7. Доступ к файлам и основы сетевого взаимодействия
div.innerText = message.operation + ':' + message.name + ' ('
+ message.size + ' bytes)';
return div;
sse.onmessage = (e) => {
alert(e.data);
}
sse.addEventListener('file-update', (e) => {
console.log(e, e.data);
files.prepend(createMessage(JSON.parse(e.data)));
});
Полный HTML-документ
</script>
с JavaScript-кодом
</body>
для получения событий SSE
</html>`)
}
func fileListener(ctx context.Context, changes chan<- FileUpdateInfo) {
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
break
}
size := 0
if stat, err := os.Stat(event.Name); err == nil {
size = int(stat.Size())
}
}
}
}
changes <- FileUpdateInfo{
Name:
event.Name,
Op:
event.Op.String(),
SizeBytes: size,
Отправка любых событий слушателя
}
файлов через наш канал
Обратите внимание: в функции init необходимо установить переменную
окружения SSE_DIRECTORY. После этого можно запустить приложение, перейти
по адресу localhost:8080/files и приступить к созданию, изменению или удалению файлов в указанной папке. При этом все изменения будут отображаться
на странице в хронологическом порядке (рис. 7.4).
Вносимые изменения отображаются друг над другом. Как уже говорилось,
в данном случае нам не требуется двусторонняя связь. Клиенты выступают исключительно в роли получателей данных. Разумеется, обе технологии можно
комбинировать в более сложных приложениях, в которых разные сценарии
требуют различных способов передачи данных.
7.4. Веб-сокеты и события, отправляемые сервером
233
Рис. 7.4. Изменения файлов в хронологическом порядке. При модификации файла
с сохранением содержимого отображается его размер в байтах
СОВЕТ Интерфейс Flusher служит своеобразным индикатором: если клиент его
не поддерживает, то, скорее всего, его браузер не способен обрабатывать буферизованные потоки данных. В таком случае мы возвращаем сообщение об ошибке и разрываем
соединение. Несмотря на примитивность, этот способ проверки совместимости позволяет с высокой долей вероятности определить, может ли браузер клиента работать
с потоками событий.
Как и у веб-сокетов, у технологии SSE есть свои недостатки. Например, большинство браузеров ограничивает количество одновременных соединений
с одним доменом. Это значит, что, если ваше приложение активно использует
SSE, вы легко достигнете этого предела (который обычно составляет от двух до
десяти соединений на домен) при открытии нескольких объектов EventSource.
Более того, это ограничение распространяется на все окна и вкладки, так что
один пользователь может легко исчерпать заданный лимит и столкнуться
с ошибками.
Существуют способы, позволяющие обойти эту проблему. Самый распространенный из них сводится к использованию протокола HTTP/2. Однако эти
ограничения все-таки стоит учитывать при проектировании систем на базе SSE
и заранее предусматривать план действий на случай превышения соответствующих лимитов.
234
Глава 7. Доступ к файлам и основы сетевого взаимодействия
Резюме
Язык Go предусматривает легкие средства для чтения и записи файлов,
а также позволяет решать эти задачи с помощью более надежных подходов,
таких как потоковая обработка и буферизация данных.
Сетевое взаимодействие лежит в основе мощной модели конкурентности
языка Go. Серверы на базе протоколов UDP и TCP создавать очень просто,
и они могут использоваться для хранения логов и реализации мультиплексированной передачи данных между пользователями.
Хотя стандартный протокол HTTP остается самым распространенным способом работы с TCP, язык Go предлагает такие инструменты, как веб-сокеты,
позволяющие устанавливать долгоживущие соединения и обслуживать
множество клиентов одновременно.
Если нам не требуется двусторонняя передача данных, мы можем использовать SSE, чтобы направлять информацию от сервера к клиенту через более
легковесное соединение, не хранящее состояние. Это позволяет реализовать, например, систему уведомлений с минимальными затратами сетевых
ресурсов.
Часть III
Создание полноценных
веб-приложений
В этой части мы рассмотрим ключевые концепции, необходимые для построения полноценных веб-сервисов на языке Go, — от формирования ответов API
и работы с шаблонами до отправки и приема данных в различных форматах
и с использованием разных протоколов.
Помимо этого, мы обсудим простую и продвинутую маршрутизацию, аутентификацию, обслуживание статических файлов, обработку форм и многое другое.
В главе 8 мы напишем бэкенд для веб-сервера, вы научитесь работать с HTTPзапросами и осуществлять маршрутизацию. Затем мы расширим функциональность сервера с помощью системы шаблонов Go, чтобы формировать безопасные
и форматированные ответы для веб-интерфейса и других получателей. В главе 9
мы соберем все воедино и создадим полноценное веб-приложение, включая
фронтенд и бэкенд, после чего добавим в него поддержку загрузки файлов,
а также обработки данных POST-запросов и HTTP-заголовков.
Кроме того, мы обсудим способы взаимодействия с внешними веб-сервисами.
Это важно как для организации конвейеров обработки данных и ETL-процессов
(извлечение, преобразование, загрузка), так и для проксирования трафика
сторонних служб, использующих различные протоколы. Мы рассмотрим такие
способы передачи данных, как REST, JSON и RPC (удаленный вызов процедур),
а также изучим лучшие практики проектирования клиентов и серверов для
взаимодействия с подобными сервисами.
После изучения этой части книги вы сможете не только создавать полноценные
веб-приложения, но и организовывать их взаимодействие с внешними вебсервисами.
8
Создание HTTP-сервера
В этой главе
3 Встроенные средства маршрутизации на основе HTTP-методов
и переменных пути.
3 Повышение устойчивости веб-сервера с помощью тайм-аутов.
3 Использование промежуточного ПО и контекста во избежание
дублирования кода.
3 Обработка данных из заголовков, cookie-файлов и HTML-форм.
3 Обработка других типов данных запроса.
3 Сокращение объема дублирующегося кода с помощью
промежуточного ПО.
В предыдущих главах вы познакомились с ключевыми строительными блоками, необходимыми для проектирования приложений, готовых к эксплуатации.
Мы обсудили основы тестирования, отладки, реализации конкурентности,
сетевых взаимодействий и работы с файлами. В этой главе мы приступим к созданию веб-приложений, способных выдерживать реальные нагрузки. Мы начнем
с обсуждения основ маршрутизации, а затем поговорим о мультиплексорах
и маршрутизаторах, позволяющих точнее сопоставлять URL-адреса с конечными
точками и направлять запросы к нужным обработчикам. Мы также создадим
промежуточные обработчики, чтобы централизованно решать распространенные
8.1. Маршрутизация запросов и обработка данных
237
задачи и минимизировать количество дублирующегося кода, или «спагеттилогики», в обработчиках веб-запросов.
Хотя пакет http из стандартной библиотеки Go по своей сути довольно прост,
а предоставляемые им средства позволяют легко создавать высокопроизводительные веб-серверы, он построен по модульному принципу, поощряющему
расширение функциональности. Запустить HTTP-сервер можно с помощью нескольких строк кода, но для построения решения, готового к работе в реальных
условиях, разработчику потребуется тщательно спроектировать и аккуратно
собрать эти модули в единую систему.
Фреймворки: писать код самому или использовать готовые решения?
Как и для большинства языков, для Go существует множество сторонних фреймворков, предлагающих набор различных вспомогательных функций в одном пакете.
Найдя фреймворк, полностью соответствующий вашим потребностям, помните о том,
что они часто включают лишнюю функциональность, которая может снизить производительность или создать проблемы с безопасностью. Мы считаем, что стандартная
библиотека Go предоставляет достаточно возможностей для уверенного старта
и рекомендуем обращаться к фреймворкам только в том случае, если они предлагают
функции, которые вы не можете реализовать самостоятельно.
8.1. Маршрутизация запросов и обработка данных
В предыдущих главах мы уже создавали простейшие веб-серверы (в частности,
в главах 1 и 7), но пока не касались более сложных тем, связанных с построением
полноценного серверного приложения. Зрелое веб-приложение имеет множество
компонентов, требующих гибкой обработки данных, за которую по большей части
отвечают библиотеки, в нашем случае — стандартный пакет net/http. Однако
для глубокого понимания принципа работы приложения нам следует выйти
за рамки этих абстракций. Мы должны точно знать, что именно запрашивает
клиент и в каком контексте. Он создает контент или считывает его? Должен ли
запрос быть аутентифицирован? И если да, то каким образом? Именно такие
детали определяют, как будет функционировать приложение. Причем на многие
из этих вопросов нужно ответить еще до того, как начнет выполняться логика
обработки запроса.
Маршрутизатор или мультиплексор?
В этой главе мы будем использовать термины «маршрутизатор» (или «роутер»)
и «мультиплексор» как синонимы, обозначающие компонент, который сопоставляет
шаблон маршрута с функцией-обработчиком. Такое определение не является строгим, и в других источниках вы можете встретить иную трактовку.
238
Глава 8. Создание HTTP-сервера
Первым делом мы должны научиться принимать и передавать данные в нужном
формате, направлять запросы к соответствующим обработчикам и использовать
заголовки для описания намерений запроса и их корректной интерпретации.
Сначала мы рассмотрим способы маршрутизации запросов, позволяющие обрабатывать совпадающие пути с учетом HTTP-метода, а в разделе 8.2 усовершенствуем этот подход.
8.1.1. Маршрутизация на основе HTTP-методов
Когда от клиента или пользователя поступает запрос, первым делом нужно решить, как его обрабатывать. Почти всегда выбор способа обработки определяется
комбинацией пути и типа запроса, и именно за это отвечает маршрутизатор.
В любом более или менее сложном веб-приложении приходится обрабатывать
несколько маршрутов. Как вы видели в предыдущих главах, сопоставить конечную точку с маршрутом с помощью пакета http языка Go — задача тривиальная. Для этого достаточно передать функцию, соответствующую сигнатуре
интерфейса HandlerFunc:
http.HandleFunc("/home", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Welcome to my homepage")
})
Но что, если мы хотим реализовать API в стиле REST, логика которого зависит
от HTTP-метода? Возьмем, к примеру, простой раздел с комментариями на
веб-странице, например в блоге. Метод GET можно использовать для получения
комментариев, а POST — для их добавления. При этом допускается задействовать
один и тот же маршрут /comments и определять логику обработки на основе
HTTP-метода.
CRUD или не CRUD — вот в чем вопрос
В первые годы популяризации стиля REST строгое следование модели CRUD (Create
[«создание»], Read [«чтение»], Update [«модификация»] и Delete [«удаление»]) воспринималось практически как догма. Хотя она действительно хорошо соотносится
со стандартными HTTP-методами, сложность реальных приложений зачастую побу
ждает отказаться от этой схемы. Кроме того, не все клиентские приложения стабильно
поддерживают такие методы, как OPTIONS, PUT, PATCH и DELETE, а некоторые вообще
неправильно определяют запросы, подлежащие кэшированию.
Как и во многих других случаях, модель CRUD не является универсальным решением: архитектура приложения должна определяться целями проекта, а не наоборот.
Проблема. Мы хотим использовать одну и ту же конечную точку для создания
и чтения данных. Однако стандартный маршрутизатор Go сопоставляет путь
8.1. Маршрутизация запросов и обработка данных
239
на основе кратчайшего совпадения, не учитывая дополнительные данные
запроса, такие как заголовок и HTTP-метод.
Решение. Рассмотрим два подхода. Первый является наиболее простым и сводится к включению метода в сам шаблон маршрута. Второй предполагает
написание обертки для обработчика, позволяющей вручную распределять
запросы, что предоставляет дополнительный контроль над маршрутизацией
на основе различных атрибутов запроса.
Обсуждение. У второго подхода есть недостатки: он не позволяет повторно
использовать код и требует, чтобы для каждого набора возможных маршрутов
был написан свой обработчик, охватывающий все допустимые HTTP-методы.
Однако если у вас есть один общий путь, для которого нужно реализовать
несколько вариантов обработки в зависимости от метода, то такой способ
вполне подойдет.
Чтобы принимать запрос по одному и тому же пути с учетом HTTP-метода,
нужно добавить к пути специальное обозначение или префикс метода. В листинге 8.1 показано, как это можно сделать.
Листинг 8.1. И
спользование встроенных средств для маршрутизации
запросов на основе HTTP-метода
package main
import (
"net/http"
)
func getComments(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("here you'll get the comments"))
}
func postComments(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("thank you for posting a comment"))
}
func main() {
http.HandleFunc("GET /comments", getComments)
http.HandleFunc("POST /comments", postComments)
}
Обработчик
GET-запросов
Обработчик
POST-запросов
Направляет GET-запросы
к обработчику getComments
if err := http.ListenAndServe(":8000", nil); err != nil {
panic(err)
}
Направляет
POST-запросы
к обработчику
postComments
Мы можем реализовать этот подход, добавив обработчики для методов GET и POST,
которые позволят пользователям просматривать комментарии и добавлять их
в срез comment, хранящийся в памяти (листинг 8.2), соответственно.
240
Глава 8. Создание HTTP-сервера
Листинг 8.2. Использование одного маршрута с разными HTTP-методами
package main
import (
"fmt"
"io/ioutil"
"net/http"
"time"
)
type comment struct {
text
string
dateString string
}
Форма исходной структуры
данных comment
var comments []comment
Целевые
функции-обработчики
func getComments(w http.ResponseWriter, r *http.Request) {
commentBody := ""
for i := range comments {
commentBody += fmt.Sprintf("%s (%s)\n", comments[i].text,
comments[i].dateString)
}
fmt.Fprintln(w, fmt.Sprintf("Comments: \n%s", commentBody))
}
func postComments(w http.ResponseWriter, r *http.Request) {
commentText, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
Целевые
функции-обработчики
comments = append(comments, comment{
text:
string(commentText),
dateString: time.Now().Format(time.RFC3339),
})
}
w.WriteHeader(http.StatusOK)
func main() {
http.HandleFunc("GET /comments", getComments)
http.HandleFunc("POST /comments", postComments)
}
if err := http.ListenAndServe(":8004", nil); err != nil {
panic(err)
}
Мы можем опробовать это на практике, отправив запросы по обоим маршрутам
через браузер и/или команду cURL. С помощью последней у нас появляется
8.1. Маршрутизация запросов и обработка данных
241
возможность передать комментарий в теле POST-запроса, создав тем самым
новую запись:
curl -X POST http://localhost:8004/comments -d "I'm new here"
Затем можно открыть тот же URL-адрес в браузере или выполнить GET-запрос
(рис. 8.1).
Рис. 8.1. Комментарии, полученные через GET-запрос,
отображаются в хронологическом порядке
Любой запрос к конечной точке /comments, выполненный с методом, отличным
от GET или POST, вернет ответ с кодом 405 Method Not Allowed. Стоит отметить,
что мы не ограничены стандартными HTTP-методами. Следующий пример допустим и будет корректно обработан, хотя, по сути, это скорее забавный курьез,
чем практическое решение:
http.HandleFunc("FOOBAR /comments", fooBarHandler)
Логику маршрутизации на основе HTTP-методов несложно воспроизвести вручную. Подобные схемы реализуются нечасто, но возьмем, к примеру, конечную
точку с операцией upsert, обрабатывающую POST-запросы. Хотя большинство баз
данных и так способно выполнять частичные обновления, давайте рассмотрим
паттерн, представленный в листинге 8.3.
Листинг 8.3. Р
учная маршрутизация на основе метода с дополнительной
логикой
package main
import (
"encoding/json"
"io"
"net/http"
)
type comment struct {
ID
int
`json:"id,omitempty"`
Comment string `json:"comment,omitempty"`
}
242
Глава 8. Создание HTTP-сервера
func upsertHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
Проверяет факт поступления
postBody, err := io.ReadAll(r.Body)
обобщенного POST-запроса
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
}
}
var postComment comment
if err := json.Unmarshal(postBody, &postComment); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
В случае получения POST-запроса
проверяет, указан ли ID;
if postComment.ID == 0 {
если нет — создает комментарий
// createCommentHandler(w, r)
} else {
Если ID указан, перенаправляет запрос
// upsertCommentHandler(w, r)
к обработчику обновления
}
if r.Method == http.MethodPut || r.Method == http.MethodPatch {
// upsertCommentHandler(w, r)
При использовании метода PUT или PATCH
}
сразу вызывает обработчик обновления
Назначает промежуточную
конечную точку для всех
func main() {
методов
http.HandleFunc("/comments/upsert", upsertHandler)
if err := http.ListenAndServe(":8000", nil); err != nil {
panic("could not start server")
}
}
Такой подход делает обработку данных более устойчивой к пользовательским
ошибкам, даже если структура переданных данных отличается от ожидаемой.
Как минимум это позволяет охватить ряд потенциальных сценариев. В дальнейшем мы сможем упростить код, не создавая отдельный подмаршрутизатор
для каждого верхнеуровневого пути.
СОВЕТ Удобным инструментом для взаимодействия с API прямо из браузера является Postman, доступный в виде расширения для Google Chrome: https://mng.bz/PdXw.
В некоторых случаях бывает полезно дополнять возвращаемые сообщения об
ошибках метаданными. В REST-архитектуре метод OPTIONS используется для
указания допустимых HTTP-методов для конкретной конечной точки. Чтобы
не добавлять их вручную, можно использовать самодокументирующиеся API
или генерировать такие конечные точки с помощью больших языковых моделей (LLM). Хотя статус StatusMethodNotAllowed сам по себе говорит о причине
ошибки, в ряде случаев не будет лишним дополнить его поясняющим текстовым
сообщением.
8.1. Маршрутизация запросов и обработка данных
243
ПРИМЕЧАНИЕ Архитектурный стиль REST не ограничивается HTTP-методами.
Мы рекомендуем прочитать (или перечитать) оригинальную работу Роя Филдинга.
Прошло уже более 20 лет, а этот подход по-прежнему широко используется в вебразработке (https://mng.bz/JY6o).
8.1.2. Расширение контроля над сервером
До сих пор мы запускали сервер с помощью http.ListenAndServe, однако можем
получить больший контроль над его поведением (листинг 8.4).
Листинг 8.4. Управление сервером
package main
import (
"fmt"
"net/http"
"time"
)
func timeoutHandler(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
Передает обработчик нашему
w.Write([]byte("you should never see me"))
пользовательскому серверу
}
Определяет обработчик http.Handler
func main() {
для маршрутизации/мультиплексирования
muxer := http.NewServeMux()
muxer.HandleFunc("GET /timeout", timeoutHandler)
Создает пользовательский
server := http.Server{
http.Server с параметрами
Addr:
":8000",
ReadTimeout: 1 * time.Second,
Задает тайм-ауты, в том числе
WriteTimeout: 2 * time.Second,
для отправки ответа
Handler:
muxer,
Передает обработчик нашему
}
пользовательскому серверу
Запускает сервер и начинает
обработку запросов
if err := server.ListenAndServe(); err != nil {
}
}
panic(fmt.Sprintf("could not start server: %s", err.Error()))
Если вы обратитесь к маршруту /timeout через команду curl http://local
host:8000, то получите пустой ответ с кодом 200. Параметр сервера WriteTimeout
устанавливает дедлайн, то есть лимит времени, в течение которого обработчик
должен сформировать ответ. Поскольку в нашем случае вызов Sleep() длится
дольше заданного тайм-аута, срок исполнения запроса оказывается превышенным. Параметр ReadTimeout, в свою очередь, вернет ошибку 503. В языке Go
предусмотрена обертка http.TimeoutHandler, которая выполняет необходимые
базовые операции автоматически:
Handler:
http.TimeoutHandler(muxer, 2*time.Second, "request took too long"),
244
Глава 8. Создание HTTP-сервера
Данная обертка будет возвращать ошибку 503 для любого запроса, время выполнения которого превышает заданный лимит времени. Кроме того, структура
http.Server поддерживает настройку тайм-аутов для обработки заголовков и неактивных соединений, а также позволяет задавать параметры TLS и логирования.
Тайм-ауты можно обрабатывать и на уровне конкретного обработчика, используя контекст http.Request через r.Context(). Контексты применяются
в любых функциях, которым необходимо управлять временем выполнения или
отслеживать состояние запроса.
Контексты также применяются для управления состоянием: в контексте
http.Request уже содержатся сведения о запросе (параметры запроса, URL-
адрес, путь и т. д.), так что вы будете регулярно работать с ним при реализации
веб-проектов. Чтобы добавить собственные значения в контекст, достаточно
использовать обертку WithContext, как показано в листинге 8.5.
Листинг 8.5. Передача состояния через контекст
package main
import (
"context"
"fmt"
"net/http"
"regexp"
)
Регулярное выражение
для проверки допустимых
браузеров
Возвращает
функцию,
имеющую доступ
func uaMiddleware(next http.HandlerFunc) http.HandlerFunc {
к стандартным
return func(w http.ResponseWriter, r *http.Request) {
переменным
userAgent := r.UserAgent()
обработчика
if !validAgent.MatchString(userAgent) {
Проверяет строку userAgent
w.WriteHeader(http.StatusBadRequest)
на соответствие шаблону
return
}
var validAgent = regexp.MustCompile(`(?i)(chrome|firefox)`)
}
ctx := context.WithValue(r.Context(), "agent", userAgent)
r = r.WithContext(ctx)
Добавляет контекст к исходному
next(w, r)
запросу http.Request
Вызывает переданный обработчик
с обновленным запросом
Извлекает значение
func uaStatusHandler(w http.ResponseWriter, r *http.Request) {
из контекста
ua := r.Context().Value("agent").(string)
fmt.Fprint(w, fmt.Sprintf("congratulations, you are using: %s", ua))
}
Добавляет значение userAgent в тело
ответа в качестве подтверждения
func main() {
http.HandleFunc("GET /withcontext", uaMiddleware(uaStatusHandler))
}
}
if err := http.ListenAndServe(":8000", nil); err != nil {
panic("could not start server")
Оборачивает обработчик
}
в промежуточную функцию
8.1. Маршрутизация запросов и обработка данных
245
Этот код проверяет идентификационную строку (user agent) на соответствие
заданному шаблону и в зависимости от результата разрешает или блокирует
обработку запроса. Важно отметить, что в ту же логику можно обернуть любой
обработчик. Именно поэтому данный подход часто используется для аутентификации: запросы, требующие проверки подлинности, оборачиваются в обработчики, проверяющие сессию или считывающие такие атрибуты запроса, как
user agent. Если же мы хотим просто передавать значения между несколькими
запросами, можно воспользоваться другим приемом — создать структуру верхнего уровня, поля которой будут доступны из обработчиков (листинг 8.6).
Листинг 8.6. Передача состояния через структуру
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
"github.com/jackc/pgx/v5"
type serverControl struct {
db *pgx.Conn
}
Структура верхнего уровня для хранения
подключения к базе данных
Инициализирует serverControl
func main() {
для использования в функции main()
var sc serverControl
{
var err error
sc.db, err = pgx.Connect(context.Background(),
Устанавливает подключение
"postgres://localhost:5432")
к PostgreSQL
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
Использует метод
}
структуры serverControl,
}
открывая доступ к ее
свойствам
http.HandleFunc("GET /database", sc.databaseHandler)
}
if err := http.ListenAndServe(":8000", nil); err != nil {
log.Fatal(err)
}
type comment struct {
UserID int
`json:"userID"`
Comment string `json:"comment"`
}
func (sc serverControl) databaseHandler(w http.ResponseWriter, r *http.Request) {
var comments []comment
246
Глава 8. Создание HTTP-сервера
Выполняет запрос к базе данных через подключение, хранящееся в serverControl
rows, err := sc.db.Query(`select user_id, comment from comments limit $1`, 5)
defer rows.Close()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
for rows.Next {
var c comment
if err := rows.Scan(&c.UserID, &c.Comment); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
comments = append(comments, c)
}
output, err := json.Marshal(comments)
if err != nil {
}
}
w.Header().Set("Content-type", "application/json")
w.Write(output)
В этом примере мы подключаем базу данных PostgreSQL и предоставляем обработчику доступ к ней через обертку. Здесь используется встроенный пакет Go
database/sql и сторонний драйвер pgx для PostgreSQL. Такие значения можно
передавать вниз по цепочке вызовов, как в нашем примере, и задействовать на
любом этапе обработки. Однако корректировать их таким образом, чтобы изменения были видны выше по цепочке, нельзя. В большинстве случаев это даже
удобно, но есть и другой распространенный способ разделения преобразованных
данных. Правда, в этом случае возникает риск, поскольку любой обработчик
может изменить или закрыть соединение с базой данных, тем самым нарушив
работу последующих запросов.
8.2. Маршрутизация на основе значений,
извлеченных из пути запроса
В предыдущих примерах мы позволяли пользователям создавать и просматривать список комментариев. Если бы мы захотели предоставить им возможность просмотра отдельного комментария, можно было бы воспользоваться
параметром запроса. Однако в настоящее время распространенной практикой
является включение числовых, символьных (slug) или UUID-идентификаторов
прямо в URL-путь.
В примерах с промежуточными функциями мы можем легко парсировать URL
запроса и извлечь id с помощью path.Split или регулярных выражений. Однако,
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
247
начиная с версии 1.22, в Go появился более простой способ, который заключается
в использовании значения PathValue из запроса (листинг 8.7).
Листинг 8.7. Извлечение информации из пути запроса
func getComment(w http.ResponseWriter, r *http.Request) {
Извлекает значение
commentID, err := strconv.Atoi(r.PathValue("id"))
непосредственно
if err != nil {
в обработчике
w.WriteHeader(http.StatusInternalServerError)
return
Если значение не является целым
числом, возвращает ошибку
}
if commentID == 0 || len(comments) < commentID {
w.WriteHeader(http.StatusNotFound)
return
}
}
fmt.Fprintf(w, "Comment %d: %s", commentID, comments[commentID-1].text)
func main() {
http.HandleFunc("GET /comments", getComments)
http.HandleFunc("GET /comments/{id}", getComment)
http.HandleFunc("POST /comments", postComments)
}
Если идентификатор
некорректен, возвращает
ошибку 404…
…в противном случае отображает
комментарий в браузере
Включает значение из пути
в шаблон маршрута
if err := http.ListenAndServe(":8000", nil); err != nil {
panic(err)
}
Как уже было сказано, сопоставление путей в Go обычно работает по принципу
«наиболее длинного совпадения». Однако с появлением переменных пути это
поведение немного меняется. В таких случаях Go ориентируется на специфичность маршрута, поэтому /comments/{id} будет иметь приоритет над /comments/
longerpathvalue. Если мы обратимся к конечной точке, передав идентификатор
или индекс комментария, не соответствующий ни одному из существующих
значений, то получим ошибку 404 (рис. 8.2).
Рис. 8.2. Извлечение значения из переменной пути
248
Глава 8. Создание HTTP-сервера
Ничто не мешает вам создать несколько обработчиков с помощью http.New
ServeMux и использовать их в качестве маршрутизаторов для отдельных путей.
Каждый из них разрешается привязать к определенному шаблону пути и задать
подмаршруты на его основе. Например, маршруты /users/new и /comments/new
можно определить следующим образом:
usersRouter := http.NewServeMux()
commentsRouter := http.NewServeMux()
usersRouter.HandleFunc("/new", func(w http.ResponseWriter,
r *http.Request) {
w.Write([]byte("New user form"))
})
commentsRouter.HandleFunc("/new", func(w http.ResponseWriter,
r *http.Request) {
w.Write([]byte("New comment form"))
})
mainRouter := http.NewServeMux()
mainRouter.Handle("/users/", http.StripPrefix("/users", usersRouter))
mainRouter.Handle("/comments/", http.StripPrefix("/comments",
commentsRouter))
if err := http.ListenAndServe(":8085", mainRouter); err != nil {
panic(err)
}
Это лишь один из возможных подходов. Если вы посмотрите на другие вебфреймворки для Go, то заметите, что у каждого из них свой стиль. Некоторые
предусматривают отдельные методы для добавления маршрутов, например
muxer.Post() и muxer.Get(). Другие предполагают указание HTTP-метода непосредственно при регистрации обработчика (как в нашем примере). К прочим
подходам относятся использование заранее определенных значений вроде :id
и применение регулярных выражений напрямую. В простых случаях вполне
достаточно расширения ServeMux. Однако имеет смысл ознакомиться с популярными фреймворками и выбрать тот стиль, который подходит именно вам.
Вот некоторые из них:
Chi — использует итеративный подход, позволяя группировать маршруты
и при необходимости добавлять к получившимся группам промежуточные
обработчики (https://github.com/go-chi/chi);
httprouter — быстрый и мощный маршрутизатор с жесткими ограничениями,
например, принимает только точные совпадения пути (https://github.com/
julienschmidt/httprouter);
Gin — построен на базе httprouter и вдохновлен Sinatra, классическим Rubyфреймворком времен раннего Web 2.0 (https://github.com/gin-gonic/gin).
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
249
8.2.1. Обработка данных POST-запросов и параметров запроса
Теперь, когда мы научились корректно распределять различные типы запросов,
адресованных одной и той же конечной точке, давайте обсудим способы обработки наиболее распространенных данных, сопровождающих подобные запросы. При использовании метода GET для передачи различных опций чаще всего
применяются параметры запроса, а в случае с методом POST такие данные, как
правило, включаются в тело самого запроса. Хотя с технической точки зрения
данные будут иметь ту же структуру, этот подход уменьшает вероятность возникновения ошибок, связанных с повторной отправкой запросов и их кэшированием.
Впрочем, здесь речь идет скорее о соглашениях, нежели о строгих технических
требованиях. В листинге 8.8 мы используем параметры запроса, чтобы реализовать простой поиск комментариев по их содержимому и по имени пользователя.
Листинг 8.8. Использование параметров запроса
package main
import (
"fmt"
"net/http"
"regexp"
"time"
)
type comment struct {
text
string
username string
timestamp time.Time
}
Заранее подготовленный
список комментариев
var comments = []comment{ //
{text: "first!", username: "Bill",
timestamp: makeTime("2023-09-01T00:00:00Z")},
{text: "darn, I _just_ missed it", username: "Jill",
timestamp: makeTime("2023-09-01T00:00:20Z")},
{text: "
maybe next time", username: "Bill",
timestamp: makeTime("2023-09-01T00:01:00Z")},
{text: "ah, I see I'm late to the show, hello everyone", username:
"Phil", timestamp: makeTime("2023-09-01T00:01:05Z")},
}
func makeTime(val string) time.Time {
t, err := time.Parse(time.RFC3339, val)
if err != nil {
panic(err)
}
return t
}
func commentsHandler(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
Проверяет,
указан ли
параметр
username в URL
250
Глава 8. Создание HTTP-сервера
if username := params.Get("username"); username != "" {
filteredComments := []comment{}
for k := range comments {
if comments[k].username == username {
filteredComments = append(filteredComments, comments[k])
}
}
comments = filteredComments
В случае передачи
}
параметра search
отфильтровывает
if search := params.Get("search"); search != "" { //
комментарии,
совпадающие с текстом,
filteredComments := []comment{}
с помощью регулярного
re := regexp.MustCompile(search)
выражения
for k := range comments {
if re.MatchString(comments[k].text) {
filteredComments = append(filteredComments, comments[k])
}
}
comments = filteredComments
}
Добавляет совпадающие
commentString := ""
комментарии в срез строк
for k := range comments { //
commentString += fmt.Sprintf(
"%s (%s) @ %s\n",
comments[k].text,
comments[k].username,
comments[k].timestamp.Format("2006-01-02 15:04:05"),
)
}
}
fmt.Fprint(w, commentString)
Маршрутизатор и слушатель
для одного пути
func main() { //
http.HandleFunc("/comments", commentsHandler)
if err := http.ListenAndServe(":8000", nil); err != nil {
panic(err)
}
}
Принимая параметры запроса, мы можем фильтровать отображаемые данные
по точному совпадению имени пользователя и/или с помощью регулярного
выражения, примененного к тексту комментария. При этом мы явно проверяем,
не являются ли значения параметров username и query пустыми. Хотя тип Values
из пакета url предоставляет метод Has, который позволяет проверить наличие
заданного значения: он возвращает true даже в случае пустой строки. Поэтому
явная проверка оказывается более надежным решением.
ПРИМЕЧАНИЕ Хотя в нашем примере допускается передача регулярного выражения
напрямую через URL, это небезопасная практика, поскольку регулярные выражения
могут создавать уязвимости для DoS-атак. Несмотря на то что в документации Go
указано, что «время выполнения регулярных выражений линейно зависимо от размера входных данных», не стоит позволять пользователям отправлять регулярные
выражения напрямую на сервер для парсинга.
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
251
8.2.2. Обработка данных форм
В обработчиках из листинга 8.2 мы реализовали возможность чтения и записи
комментариев с помощью одного и того же маршрута. Мы могли просматривать
комментарии через браузер, но для их добавления приходилось использовать
cURL или другую HTTP-библиотеку. Если же мы хотим добавлять комментарии
прямо в веб-приложении, например, как в блоге через поле ввода комментариев,
можно создать простую HTML-форму с методом POST для передачи соответствующих данных. Данные формы представляют собой не что иное, как тело
запроса, обычно используемое с методами POST, PUT и PATCH. Для обработки
такой формы необходимо вызвать метод ParseForm(), который преобразует
тело запроса в структуру url.Values и предоставляет вспомогательные методы — в нашем случае для проверки полей формы. В листинге 8.9 мы помещаем
форму на веб-страницу, а обработчик POST-запросов превращает данные формы в пары «ключ — значение», которые можно использовать для добавления
комментария в срез.
Листинг 8.9. Отправка данных формы
package main
import (
"fmt"
"net/http"
"time"
)
type comment struct {
username
string
text
string
dateString string
}
Незначительно измененная
структура комментария
var comments []comment
func commentsHandler(w http.ResponseWriter, r *http.Request) {
body := `
<html>
<head>
<!-- стили и метаданные -->
</head>
<body>`
commentBody := ""
for i := range comments {
commentBody += fmt.Sprintf("<div class='comment'>%s (%s) @%s</div>", comments[i].text, comments[i].dateString,
comments[i].username)
}
body += fmt.Sprintf(`
<h1>Comments</h1>
%s
HTML-код
с формой
для отправки
комментариев
252
Глава 8. Создание HTTP-сервера
<form method="POST" action="/comments">
<div><input type="text" placeholder="Username" name="username"
/></div>
<textarea placeholder="Comment text" name="comment"></textarea>
<div><input type="submit" value="Submit" /></div>
</form>
</body>
</html>
`, commentBody)
}
w.Write([]byte(body))
func postHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
Извлекает значения
username := r.Form.Get("username")
из формы
commentText := r.Form.Get("comment")
comments = append(comments, comment{
username:
username,
text:
commentText,
dateString: time.Now().Format(time.RFC3339)
})
}
Вызов ParseForm()
для преобразования
данных формы
в структуру url.Values
Добавляет
комментарий в срез
http.Redirect(w, r, "/comments", http.StatusFound)
func main() {
http.HandleFunc("GET /comments", commentsHandler)
http.HandleFunc("POST /comments", postHandler)
}
if err := http.ListenAndServe(":8085", nil); err != nil {
panic(err)
}
Обратите внимание: мы вызываем метод Form.Get(), предполагая наличие значения, но не проверяем его через Has. Такая проверка была бы полезна, если бы
пользователь отправил некорректную форму — случайно или умышленно. Чтобы
учесть подобный сценарий, можно добавить соответствующую проверку перед
чтением данных:
if !r.Form.Has("username") || !r.Form.Has("comment") {
w.WriteHeader(http.StatusBadRequest)
return
}
Такой подход позволяет предоставить пользователю более подробную обратную связь. Однако в случае отправки вредоносного запроса это может дать
злоумышленнику больше информации о том, как работает обработчик и какие
данные он принимает.
О методах защиты от вредоносных запросов, в том числе связанных с отправкой
форм и межсайтовой подделкой запроса, мы подробно поговорим в главе 10.
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
253
Следует понимать, что этот пример небезопасен: он отображает пользовательский ввод без какой-либо очистки, что создает риск XSS-атаки (Cross-Site
Scripting — межсайтовый скриптинг). Встроенный шаблонизатор Go из пакета
html/template помогает избежать этой уязвимости, автоматически экранируя
пользовательский ввод.
СОВЕТ Как и в случае с парсингом регулярных выражений, никогда не следует доверять неэкранированному пользовательскому вводу. Это означает, что любые данные,
полученные от пользователя, нужно очищать до того, как они попадут в другие части
системы. Некоторые из самых распространенных и опасных уязвимостей связаны
именно с передачей необработанных данных другим процессам. Ознакомиться со
списком основных уязвимостей по версии организации OWASP можно здесь: https://
owasp.org/www-project-top-ten. Способы защиты от них мы обсудим в главах 9 и 10.
Если вы откроете сайт в браузере, то увидите примерно то же, что и раньше,
только на этот раз наши данные отправляются через HTML-форму (рис. 8.3).
Рис. 8.3. Форма для отправки комментариев
Итак, теперь мы умеем получать данные из веб-запросов и маршрутизировать
их с учетом пути, HTTP-метода, параметров запроса и много другого. Помимо
данных, передаваемых через параметры запроса, мы можем обрабатывать тело
POST-запроса на базовом уровне и извлекать отдельные его параметры в целевом
обработчике.
Мы использовали эти приемы для организации ввода пользовательских данных
и обработки комментариев, однако методы обработки данных из POST/PATCH/PUT/
254
Глава 8. Создание HTTP-сервера
DELETE-запросов имеют гораздо более широкую область применения, особенно
в контексте проектирования API.
Наилучший способ записи
Вероятно, вы заметили, что в последнем примере запись в ResponseWriter выполнялась не так, как раньше. При использовании подобных интерфейсов не всегда
бывает очевидно, какой именно объект Writer следует выбрать. Мы задействовали
w.Write([]byte) и fmt.Fprint(w, []byte), однако существует множество способов добиться того же самого результата, включая применение других реализаций
интерфейса io.Writer. Подход с w.Write() является наиболее распространенным,
но в конечном счете все они делают одно и то же. Так что выберите подходящий
способ и придерживайтесь его.
8.2.3. Чтение и запись cookie и других заголовков
Как вы помните, HTTP — это протокол без сохранения состояния, поэтому для
отслеживания состояния между запросами часто используются cookie-файлы. Как
правило, они хранят временную информацию, которую при необходимости легко
восстановить. В листинге 8.10 мы будем отслеживать имя пользователя от запроса
к запросу, устанавливая его только в том случае, если оно не было задано ранее.
Листинг 8.10. Установка cookie
func postHandler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.Form.Get("username")
usernameCookie, err := r.Cookie("username")
if err == nil {
username = usernameCookie.Value
}
commentText := r.Form.Get("comment")
comments = append(comments, comment{
username:
username,
text:
commentText,
dateString: time.Now().Format(time.RFC3339),
})
http.SetCookie(w, &http.Cookie{
Name:
"username",
Value:
username,
Expires: time.Now().Add(24 * time.Hour),
})
}
http.Redirect(w, r, "/comments", http.StatusFound)
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
255
Использовать cookie в последующих запросах довольно легко. Если мы обнаружим cookie-файл с именем пользователя, совпадающим с именем в комментарии,
то заменим его на строку "You", как показано в листинге 8.11.
Листинг 8.11. Чтение и использование значения cookie
func commentsHandler(w http.ResponseWriter, r *http.Request) {
username := ""
Извлекает
значение cookie
usernameCookie, err := r.Cookie("username")
if err == nil {
username = usernameCookie.Value
}
body := `
<html>
<head>
<title>Comments</title>
<style type="text/css">
body {
width: 500px;
margin: 0 auto;
}
h1 {
margin: 0;
padding: 0;
}
div {
padding: 20px 0;
}
textarea, input[type="text"] {
width: 100%;
}
textarea {
height: 200px;
}
.comment {
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 4px;
}
</style>
</head>
<body>`
Сравнивает с именем
commentBody := ""
автора комментария и при
for i := range comments {
совпадении отображает
displayName := comments[i].username
строку "You"
if username != "" && displayName == username {
displayName = "You"
}
commentBody += fmt.Sprintf("<div class='comment'>%s (%s) - @%s</div
>", comments[i].text, comments[i].dateString, displayName)
}
256
Глава 8. Создание HTTP-сервера
Разумеется, в нашем примере существует риск того, что несколько пользователей
выберут одинаковое имя. Однако он показывает, как можно сохранить и повторно
использовать значение. Даже при отсутствии постоянного хранилища мы можем
воспользоваться ассоциативным массивом, чтобы проверить, не занято ли имя
пользователя, и на основании этого либо отклонить его, либо добавить к нему
уникальный идентификатор.
Пример, безусловно, не полностью охватывает тему работы с cookie, о чем красноречиво свидетельствуют надоедливые всплывающие окна на сайтах. Cookieфайлы позволяют сохранять состояние между запросами в рамках протокола,
который сам по себе состояние не сохраняет. Используйте их всякий раз, когда
вам нужно получить от пользователя данные, зависящие от предыдущих взаи
модействий. В следующем разделе мы рассмотрим более безопасный способ
применения cookie для идентификации (рис. 8.4).
Рис. 8.4. Использование значений cookie для обозначения комментариев
пользователя. Cookie-файл хранит имя пользователя, и, если этот пользователь
снова посещает страницу, его имя заменяется строкой "You"
Шаблоны или отформатированный текст
До сих пор мы выводили данные напрямую в ResponseWriter, чаще всего с помощью
таких форматирующих функций, как Fprintf. Однако в Go есть мощная система
шаблонов для генерации различных типов данных. Особым удобством отличаются
HTML-шаблоны, которые поддерживают встроенную логику, подключение других
шаблонов и работу с переменными. Мы подробнее поговорим об этом в главе 9.
8.2. Маршрутизация на основе значений, извлеченных из пути запроса
257
8.2.4. Обработка веб-токенов JSON
В предыдущем примере мы использовали cookie-файлы для хранения текстового
значения. Однако на практике cookie чаще применяются для хранения идентификаторов сеансов. Такие идентификаторы, как правило, сопоставляются
с базой данных на сервере и позволяют определить пользователя и связанную
с ним метаинформацию. Сессионные cookie-файлы имеют ряд недостатков:
они уязвимы к перехватам сеанса, если передаются в незашифрованном виде,
а также требуют ответа сервера при каждом запросе (например, для получения
данных из базы).
Одной из альтернатив cookie в контексте управления сеансами являются
JWT (JSON Web Token). В отличие от обычных cookie-файлов и сессионных
идентификаторов, JWT изначально предназначены для безопасного хранения
идентификационной информации непосредственно в токене. Это дает два
ключевых преимущества: повышенную приватность при передаче данных
и снижение нагрузки на серверные ресурсы. В листинге 8.11 мы хранили идентификатор пользователя (в виде имени) с помощью cookie. Это весьма простое
и легковесное решение, не требующее проверки на сервере, однако такие данные
легко подделать на стороне клиента, поскольку они хранятся в открытом виде.
JWT работают схожим образом, но имеют встроенные механизмы защиты от
подделки.
В основе JWT лежит JSON-объект с информацией, зашифрованной с помощью открытого и закрытого ключей. Внутри находятся сведения о методе
шифрования и полезная нагрузка с утверждениями (claims), которая обычно
содержит такие идентифицирующие данные, как адрес электронной почты
и имя пользователя.
Безопасная аутентификация
Когда речь идет о безопасности, особенно об идентификации и аутентификации,
лучше использовать зрелые, проверенные временем библиотеки, а не писать собственные решения. Хотя веб-токен JSON устроен не особенно сложно, ошибки при
его реализации могут привести к захвату учетной записи и другим более серьезным
проблемам. На сайте https://jwt.io представлено подробное сравнение библиотек для
разных языков, включая перечень поддерживаемых функций и рейтинг на GitHub:
https://jwt.io/libraries?language=Go.
В листинге 8.12 показана простая реализация с использованием одной из самых
популярных библиотек — jwt-go (https://github.com/golang-jwt/jwt). Наиболее важные моменты, на которые стоит обратить внимание, — это способ расшифровки
ключей и типы утверждений, которые можно включить в токен.
258
Глава 8. Создание HTTP-сервера
Листинг 8.12. Создание и проверка JWT
package main
import (
"log"
"time"
)
jwt "github.com/golang-jwt/jwt/v5"
var SIGNING_KEY = []byte("this-value-should-be-secret")
type claim struct {
jwt.RegisteredClaims
}
Наш закрытый ключ. Обычно
открытые и закрытые ключи
загружаются из .pem-файла
Обертка вокруг раздела
с утверждениями
func generateClaim() (string, error) {
claims := claim{
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Subject:
"nobody@example.com",
},
}
Генерирует
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
токен на основе
ss, err := token.SignedString(SIGNING_KEY)
утверждений
if err != nil {
и ключа подписи
return "", err
}
return ss, nil
}
func main() {
if signed, err := generateClaim(); err != nil {
panic(err)
} else {
token, err := jwt.Parse(signed, func(token *jwt.Token) (interface{}, error) {
return SIGNING_KEY, nil
})
Парсит сам токен
if err != nil {
panic(err)
Проверяет утверждения
}
и возвращает
ассоциативный массив
if validatedClaims, ok :=
значений
token.Claims.(jwt.MapClaims); ok && token.Valid {
log.Println(validatedClaims["sub"])
} else {
panic("error getting claims")
}
}
}
Чтобы спровоцировать ошибку проверки подписи, достаточно изменить значение SIGNING_KEY в блоке валидации на любой другой срез байтов. Как видно
8.3. Генерация ошибок и базовая аутентификация
259
из примера, JWT существенно упрощают процесс идентификации, поскольку
клиент просто отправляет набор утверждений, защищенный шифрованием.
В целом этот подход может оказаться избыточным для небольших проектов,
однако вы наверняка столкнетесь с ним при использовании сторонних SSOрешений (Single Sign-On — технология единого входа), таких как Auth0.
Недостатки JWT
Мы рассмотрели преимущества JWT, однако у них есть и недостатки. Один из главных — сложность аннулирования токена. В отличие от значений, хранящихся в базе
данных, JWT нельзя просто удалить. Единственный способ — это использовать специальный сервис со списком отозванных токенов и выполнять проверку при каждом
запросе, что сводит на нет одно из ключевых преимуществ технологии. Спецификация
JWT доступна по адресу https://datatracker.ietf.org/doc/html/rfc7519.
8.3. Генерация ошибок и базовая аутентификация
Хотя мы уже формировали ответы на запросы, наши отчеты об ошибках были
довольно ограниченными. Между тем коды HTTP-ответов — это один из самых
легких и универсальных способов донесения сведений об ошибке до клиента. JWTтокены представляют собой удобный способ сохранения идентификационных
данных без участия серверной части, однако, помимо них, нам доступна более старая
и столь же легковесная технология — базовая HTTP-аутентификация. Мы можем
использовать ее в приложении для публикации комментариев, чтобы возвращать
ошибку в случае, если пользователь не прошел аутентификацию (листинг 8.13).
Листинг 8.13. Г енерация ответов с использованием базовой схемы
аутентификации
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"time"
)
type comment struct {
username
string
text
string
dateString string
}
var comments []comment
var validUsers = map[string]string{
"bill": "abc123",
}
Жестко заданный ассоциативный массив
с именами пользователей и паролями
260
Глава 8. Создание HTTP-сервера
func login(username, password string) bool {
if validPassword, ok := validUsers[username]; ok {
return validPassword == password
}
return false
}
Метод для проверки
учетных данных
func main() {
http.HandleFunc("GET /comments", getComments)
http.HandleFunc("POST /comments", postComments)
}
if err := http.ListenAndServe(":8000", nil); err != nil {
panic(err)
}
Извлекает имя
пользователя и пароль
из BasicAuth при
получении POST-запроса
func postComments(w http.ResponseWriter, r *http.Request) {
username, password, auth := r.BasicAuth()
if !auth || !login(username, password) {
Возвращает ошибку 403
w.WriteHeader(http.StatusUnauthorized)
при неудачной попытке входа
return
}
commentText, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
comments = append(comments, comment{username: username, text:
string(commentText), dateString: time.Now().Format(time.RFC3339)})
w.WriteHeader(http.StatusOK)
func getComments(w http.ResponseWriter, r *http.Request) {
commentBody := ""
for i := range comments {
commentBody += fmt.Sprintf("%s (%s) - @%s\n", comments[i].text,
comments[i].dateString, comments[i].username)
}
}
fmt.Fprintln(w, fmt.Sprintf("Comments: \n%s", commentBody))
Разумеется, в реальных приложениях список пользователей будет храниться
в отдельном источнике, а не в исходном коде и в идеале в зашифрованном виде.
Тем не менее пример позволяет уловить суть. В главах 9 и 10 мы обсудим работу
с базой данных и простые способы проверки имени пользователя и пароля.
Отправив некорректный запрос, например, с помощью cURL, мы можем получить соответствующий код ошибки, как показано в этом примере:
curl -X POST -i http://localhost:8085/comments -d "This will never succeed"
-u "bill:abc124"
Помимо ответов с кодом 200 OK, важно возвращать коды ошибок и по возможности завершать обработку запроса как можно раньше.
Резюме
261
СОВЕТ В Go коды HTTP-ответов представлены в виде констант, связанных с соответствующими числовыми значениями (например, StatusNotFound = 404). Однако,
чтобы сделать код более читаемым, по возможности следует использовать имена этих
констант. Ведь не каждый разработчик сразу вспомнит, что код 425 означает Too Early,
510 — Not Extended, а 418 — Teapot.
Мультиплексоры и промежуточные обработчики: писать
самостоятельно или использовать готовые?
В начале главы мы советовали взвешенно подходить к выбору фреймворков для Go.
Некоторые из них являются довольно мощными и поставляются вместе с маршрутизаторами, встроенными промежуточными обработчиками и прочими средствами.
Но все это может оказаться избыточным и сделать проект громоздким.
Применение мультиплексоров позволяет уменьшить объем кода и избежать рутинной работы, связанной с организацией маршрутизации. Многие фреймворки
предусматривают библиотеки для маршрутизации, которые можно использовать
в качестве автономного средства. Среди них есть и проверенные временем решения, например мультиплексор от Gorilla (https://github.com/gorilla/mux), который был
объявлен устаревшим, но впоследствии получил новую жизнь.
Резюме
Стандартная библиотека Go предусматривает простые средства для осуществления базовой маршрутизации запросов, однако, если вам нужна более
сложная логика, язык Go предоставляет все необходимое для расширения
и построения гибких схем маршрутизации.
Сложные веб-приложения работают со множеством источников данных,
начиная с форм и параметров запроса и заканчивая заголовками и cookieфайлами. Язык Go предоставляет все необходимые средства для получения
доступа к ним, а сторонние библиотеки обеспечивают поддержку таких
альтернативных технологий, как JWT.
Промежуточные обработчики позволяют избежать дублирования кода за
счет оборачивания HTTP-обработчиков в дополнительную логику, выполняемую заранее. Используя паттерн проектирования «Декоратор», мы
можем применять несколько оберток, передавая дополнительные данные
и при необходимости прерывая цепочку вызовов.
Возврат информации об ошибках при некорректных запросах или невозможности их обработки играет важную роль, особенно когда речь идет об
ошибках, которые клиент может исправить, например об ошибках аутентификации.
9
HTML-шаблоны и шаблоны
электронных писем
В этой главе
3 Добавление логики в шаблоны.
3 Вложенные шаблоны.
3 Наследование шаблонов.
3 Представление объектов в виде HTML-кода.
3 Шаблоны электронных писем.
Когда в программной среде требуется сформировать текстовые или HTMLответы, нередко приходится подбирать и подключать специальные библиотеки.
В предыдущих главах мы в основном отправляли форматированные строки напрямую в поток вывода. Однако в реальных приложениях такой подход сложен
в сопровождении, требует дополнительной работы и чаще приводит к ошибкам. Например, неочищенный пользовательский ввод может сделать систему
уязвимой для XSS-атак. В случае со многими другими языками разработчики
вынуждены задействовать сторонние шаблонизаторы, однако стандартная
библиотека языка Go изначально содержит средства шаблонизации как для
обычного текста, так и для HTML-кода. Обработка HTML, включая защиту
от недоверенных данных, построена поверх движка текстовых шаблонов и дополнена механизмами, учитывающими особенности HTML.
9.1. Работа с HTML-шаблонами
263
Хотя стандартная библиотека Go полноценно поддерживает HTML-шаблоны,
она не навязывает жестких ограничений. Вместо этого она предлагает решение,
благодаря которому можно расширять и компоновать шаблоны, например,
вкладывать их друг в друга или организовать их наследование. Такая простая
и гибкая модель помогает реализовать многие распространенные паттерны
работы с шаблонами.
В этой главе вы узнаете, как расширять функциональность HTML-шаблонов
и какие приемы позволяют эффективно их сочетать. По ходу дела мы дадим ряд
советов, в том числе касающихся производительности, помогающих ускорить
работу приложения. Мы покажем, как грамотно выполнить парсинг шаблона,
чтобы сократить общее время обработки, а затем — как использовать текстовые
шаблоны для генерации электронных писем.
9.1. Работа с HTML-шаблонами
Пакеты html и html/template из стандартной библиотеки Go служат основой
для работы с HTML и позволяют использовать переменные и функции внутри
шаблонов. Пакет html/template построен на базе пакета text/template, который
обеспечивает общую поддержку текстовых шаблонов. Поскольку HTML-код
представляет собой текст, для его обработки вполне можно использовать и text/
template. Однако основное преимущество html/template заключается в контекстно
осведомленных функциях обработки HTML, снимающих часть нагрузки с разработчика.
ПРИМЕЧАНИЕ В предыдущих главах мы формировали ответы, напрямую записывая
их в Writer с помощью fmt или метода Write. Хотя сейчас мы рассматриваем работу
с шаблонами, стоит отметить, что в некоторых случаях использование Write вполне
оправданно, поскольку этот способ сопряжен с меньшими издержками.
В следующих разделах мы рассмотрим приемы, которые пригодятся вам при
работе с шаблонами в собственных приложениях.
9.1.1. Обзор стандартного пакета для работы с HTML
Перед обсуждением этих приемов важно понять, как устроены пакеты в стандартной библиотеке. Пакет html содержит лишь несколько функций для
экранирования строк и его отмены, тогда как html/template предоставляет все
необходимое для взаимодействия со структурированными шаблонами, предназначенными для парсинга. Для начала рассмотрим простой HTML-шаблон
(листинг 9.1).
264
Глава 9. HTML-шаблоны и шаблоны электронных писем
Листинг 9.1. Простой HTML-шаблон
<!DOCTYPE HTML>
<html>
<head>
Заголовок, сформированный
<meta charset="utf-8">
на основе свойства Title
<title>{{.Title}}</title>
</head>
<body>
<h1>{{.Title}}</h1>
Отображение заголовка
<p>{{.Content}}</p>
и содержимого
</body>
</html>
В этом листинге продемонстрированы основы работы с шаблонами. Если
не считать действий (их еще называют директивами), заключенных в двойные
фигурные скобки, шаблон выглядит как обычный HTML-файл. В данном случае
директивы предполагают вывод значений, переданных в шаблон (например,
отображение заголовка). Однако возможности шаблонов этим не ограничиваются, в чем мы убедимся чуть позже. Следующим шагом будет вызов нашего
шаблона из кода и передача ему значений для подстановки вместо {{.Title}}
и {{.Content}}, как показано в листинге 9.2.
Листинг 9.2. Использование простого HTML-шаблона
package main
import (
"html/template"
"net/http"
)
Использует пакет html/template
вместо text/template
type Page struct {
Title, Content string
}
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Передаваемый в шаблон
Title:
"An Example",
объект данных со свойствами,
Content: "Have fun stormin' da castle.", подлежащими выводу
}
t := template.Must(template.ParseFiles("templates/simple.html"))
t.Execute(w, p)
Парсит шаблон
}
Формирует HTTP-ответ с помощью
для последующего
шаблона и набора данных
использования
func main() {
http.HandleFunc("/", displayPage)
Предоставляет результат
http.ListenAndServe(":8080", nil)
через простой веб-сервер
}
Это небольшое приложение берет данные и выводит их через простой веб-сервер,
используя шаблон из листинга 9.1. Здесь мы применяем пакет html/template,
а не text/template, поскольку его контекстно осведомленные функции автоматически выполняют часть рутинных операций.
9.1. Работа с HTML-шаблонами
265
Контекстная осведомленность не ограничивается знанием того, что шаблон
относится к HTML. Этот пакет «понимает», что происходит внутри шаблона.
Возьмем, к примеру, такой его фрагмент:
<a href="/user?id={{.Id}}">{{.Content}}</a>
Пакет html/template интерпретирует его осмысленно, добавляя соответствующую контексту функциональность, связанную с экранированием. В результате
предыдущий фрагмент автоматически преобразуется в следующий:
<a href="/user?id={{.Id | urlquery}}">{{.Content | html}}</a>
Перед формированием итогового вывода к переменным (в нашем случае .Id
и .Content) применяются функции экранирования. Эти функции преобразуют
символы, которые могли бы интерпретироваться как разметка и изменять смысл
или структуру страницы, в безопасные представления. Такие символы попрежнему отображаются, но не влияют на структуру HTML. При использовании
пакета text/template все действия, связанные с экранированием, пришлось бы
выполнять вручную.
Механизм контекстно осведомленного экранирования построен на модели безопасности, в которой шаблон считается доверенным, а переменные, содержащие
пользовательские данные, — недоверенными и подлежащими экранированию.
Например, если пользователь приложения введет строку <script>alert('busted
pwned')</script> и вы отобразите ее через систему HTML-шаблонов, то она
будет автоматически экранирована, в результате чего в шаблоне отобразится
текст: script>alert(' ;busted pwned')</script>. Такой вывод
является безопасным и предотвращает возникновение XSS-уязвимости.
Для отображения неэкранированной переменной можно использовать тип HTML
из пакета html/template. Но в этом случае ответственность за безопасность
данных ложится на разработчика. В главе 10 мы рассмотрим ситуации, когда
неэкранированный вывод является допустимым: например, при генерации токенов для защиты от межсайтовой подделки запросов (CSRF-атаки).
В шаблоны разрешается включать не только переменные. Язык шаблонов Go
также позволяет организовывать циклы и создавать вложенные конструкции
if/else, называемые конвейерами или пайплайнами (pipelines). В листинге 9.3
показано, как можно передать срез пользовательской структуры данных в шаблон
и отобразить его содержимое.
Листинг 9.3. Передача срезов/массивов в шаблон
package main
import (
"html/template"
"log"
"net/http"
)
266
Глава 9. HTML-шаблоны и шаблоны электронных писем
type comment struct { //
Username string
Text
string
}
type Page struct {
Title, Content string
Comments
[]comment //
}
Структура Page
с добавленным
срезом комментариев
var t = template.New("templates")
func routeComments(w http.ResponseWriter, r *http.Request) {
p := &Page{
Title:
"An Example",
Content: "Have fun stormin' da castle.",
Comments: []comment{
{Username: "Bill", Text: "Looks like a good example."},
{Username: "Jill", Text: "I really enjoyed this article."},
{Username: "Phil", Text: "I don't like to read."},
}, //
Несколько шаблонных
}
комментариев
}
if err := t.ExecuteTemplate(w, "list.html", p);
err != nil { //
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
}
Выполняет
основной шаблон
Парсит шаблоны
func init() {
из указанной директории
_, err := t.ParseGlob("templates/*.html") //
if err != nil {
log.Fatal("Error loading templates:" + err.Error())
}
}
func main() {
http.HandleFunc("/comments", routeComments)
if err := http.ListenAndServe(":8085", nil); err != nil {
panic(err)
}
}
В этом Go-коде мы просто добавляем обработчик routeComments, который содержит шаблонный код, включая несколько жестко заданных комментариев.
В отличие от других строковых значений их нельзя отформатировать прямо
в шаблоне с помощью переменной {{.Comments}}. Хотя мы могли бы сформировать HTML-строку вручную, гораздо удобнее перебрать эти срезы с помощью
встроенного ключевого слова range, как показано в листинге 9.4.
На рис. 9.1 показано, как комментарии, добавленные пользователем на странице,
отображаются через шаблон из листинга 9.4.
9.1. Работа с HTML-шаблонами
267
Листинг 9.4. Шаблон для перебора комментариев
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style type="text/css">
body {
max-width: 400px;
}
.comment {
padding: 1rem;
border: 1px solid black;
margin-bottom: 0.25rem;
}
</style>
</head>
<body>
<h1>{{.Title}}</h1>
Ключевое слово range
<p>{{.Content}}</p>
для перебора комментариев
{{ range .Comments }}
<div class="comment">
{{.Text}} #B
<div>by {{.Username}}</div>
Отдельные свойства
</div>
комментария
{{ end }}
</body>
</html>
Рис. 9.1. Комментарии, отображенные с помощью шаблона с ключевым словом range
268
Глава 9. HTML-шаблоны и шаблоны электронных писем
Имейте в виду, что внутри цикла с range вы фактически ограничены областью
видимости, предоставляемой срезом .Comments. Если вам нужно подняться на
уровень выше и получить доступ, например, к значению {{.Title}}, используйте
префикс $, чтобы обратиться к внешней (в данном случае глобальной) области
видимости: {{$.Title}}. Если вы перебираете срез простых значений, а не структур или объектов, выражение {{.}} будет возвращать само значение элемента.
Помимо циклов и конвейеров, мы рассмотрим еще четыре способа расширения
встроенного шаблонизатора, которые позволяют реализовать распространенные
паттерны работы с шаблонами.
9.1.2. Добавление функциональности в шаблоны
Помимо простого вывода значений, в шаблонах можно напрямую вызывать
функции и методы. Как вы уже видели, шаблонизатор для HTML автоматически
добавляет функции экранирования в нужных местах. Именно через функции
реализуется более сложная логика внутри шаблонов. Пакеты для работы с шаблонами предоставляют менее 20 функций, часть которых поддерживает такую
интеллектуальную обработку.
Рассмотрим одну из таких функций, Sprintf , которая реализуется внутри
шаблона через printf. Ниже приведен синтаксис ее использования в шаблоне:
{{"output" | printf "%q"}}
Этот код передает строку output в printf с помощью строки форматирования
%q, результатом чего является отображение строки output, заключенной в кавычки. Такой стиль записи через конвейер (|) может показаться непривычным,
поскольку он не соответствует обычному способу вызова функций в языке Go.
Тем не менее с подобными цепочками вызовов имеет смысл поэкспериментировать, чтобы понять, как они работают, особенно в контексте передачи значений
в качестве параметров.
Хотя встроенные возможности шаблонов довольно широки, разработчикам
нередко приходится дополнять их. Например, нам часто бывает необходимо
отображать дату и время в удобочитаемом формате. Разумеется, каждое значение даты можно форматировать вручную непосредственно в исходном коде,
но гораздо удобнее использовать для этого систему шаблонов. Такой подход
к повторному применению кода помогает поддерживать единообразие вывода
в шаблонах и идеально подходит для задач форматирования данных для отображения, поскольку избавляет от необходимости хранить несколько вариантов
значения для одной переменной.
Проблема. Встроенные функции шаблонов не обеспечивают всей необходимой функциональности для разработки полноценного веб-приложения.
Решение. Точно так же, как язык Go предоставляет шаблонам доступ к стандартным функциям (например, fmt.Sprintf доступна в шаблонах как printf),
9.1. Работа с HTML-шаблонами
269
вы можете включить собственные функции в систему шаблонов, расширяя
ее по своему усмотрению.
Обсуждение. В шаблонах информацию можно отображать разными способами. Хотя генерацию данных следует оставить на уровне логики приложения,
их форматирование и отображение имеет смысл реализовать непосредственно в шаблоне. Хороший пример — отображение даты и времени. Внутри приложения эти значения должны храниться в виде значения типа time.Time,
но при выводе они могут быть представлены пользователю в самых разных
форматах.
В языке Go действиями являются данные и команды, заключенные в двойные
фигурные скобки. Эти команды могут применяться к данным и объединяться
в конвейеры с помощью |. Та же идея лежит в основе использования оператора конвейера в командной строке UNIX. Язык Go предоставляет API,
позволяющий добавлять собственные команды к набору, доступному в шаблонах. В листинге 9.5 показано, как реализовать вывод отформатированной
даты в шаблоне.
Листинг 9.5. Добавление функции в шаблон
package main
import (
"html/template"
"net/http"
"time"
)
HTML-шаблон
в виде строки
var tpl = `<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>Date Example</title>
</head>
<body>
<p>{{.Date | dateFormat "Jan 2, 2006"}}</p>
</body>
</html>`
var funcMap = template.FuncMap{
"dateFormat": dateFormat,
}
Передает значение Date
через команду dateFormat
Сопоставляет функции Go
с функциями шаблона
func dateFormat(layout string, d time.Time) string {
return d.Format(layout)
}
Функция для преобразования
времени в форматированную строку
func serveTemplate(res http.ResponseWriter, req *http.Request) {
Создает новый экземпляр template.Template
t := template.New("date")
t.Funcs(funcMap)
Передает дополнительные функции
t.Parse(tpl)
(из ассоциативного массива) в шаблонизатор
Парсит строку шаблона
и передает ее в шаблонизатор
270
Глава 9. HTML-шаблоны и шаблоны электронных писем
data := struct{ Date time.Time }{
Date: time.Now(),
}
}
t.Execute(res, data)
Создает набор данных
для отображения в шаблоне
Отправляет шаблон с данными
в качестве ответа
func main() {
http.HandleFunc("/", serveTemplate)
http.ListenAndServe(":8080", nil)
}
Предоставляет шаблон
и данные через веб-сервер
Вместо того чтобы ссылаться на внешний файл, мы сохраняем этот HTMLшаблон в виде строки в переменной. Внутри шаблона значение переменной Date
передается через функцию шаблона dateFormat с заданной строкой форматирования, прежде чем попасть в итоговый вывод. Важно понимать, что механизм
конвейерной передачи направляет результат работы одного компонента конвейера следующему компоненту в качестве последнего аргумента.
Поскольку dateFormat не входит в число встроенных функций шаблонов, мы
должны самостоятельно сделать ее доступной для шаблона. Подключение
пользовательских функций к шаблонам выполняется в два этапа. Сначала
создается ассоциативный массив, в котором имена, используемые в шаблоне,
сопоставляются с соответствующими функциями языка Go. В данном примере
функция dateFormat сопоставлена с именем dateFormat. Однако имя функции
Go не обязательно должно совпадать с именем в шаблоне: вы можете задать
любое другое, но для удобства восприятия имеет смысл использовать одинаковые имена.
При создании нового экземпляра template.Template необходимо передать
ассоциативный массив функций (в данном случае funcMap ) в метод Funcs ,
чтобы сделать эти функции доступными для работы внутри шаблонов. Последним шагом перед применением шаблона является его парсинг в структуру
template.Template.
Далее экземпляр шаблона используется обычным способом. Структура данных
в этом примере определяется анонимной структурой (struct), в которой передаваемые данные представлены в виде пар «ключ — значение». Эта структура
передается в метод Execute вместе с объектом io.Writer, в который записывается результат отрисовки (рендеринга) шаблона. В момент вызова функции
dateFormat в шаблоне ей передается строка формата Jan 2, 2006 и экземпляр
time.Time, который преобразуется в строку соответствующего формата.
ПРИМЕЧАНИЕ Дата и время в строках форматирования должны быть заданы в определенном виде. Подробнее об этом — в документации к пакету по адресу http://
golang.org/pkg/time/#Time.Format.
9.1. Работа с HTML-шаблонами
271
Если вы планируете применять один и тот же набор функций ко множеству
шаблонов, то для их создания и подключения к ним ваших пользовательских
функций имеет смысл использовать функцию Go:
func parseTemplateString(name, tpl string) *template.Template {
t:= template.New(name)
t.Funcs(funcMap)
t = template.Must(t.Parse(tpl))
return t
}
Эту функцию можно многократно применять для создания нового объекта шаблона из строки шаблона, подключая к нему ваши пользовательские функции.
Например, в листинге 9.5 ее можно было бы задействовать внутри serveTemplate
вместо того, чтобы каждый раз явно парсить шаблон и добавлять в него функции. Такой подход хорошо работает и с шаблонами, загружаемыми из файлов.
9.1.3. Ограничение парсинга шаблонов
Преобразование шаблонов из текстовой формы в экземпляры типов — это довольно трудоемкий процесс для Go-приложения. При парсинге шаблона строка
символов превращается в объектную модель с набором узлов разных типов,
которые Go умеет интерпретировать. Парсинг выполняется парсером из пакета
text/template/parser, который скрывается за такими функциями, как Parse
и ParseFiles. Если вы не работаете напрямую с парсером (что не рекомендуется), легко упустить из виду всю ту работу, которую выполняют используемые
вами функции.
Однако существуют методы, позволяющие избежать лишних вычислений и ускорить работу приложения. Поскольку серверные Go-приложения обрабатывают
множество запросов от различных клиентов, повторный парсинг шаблона для
каждого запроса приводит к избыточной нагрузке. Сокращение количества
дублирующихся операций позволяет заметно повысить производительность.
Проблема. Мы хотим избежать повторного парсинга одного и того же шаблона
во время работы приложения.
Решение. Парсировать шаблон один раз, сохранить его в переменной и использовать готовый объект при формировании вывода.
Обсуждение. Вместо того чтобы парсить шаблон внутри HTTP-обработчика
(то есть при каждом его вызове), мы можем выполнять парсинг за пределами
самой функции. Это позволяет многократно применять шаблон к различным
наборам данных без его повторного парсинга. В листинге 9.6 приведена измененная версия листинга 9.2, в которой реализуется кэширование парсированного шаблона.
272
Глава 9. HTML-шаблоны и шаблоны электронных писем
Листинг 9.6. Кэширование парсированного шаблона
package main
import (
"html/template"
"net/http"
)
Парсит шаблон
при инициализации
пакета
var t = template.Must(template.ParseFiles("templates/simple.html"))
type Page struct {
Title, Content string
}
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Title:
"An Example",
Content: "Have fun stormin' da castle.",
}
t.Execute(w, p)
Выполняет шаблон внутри
}
HTTP-обработчика
func main() {
http.HandleFunc("/", displayPage)
http.ListenAndServe(":8080", nil)
}
В отличие от кода в листинге 9.2, где шаблон парсируется внутри функции-
обработчика, в этом примере шаблон парсится один раз при инициализации
пакета и при вызове HTTP-обработчика используется как обычно.
Как показали тесты производительности, о которых мы говорили в главе 5, повторно использовать уже парсированный шаблон гораздо быстрее, чем заново
парсить его каждый раз. Это простой, но вполне эффективный способ ускорить
работу приложения.
Если вам необходимо парсировать не отдельный файл, а целый каталог с шаблонами, воспользуйтесь методом ParseGlob, например, так: template.Parse
Glob("templates/*.html").
9.1.4. Когда выполнение шаблона завершается с ошибкой
Любое программное обеспечение может дать сбой, и шаблоны не исключение.
Если во время выполнения шаблона возникает ошибка, она возвращается
вызывающему коду. Однако в некоторых случаях сбой случается уже после
частичного вывода, то есть парсинг шаблона прекращается после отправки его
части пользователю.
Во время выполнения шаблона результат сразу записывается в поток вывода.
Если функция, вызываемая в шаблоне, завершится с ошибкой, выполнение
9.1. Работа с HTML-шаблонами
273
шаблона прервется, но уже сгенерированная его часть будет отображена в пользовательском интерфейсе.
Проблема. При сбое во время выполнения шаблона необходимо предотвратить вывод частично сгенерированной страницы и отобразить страницу
с сообщением об ошибке.
Решение. Записывать результат выполнения шаблона в буфер. Если ошибок
не возникает, отправить содержимое буфера пользователю, в противном
случае — обработать возникшую ошибку.
Обсуждение. Шаблоны должны быть максимально простыми. Они призваны
отображать данные и при необходимости форматировать их. Все ошибки,
связанные с данными, необходимо устранять до передачи этих данных
в шаблон. Функции внутри шаблонов нужно использовать только для отображения и реализации несложной логики. Такой подход соответствует
принципу разделения ответственности и снижает вероятность сбоев при
выполнении шаблонов.
Потоковая передача ответов имеет свои преимущества. Когда шаблон выполняется и записывается напрямую в ResponseWriter, пользователи начинают
получать данные быстрее. В то же время буферизация ответа добавляет
задержку перед отправкой контента. Сегодня пользователи ожидают от
веб-приложений такой же отзывчивости, как и от нативных десктопных программ, и потоковый вывод позволяет этого достичь. Поэтому по возможности
следует записывать ответы сразу в поток вывода.
Однако бывают ситуации, когда буферизация оказывается более предпочтительной. Если выполнение шаблона может завершиться с ошибкой, лучше
сначала записать результат в буфер. В этом случае возникшую ошибку можно
обработать до того, как пользователь увидит частично сформированный ответ. Данный подход продемонстрирован в листинге 9.7.
Листинг 9.7. Буферизация результата выполнения шаблона
package main
import (
"bytes"
"fmt"
"html/template"
"io"
"net/http"
)
var t *template.Template
func init() {
t = template.Must(template.ParseFiles("./templates/simple.html"))
}
274
Глава 9. HTML-шаблоны и шаблоны электронных писем
type Page struct {
Title, Content string
}
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Title:
"An Example",
Content: "Have fun stormin' da castle.",
}
Создает буфер для хранения
var b bytes.Buffer
результата выполнения шаблона
err := t.Execute(&b, p)
Обрабатывает ошибки, возникшие
if err != nil {
при выполнении шаблона
fmt.Fprint(w, "A error occured.")
return
}
}
b.WriteTo(w)
Копирует содержимое
буфера в поток вывода
func main() {
http.HandleFunc("/", displayPage)
http.ListenAndServe(":8080", nil)
}
В данном случае при выполнении шаблона вывод записывается не в http.Res
ponseWriter, а во временный буфер. Если в процессе возникнут ошибки, они
будут обработаны до того, как содержимое буфера попадет в поток вывода.
Такой подход полезен, например, при проведении регрессионного или фаззингтестирования, когда требуется получить определенный результат при заданных
условиях.
В большинстве случаев имеет смысл записывать вывод непосредственно
в ResponseWriter, однако в процессе разработки и отладки буфер помогает избежать путаницы. Если тестировать поведение прямо в браузере, то при ошибке
может отобразиться пустая страница без каких-либо пояснений. Чтобы спровоцировать такую ситуацию, достаточно нарушить структуру шаблона. Например,
если в файле list.html не закрыть конструкцию range, на выходе мы получим
только часть страницы:
<body>
<h1>{{.Title}}</h1>
<p>{{.VariableDoesNotExist}}</p>
{{ range .Comments }}
<div class="comment">
{{.Text}}
<div>by {{.Username}}</div>
</div>
{{ end }}
</body>
9.1. Работа с HTML-шаблонами
275
9.1.5. Комбинирование шаблонов
Базовым инструментом для генерации HTML-вывода является пакет html/
template. Он обеспечивает безопасную генерацию HTML-кода, однако задокументированные способы его использования являются слишком упрощенными.
При создании приложений разработчикам часто требуется объединять шаблоны,
задействовать особые схемы для их повторного применения и управления ими,
кэшировать сгенерированный вывод и многое другое. Далее мы рассмотрим
три подхода к работе с шаблонами, основанных на стандартной библиотеке:
вложенные шаблоны, расширение базового шаблона с помощью наследования
и сопоставление объекта данных с конкретным шаблоном (например, объекта
пользователя с шаблоном пользователя).
Вложенные шаблоны
Как и в случае с повторным использованием кода, у нас часто возникает необходимость в повторном использовании фрагментов шаблонов. Например, если
приложение содержит множество веб-страниц, то, скорее всего, значительная
часть HTML-разметки повторяется от страницы к странице, а отличия касаются
только отдельных элементов.
Проблема. Мы хотим избежать дублирования общих разделов HTMLразметки в каждом шаблоне и снизить издержки, связанные с сопровождением, за счет повторного использования фрагментов шаблонов.
Решение. Задействовать вложенные шаблоны для повторного использования
общих фрагментов HTML-кода, как показано на рис. 9.2. Такие вложения позволяют переиспользовать части разметки, сокращая объем дублирующегося кода.
Рис. 9.2. Вложенные шаблоны обеспечивают совместное использование
общих фрагментов кода
Обсуждение. Система шаблонов Go разработана с расчетом на использование
нескольких шаблонов и их взаимодействие. Родительский шаблон может
импортировать другие шаблоны. При выполнении родительского шаблона
276
Глава 9. HTML-шаблоны и шаблоны электронных писем
для генерации вывода вложенные шаблоны тоже включаются в результат.
В листинге 9.8 показан принцип работы такого механизма с некоторыми
важными нюансами.
Листинг 9.8. Включение шаблона head в шаблон index
<!DOCTYPE HTML>
<html>
{{template "head.html" .}}
<body>
<h1>{{.Title}}</h1>
<p>{{.Content}}</p>
</body>
</html>
Включает другой шаблон в текущий
и передает ему весь набор данных
Этот пример вложенного шаблона начинается с файла index.html. Он напоминает простой шаблон из листинга 9.1 за исключением того, что вместо раздела
<head> здесь используется директива для подключения другого шаблона.
Директива {{template "head.html" .}} состоит из трех частей. Ключевое слово template указывает шаблонизатору на необходимость подключить другой
шаблон, а head.html — это имя подключаемого шаблона. Последний элемент —
точка (.), обозначающая набор данных, передаваемый в подключаемый шаблон.
В нашем случае в head.html передается весь набор данных, доступный родительскому шаблону. Если бы в наборе данных содержалась вложенная структура, ее тоже можно было бы передать. Например, использование конструкции
{{template "head.html" .Foo}} означало бы, что в head.html будут доступны
свойства объекта .Foo (листинг 9.9).
Листинг 9.9. Шаблон head.html в шаблоне index
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
</head>
Значение Title совпадает с тем,
которое используется в index.html
Когда шаблон head.html вызывается из index.html, в него передается весь набор
данных. При обращении к Title используется то же значение, что и в index.html,
поскольку head.html имеет доступ ко всему набору данных. В листинге 9.10
объединены оба шаблона.
В листинге 9.10 оба шаблона парсятся совместно, образуя единый объект шаблона. Благодаря этому head.html становится доступен из index.html во время
выполнения. Для запуска шаблона используется метод ExecuteTemplate, что
позволяет явно указать имя шаблона, подлежащего выполнению. Если бы применялся метод Execute, как в предыдущих листингах, был бы выполнен первый
шаблон из переданных в ParseFiles. Таким образом, ExecuteTemplate позволяет
выбрать конкретный шаблон при наличии нескольких.
9.1. Работа с HTML-шаблонами
277
Листинг 9.10. Использование вложенных шаблонов
package main
import (
"html/template"
"net/http"
)
var t *template.Template
func init() {
t = template.Must(template.ParseFiles(
"index.html",
"head.html",
Загружает оба шаблона
))
в объект шаблона
}
type Page struct {
Title, Content string
}
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Title:
"An Example",
Content: "Have fun stormin' da castle.",
}
t.ExecuteTemplate(w, "index.html", p)
Вызывает шаблон index.html,
}
передавая ему данные страницы
func main() {
http.HandleFunc("/", displayPage)
http.ListenAndServe(":8080", nil)
}
Предоставляет страницу с помощью
встроенного веб-сервера
Наследование шаблонов
Многие системы шаблонов реализуют модель, в которой базовый шаблон дополняется другими шаблонами, подставляющими содержимое в его недостающие разделы. Это отличается от предыдущего подхода, при котором несколько верхнеуровневых шаблонов совместно использовали одни и те же вложенные
шаблоны. В данном случае, наоборот, общим является шаблон верхнего уровня.
Проблема. Мы хотим задать базовый шаблон, содержащий несколько разделов, которые можно расширять с помощью других шаблонов.
Решение. Вместо того чтобы рассматривать файл как единый шаблон, думайте
о разделах файла как о шаблонах. Базовый файл содержит общую разметку
и ссылается на другие, еще не определенные шаблоны (рис. 9.3). Шаблоны,
расширяющие базовый файл, предоставляют недостающие вложенные
шаблоны или переопределяют существующие. После их объединения получается полноценный шаблон с общей основой.
278
Глава 9. HTML-шаблоны и шаблоны электронных писем
Рис. 9.3. Общий базовый шаблон
Обсуждение. Система шаблонов в Go поддерживает некоторые паттерны
наследования. Хотя она не предоставляет весь спектр возможностей наследования, доступных в других шаблонизаторах, она позволяет использовать
ряд приемов. В листинге 9.11 показан базовый шаблон, от которого могут
наследовать другие.
Листинг 9.11. Базовый шаблон для наследования
{{define "base"}}
<!DOCTYPE HTML>
Начинает определение нового
<html>
базового шаблона с помощью define
<head>
Вызывает шаблон title,
<meta charset="utf-8">
определенный в другом месте
<title>{{template "title" .}}</title>
{{ block "styles" . }}
<style>
Определяет
h1 {
и немедленно
color: #400080;
вызывает
}
шаблон styles
</style>
{{ end }}
</head>
<body>
Определяет и вызывает шаблон scripts
<h1>{{template "title" .}}</h1>
(пока пустой). Расширяющий шаблон
{{template "content" .}}
может переопределить его содержимое
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}
Завершение базового шаблона
Итак, файл содержит несколько шаблонов, каждый из которых начинается
с директивы define или block и завершается директивой end. Директива block
определяет шаблон и немедленно выполняет его. Шаблон base, к которому
можно обращаться по имени, вызывает другие шаблоны, но сам по себе может их
не определять. Шаблоны, расширяющие данный (например, как в листинге 9.12),
9.1. Работа с HTML-шаблонами
279
должны заполнять недостающие подшаблоны. В других случаях в шаблон
можно включить разделы с содержимым по умолчанию, которое допускается
переопределять расширяющим шаблоном. Некоторые разделы могут быть необязательными — для них обычно создают пустые шаблоны, которые будут
использоваться по умолчанию.
ПРИМЕЧАНИЕ В версии Go 1.6 появилась директива block и возможность переопределять разделы, уже содержащие контент. До этого шаблоны с содержимым
переопределять было нельзя.
Листинг 9.12. Наследование обязательных разделов
{{define "title"}}User: {{.Username}}{{end}}
Определяет шаблон title
{{define "content"}}
<ul>
<li>Userame: {{.Username}}</li>
Определяет
шаблон content
<li>Name: {{.Name}}</li>
</ul>
{{end}}
Шаблоны, расширяющие базовый, должны определить все вложенные шаблоны,
для которых не задано содержимое по умолчанию. В нашем случае необходимо
определить разделы title и content, так как они являются обязательными.
Обратите внимание, что необязательные разделы, определенные в листинге 9.11
как пустые или содержащие стандартное содержимое, не требуют повторного
определения. В листинге 9.13 показано, как можно заполнить необязательный
шаблон наряду с обязательными разделами.
Листинг 9.13. Наследование с необязательным разделом
{{define "title"}}
{{.Title}}
{{end}}
{{define "content"}}
<p>
{{.Content}}
</p>
{{end}}
{{define "styles"}}
<style>
h1 {
color: #800080;
}
</style>
{{end}}
Определяет шаблон для заполнения
необязательного раздела
родительского шаблона
Здесь определяется шаблон styles, который переопределяет стандартную реа
лизацию из листинга 9.11. В листинге 9.14 продемонстрировано объединение
всех шаблонов.
280
Глава 9. HTML-шаблоны и шаблоны электронных писем
Листинг 9.14. Использование механизма наследования шаблонов
package main
import (
"html/template"
"net/http"
)
var t map[string]*template.Template
Вызывает
шаблон страницы
Создает ассоциативный
массив шаблонов
func init() {
t = make(map[string]*template.Template)
temp := template.Must(template.ParseFiles(
"base.html", "user.html",
))
t["user.html"] = temp
}
temp = template.Must(template.ParseFiles(
"base.html", "page.html",
))
t["page.html"] = temp
type Page struct {
Title, Content string
}
type User struct {
Username, Name string
}
Загружает шаблоны
вместе с базовым в этот
ассоциативный массив
Объекты данных,
передаваемые
в шаблоны
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Создает набор данных
Title:
"An Example",
для страницы
Content: "Have fun stormin' da castle.",
}
t["page.html"].ExecuteTemplate(w, "base", p)
Вызывает шаблон
}
страницы
func displayUser(w http.ResponseWriter, r *http.Request) {
u := &User{
Username: "swordsmith",
Name:
"Inigo Montoya",
}
t["user.html"].ExecuteTemplate(w, "base", u)
}
func main() {
http.HandleFunc("/user", displayUser)
http.HandleFunc("/", displayPage)
http.ListenAndServe(":8080", nil)
}
Предоставляет страницы
через встроенный веб-сервер
9.1. Работа с HTML-шаблонами
281
В этом примере сначала создается ассоциативный массив для хранения шаблонов. Каждый шаблон сохраняется отдельно от остальных. Массив наполняется
экземплярами шаблонов, имена которых служат ключами. При загрузке шаблонов user.html и page.html вместе с каждым из них загружается файл base.html,
что делает возможным его наследование.
Подготовка к отображению страницы происходит примерно так же, как при
обычном использовании шаблонов. Сначала создается и заполняется набор
данных. Когда приходит время формировать ответ, из ассоциативного массива
выбирается нужный шаблон и вызывается базовый. Именно базовый шаблон
является корневым для всей страницы и инициализирует выполнение вложенных шаблонов, определенных в рамках наследования.
Поддержание чистоты вывода
В предыдущих примерах блоки {{ define }} мы размещали прямо внутри HTMLсодержимого. Хотя в этом нет ошибки, Go также позволяет удалять лишние пробелы
и отступы в шаблоне — слева, справа или с обеих сторон — с помощью дефиса (-),
добавленного перед и/или после переменной.
Например, если вы хотите, чтобы директива {{ define }} располагалась на одной
строке, а тег <!DOCTYPE> — на следующей, но при этом чтобы на странице он отображался первым, то это можно реализовать так:
{{ define "head" -}}
<!DOCTYPE HTML>
<html>
Аналогичным образом можно удалить пробел в конце:
</style>
</head>
{{- end }}
Такой прием помогает сохранить удобочитаемость кода, не влияя на результат вывода. В случае с HTML практическое значение может быть невелико, но при генерации
контента, чувствительного к пробелам, такая возможность оказывается весьма полезной. Как минимум это облегчает визуальное восприятие шаблона.
Сопоставление типов данных с шаблонами
Предыдущие два метода работы с шаблонами предполагали формирование всего
вывода целиком. Для этого требовалось передавать полный набор данных страницы, а шаблон должен был обрабатывать все возможные варианты ее отображения.
Альтернативный подход заключается в том, чтобы прорисовывать части страницы (например, экземпляр объекта пользователя) по отдельности, а затем
передавать полученный HTML-код в шаблон более высокого уровня. Такому
282
Глава 9. HTML-шаблоны и шаблоны электронных писем
шаблону не нужно знать ни тип передаваемых данных ему, ни то, как именно их
следует отображать. Суть этой идеи продемонстрирована на рис. 9.4.
Проблема. Нам требуется отобразить объект в виде HTML-кода и передать
полученный результат в шаблон более высокого уровня для его включения
в итоговую страницу.
Решение. Использовать шаблон для преобразования объекта в HTML. Полученный HTML сохранить в переменной и передать в родительский шаблон,
предварительно обернув его в template.HTML, чтобы пометить как безопасный
HTML-код, не требующий экранирования.
Обсуждение. Для выполнения отрисовки в несколько этапов существуют
веские причины. Во-первых, если получение набора данных или его отображение в HTML занимает значительное время, стоит избегать повторного
выполнения этих операций при каждом запросе страницы.
Представьте, что у вас есть страница с информацией о пользователе и его
активности, которую могут просматривать другие пользователи. Чтобы отобразить эти сведения, потребуется выполнить запросы к нескольким источникам. Если данные кэшируются, мы можем избежать загрузки информации
при каждом просмотре страницы.
Однако даже при кэшировании этого набора данных нам придется заново
его прорисовывать при каждой загрузке страницы, а также хранить сложную структуры данных. Кроме того, при визуализации необходимо будет
убедиться в том, что пакет шаблонов обрабатывает вывод в нужном формате
и корректно экранирует все данные. Если же мы заранее поместим в кэш
готовый HTML-фрагмент, доступный для повторного использования, то
благодаря такому более эффективному кэшированию нам удастся избежать
лишней работы при генерации каждой страницы.
Рис. 9.4. Передача отрисованных HTML-объектов в шаблон
9.1. Работа с HTML-шаблонами
283
Во-вторых, если приложение предусматривает сложную логику и множество
страниц, то шаблоны могут содержать большой объем дублирующейся разметки. Если же каждый шаблон отвечает только за одну часть (например, за
основное содержимое, блок боковой панели или обертку страницы), то ими
гораздо проще управлять.
В листинге 9.15 показано, как можно отрисовать объект с помощью шаблона,
сохранить результат в виде HTML, а затем вставить его в другой шаблон.
Листинг 9.15. Шаблон объекта Quote
<blockquote>
“{{.Quote}}”
— {{.Person}}
</blockquote>
Свойства объекта Quote,
записываемые в поток вывода
Шаблон quote.html связан с объектом Quote. Он используется для отображения объекта Quote в виде HTML и ссылается на поля этого объекта. Обратите
внимание, что данный шаблон не содержит других элементов, необходимых для
формирования полной страницы. Эти элементы находятся в шаблоне index.html,
показанном в листинге 9.16.
Листинг 9.16. Универсальная обертка страницы
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
</head>
<body>
<h1>{{.Title}}</h1>
Свойства, используемые
<p>{{.Content}}</p>
для отображения типовой
</body>
страницы
</html>
Файл index.html представляет собой шаблон обертки страницы. Он содержит
переменные, которые уместны в контексте всей страницы. При этом выводимые значения не привязаны ни к пользователю, ни к чему-то конкретному.
Листинг 9.17 объединяет все эти шаблоны.
Листинг 9.17. Объединение шаблонов
package main
import (
"bytes"
"html/template"
"net/http"
)
284
Глава 9. HTML-шаблоны и шаблоны электронных писем
var t *template.Template
var qc template.HTML
Переменные для хранения данных,
общих для всех запросов
func init() {
t = template.Must(template.ParseFiles(
"index.html",
"quote.html",
Загружает два файла шаблонов
))
для последующего использования
}
type Page struct {
Title
string
Content template.HTML
}
type Quote struct {
Quote, Person string
}
Структуры данных для шаблонов,
учитывающие их уникальные
особенности
func main() {
q := &Quote{
Заполняет набор
Quote: `You keep using that word. I do not think
данных для передачи
it means what you think it means.`,
в шаблон
Person: "Inigo Montoya",
}
Записывает шаблон и структуру
данных в буфер
var b bytes.Buffer
t.ExecuteTemplate(&b, "quote.html", q)
Сохраняет цитату в виде HTML
qc = template.HTML(b.String())
в глобальной переменной
}
http.HandleFunc("/", displayPage)
http.ListenAndServe(":8080", nil)
Запускает обработчик с использованием
встроенного веб-сервера
func displayPage(w http.ResponseWriter, r *http.Request) {
p := &Page{
Title:
"A User",
Создает набор данных
Content: qc,
для страницы с HTML-цитатой
}
Отправляет цитату и страницу
t.ExecuteTemplate(w, "index.html", p)
в выходной поток веб-сервера
}
Этот код начинается с парсинга двух шаблонов, quote.html и index.html, и их
сохранения в переменной. Здесь используются две структуры данных: первая
описывает содержимое всей веб-страницы, а вторая представляет собой объект
Quote, который можно преобразовать в HTML.
Чтобы сгенерировать часть содержимого отдельно от всей страницы, в функции main создается экземпляр объекта Quote , который затем передается
в ExecuteTemplate вместе с шаблоном quote.html для формирования HTMLпредставления цитаты. Вместо непосредственного вывода шаблона он записывается в буфер (Buffer). Затем содержимое буфера преобразуется в строку
и передается в template.HTML. Пакет html/template по умолчанию экранирует
большую часть получаемых данных. Исключение составляет тип template.HTML,
9.2. Использование шаблонов для электронных писем
285
который считается безопасным HTML. Поскольку содержимое было сгенерировано с использованием шаблона, выполняющего экранирование, результат
работы шаблона quote.html можно считать безопасным и сохранить для дальнейшего использования.
В типе Page свойство Content имеет тип template.HTML. При создании набора
данных для генерации страницы в это свойство помещается HTML, полученный
из объекта Quote. При вызове шаблона index.html с этим набором данных система шаблонов распознает тип template.HTML и пропускает этап экранирования.
HTML-фрагмент с цитатой используется как есть, что обеспечивает чистый
и удобный способ хранения и передачи HTML-содержимого.
ВНИМАНИЕ HTML, полученный от пользователя, нельзя считать безопасным. Всегда
экранируйте введенные пользователем данные перед их выводом на страницу.
9.2. Использование шаблонов для электронных писем
Электронная почта — один из основных инструментов современной коммуникации. Ее часто используют для отправки уведомлений, подтверждения регистрации и многого другого.
Стандартная библиотека Go не предусматривает отдельного пакета шаблонов,
предназначенных специально для электронной почты. Вместо этого можно
использовать пакеты text/template и html/template, которые содержат все необходимое для отправки писем в текстовом и HTML-формате.
Электронная почта — это еще одна область, в которой шаблоны могут оказаться
весьма полезными. Иногда письмо создается в виде обычного текста, а иногда —
в виде HTML. Эти два формата поддерживаются стандартными пакетами шаблонов, так что мы можем воспользоваться любым из них для генерации писем.
Проблема. Мы хотим использовать шаблоны при создании и отправке электронных писем так же, как мы это делали в примерах с веб-страницами.
Решение. С помощью пакетов шаблонов сформировать текст письма и записать его в буфер. Затем передать содержимое буфера в код функции, отвечающий за отправку письма, например, из пакета smtp.
Обсуждение. Шаблоны можно применять для самых разных задач, в том
числе для создания хорошо отформатированных электронных писем. В листинге 9.18 приведен пример кода, в котором письмо создается на основе
шаблона и отправляется с помощью пакета net/smtp.
Листинг 9.18. Отправка письма, созданного на основе шаблона
package main
import (
"bytes"
"net/smtp"
286
)
Глава 9. HTML-шаблоны и шаблоны электронных писем
"strconv"
"text/template"
Использует пакет text/template для отправки
писем в обычном текстовом формате
type EmailMessage struct {
From, Subject, Body string
To
[]string
}
Структура данных,
представляющая письмо
type EmailCredentials struct {
Username, Password, Server string
Port
int
}
const emailTemplate = `From: {{.From}}
To: {{.To}}
Subject: {{.Subject}}
{{.Body}}
`
Шаблон письма,
заданный в виде строки
var t *template.Template
func init() {
t = template.New("email")
t.Parse(emailTemplate)
}
func main() {
message := &EmailMessage{
From:
"me@example.com",
To:
[]string{"you@example.com"},
Subject: "A test",
Body:
"Just saying hi",
}
var body bytes.Buffer
t.Execute(&body, message)
Записывает отрисованный
текст письма в буфер
authCreds := &EmailCredentials{
Username: "myUsername",
Password: "myPass",
Server:
"smtp.example.com",
Port:
25,
}
auth := smtp.PlainAuth(
"",
authCreds.Username,
authCreds.Password,
authCreds.Server,
)
Заполняет набор
данных для шаблона
и почтового клиента
Настраивает
SMTP-клиент
для отправки
почты
smtp.SendMail(
authCreds.Server+":"+strconv.Itoa(authCreds.Port),
auth,
Отправляет
письмо
}
)
message.From,
message.To,
body.Bytes(),
Резюме
287
Байты из буфера передаются
при отправке письма
В данном примере демонстрируется отправка простого письма, сгенерированного
на основе шаблона. Обратите внимание: здесь используется пакет text/template
вместо html/template, который применялся в предыдущих листингах этой главы.
Пакет html/template построен поверх text/template и обеспечивает HTMLспецифичную функциональность, такую как контекстно зависимое экранирование.
Использование пакта text/template означает, что помещаемые в шаблон свойства (например, .Body) не экранируются автоматически. Если вам необходимо
экранировать такие данные, вы можете воспользоваться соответствующими
функциями из пакета text/template.
При выполнении шаблона с переданным набором данных в него также передается
буфер, в который записывается результат. Этот буфер затем используется как
источник содержимого для отправки письма через почтовый клиент.
Такой подход можно расширить, чтобы отправлять письма в различных форматах и разными способами. Например, вы можете воспользоваться пакетом html/
template для отправки писем в формате HTML.
Использование шаблонов при работе с HTML и электронной почтой позволяет
эффективно управлять растущей сложностью приложения, придерживаясь
принципа разделения ответственности.
Резюме
Систему шаблонов Go можно расширять собственными функциями и использовать синтаксис конвейера для последовательной обработки выходных
переменных.
Кэширование и буферизация шаблонов помогают снизить издержки, связанные с повторным парсингом (разбором), и предотвратить отображение
частично сгенерированного вывода при возникновении ошибок.
Возможность многократно использовать отдельные разделы (например, заголовок или нижний колонтитул), которые могут быть общими для разных
шаблонов, позволяет избежать дублирования кода и упрощает структуру.
Пакеты text/template и html/template, входящие в стандартную библиотеку,
можно использовать для генерации электронных писем и других документов
в формате простого текста или разметки.
10
Отправка и получение данных
В этой главе
3 Раздача статических файлов, таких как CSS, изображения и JavaScript.
3 Встраивание статических файлов непосредственно
в скомпилированный исполняемый файл.
3 Обработка HTML-форм, в том числе предусматривающих загрузку
файлов.
3 Работа с необработанными составными сообщениями.
Самые ранние веб-сервисы представляли собой просто средство для раздачи
файлов. В 1991 году, когда появился протокол HTTP, интерактивности, к которой мы привыкли сегодня, еще не существовало — она появилась позднее
благодаря веб-формам. Созданные десятилетия назад, эти механизмы до сих
пор являются основой Сети и продолжают обеспечивать работу современных
веб-приложений.
Глава начинается с описания способов раздачи статических файлов в Goприложении. Поскольку приложение, написанное на Go, само по себе является
веб-сервером, а не запускается на отдельном сервере вроде Nginx, вам необходимо самостоятельно определить, как именно будут обслуживаться такие файлы,
как таблицы стилей (Cascading Style Sheets, CSS), JavaScript-код, изображения
и прочие ресурсы. Вы познакомитесь с несколькими подходами к хранению
и обслуживанию файлов, каждый из которых подходит для разных сценариев.
10.1. Раздача статического содержимого
289
Затем мы перейдем к теме обработки форм. Ее базовые принципы мы уже обсуждали в главе 9, однако для случаев посложнее, таких как обработка больших
файлов, передаваемых в форме составного сообщения, требуются более продвинутые техники.
Раздача файлов и обработка форм в сочетании с шаблонами, о которых мы говорили в предыдущей главе, формируют фундамент для построения полноценных веб-приложений на языке Go. Эти приемы можно использовать совместно
с любыми современными технологиями разработки фронтенда для создания
многофункциональных веб-приложений.
10.1. Раздача статического содержимого
Как уже было показано, веб-приложение на языке Go не обязательно запускать
за отдельным веб-сервером. Если вы не используете сервер по умолчанию, то
можете задать значения тайм-аутов, чтобы сократить задержки и избежать
утечек соединений. Хотя у обратного прокси-сервера есть определенные преимущества (например, встроенные возможности кэширования и балансировки
нагрузки), пакет http из библиотеки Go вполне способен самостоятельно раздавать весь контент, будь то страницы приложения или статические файлы,
такие как CSS, изображения или JavaScript. На рис. 10.1 показано различие
между Go-приложением и программой, работающей на отдельном веб-сервере,
выполняющем функции обратного прокси.
Рис. 10.1. Сравнение Go-приложения, обменивающегося данными
по протоколу HTTP, с типичной моделью веб-сервера
290
Глава 10. Отправка и получение данных
Для работы со статическими файлами в стандартной библиотеке Go предусмотрен пакет http, содержащий набор функций, позволяющих организовать их
раздачу. Файловый сервер встраивается непосредственно в приложение, как
показано в листинге 10.1.
Листинг 10.1. Раздача файлов с помощью пакета http
package main
import (
"net/http"
)
Использует каталог
func main() {
файловой системы
dir := http.Dir("./files")
if err := http.ListenAndServe(
":8080",
http.FileServer(dir),
); err != nil {
Раздает содержимое
panic(err)
каталога файловой системы
}
}
Обработчик FileServer из пакета http представляет собой условно-интеллектуальный файловый сервер. Он раздает файлы из указанного каталога локальной
файловой системы с учетом заданных прав доступа. Этот обработчик умеет
анализировать HTTP-заголовок If-Modified-Since и возвращать ответ с кодом
304 Not Modified, если запрашиваемая версия файла совпадает с уже имеющейся
у пользователя. Обратите внимание на каталог, передаваемый в FileServer:
если при сборке или развертывании приложения вы переместите статические
файлы в другое место, раздача может не сработать. В таких ситуациях полезно
использовать переменные окружения с резервным значением. Впрочем, подобные
ошибки довольно легко обнаружить.
ПРИМЕЧАНИЕ Если каталога, переданного в http.Dir, не существует, ошибка не возникнет, но при запрашивании статических файлов будет возвращаться код 404.
Поэтому хорошей практикой считается предварительная проверка наличия и доступности каталога.
Если вы хотите написать собственный обработчик для раздачи файлов, используйте функцию ServeFile из пакета http, как показано в листинге 10.2.
Так выглядит альтернативный способ раздачи одного конкретного файла. Простейший веб-сервер задействует один обработчик для всех путей. В данном
случае обработчик readme предоставляет содержимое файла ./files/readme.txt
с помощью функции ServeFile. Эта функция принимает файлы или каталоги
в качестве третьего аргумента и, подобно обработчику FileServer, умеет анализировать заголовок If-Modified-Since, при необходимости возвращая код
304 Not Modified.
10.1. Раздача статического содержимого
291
Листинг 10.2. Раздача файла с помощью пользовательского обработчика
package main
import (
"net/http"
)
Регистрирует обработчик
func main() {
для всех путей
http.HandleFunc("/", readme)
if err := http.ListenAndServe(":8082", nil); err != nil {
panic(err)
}
}
func readme(res http.ResponseWriter, req *http.Request) {
http.ServeFile(res, req, "./files/readme.txt")
}
Предоставляет содержимое
файла readme
Разумеется, вы можете расширить этот подход, добавив в маршрут параметр
пути, например использовать конечную точку вида /readmes/{documentID}
и извлекать значение через r.PathValue("documentID"), чтобы раздавать разные
файлы по запросу. Однако здесь необходимо тщательно проверять входные
данные, чтобы исключить несанкционированный доступ к файлам. Поэтому для
реализации такой схемы предпочтительнее использовать функцию FileServer
вместо ServeFile.
Эти функции и лежащие в их основе механизмы позволяют раздавать файлы
несколькими способами и с разной степенью контроля. Если вы применяете
собственные маршруты и конечные точки и хотите комбинировать их с файловыми серверами, то можете передать FileServer в качестве обработчика любому
маршруту, например, так:
http.HandleFunc("/comments", routeComments)
fileServer := http.FileServer(http.Dir("./static/"))
http.Handle("/static/", http.StripPrefix("/static", fileServer))
При этом, если пользователь напрямую обратится к конечной точке /static, он
увидит содержимое каталога.
10.1.1. Раздача подкаталогов
Во многих фреймворках и приложениях принято раздавать файлы из локальной файловой системы, в которой находится само приложение. Это позволяет
другим приложениям подключать внешние файловые системы так, как будто
они являются локальными, либо действительно использовать локальные
ресурсы.
Проблема. Мы хотим раздавать содержимое каталога и его подкаталогов как
часть веб-приложения.
292
Глава 10. Отправка и получение данных
Решение. Использовать встроенный файловый сервер или обработчики для
раздачи файлов из локальной файловой системы. Чтобы получить больший
контроль над отображением страниц с сообщениями об ошибках, в том числе связанных с отсутствием запрашиваемого файла, придется реализовать
собственный файловый сервер.
Обсуждение. Чтобы понять принцип раздачи файлов, рассмотрим следующий пример. Допустим, у нас есть каталог example_app/static/, для раздачи
которого мы хотим использовать путь example.com/static/. На первый взгляд
задача кажется тривиальной, и в некоторых случаях так и есть. Однако,
если вы хотите более точно управлять поведением сервера, вам потребуется
обойти часть встроенных механизмов раздачи. Начнем с простого примера
(листинг 10.3).
Листинг 10.3. Раздача подкаталога
func main() {
dir := http.Dir("./files/")
handler := http.StripPrefix(
"/static/",
http.FileServer(dir)
)
http.Handle("/static/", handler)
http.HandleFunc("/", homePage)
}
Указывает каталог и его подкаталоги,
выбранные для раздачи
Путь /static/ используется для раздачи каталога,
и его необходимо удалить перед поиском файла
Предоставляет главную страницу, которая может включать
файлы из каталога со статическим содержимым
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
В этом примере встроенный веб-сервер раздает содержимое каталога ./files/
по пути /static/ через файловый сервер из пакета http. Каталог в файловой
системе может быть любым, он не обязан располагаться рядом с исходным кодом приложения. Функция StripPrefix используется для удаления префикса
из URL-адреса перед передачей искомого пути файловому серверу. Это необходимое условие для нахождения нужных файлов при раздаче содержимого
подкаталогов в приложении.
10.1.2. Использование файлового сервера
с пользовательскими страницами ошибок
Встроенный файловый сервер из стандартной библиотеки Go автоматически
генерирует страницы ошибок, включая 404 Not Found. Однако такие страницы
отображаются в стандартном виде, определяемом браузером, и не поддаются
настройке. Но что, если вы хотите создать собственные страницы ошибок,
например содержащие ссылки, позволяющие пользователю найти нужный
контент?
10.1. Раздача статического содержимого
293
Проблема. Как задать собственные страницы ошибок, в том числе на случай,
когда запрашиваемый файл отсутствует, если за раздачу файлов отвечает
ваше веб-приложение?
Решение. Использовать файловый сервер, позволяющий задать обработчики
для страниц ошибок. Пакет go-fileserver (https://github.com/Masterminds/
go-fileserver) расширяет функционал встроенного сервера и предоставляет
возможность создавать пользовательские обработчики ошибок.
Обсуждение. И FileServer, и ServeFile опираются на функцию ServeContent
из пакета http. Эта функция вызывает приватные функции, в том числе Error
и NotFound, которые и формируют стандартные ответы. Обработка ошибок
реализована на самом низком уровне, поэтому, чтобы изменить такое поведение, необходимо создать собственный файловый сервер. Он может представлять собой как абсолютно новую реализацию, так и модифицированную
версию стандартного файлового сервера.
Пакет go-fileserver как раз то, что нам нужно. Этот пакет спроектирован для
совместного использования с пакетом http из стандартной библиотеки и реализует только ту функциональность, которая отсутствует в ней. В листинге 10.4
показано, как работает такой файловый сервер.
Листинг 10.4. Пользовательские страницы ошибок в файловом сервере
package main
import (
"fmt"
fs "github.com/Masterminds/go-fileserver"
"net/http"
)
Импортирует пакет для реализации
файлового сервера
Задает функцию,
вызываемую при отсутствии
запрашиваемого файла
func main() {
fs.NotFoundHandler = func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "The requested page could not be found.")
}
Указывает каталог с файлами,
подлежащими раздаче
Использует встроенный
dir := http.Dir("./files")
веб-сервер и пользовательский
http.ListenAndServe(":8080", fs.FileServer(dir))
файловый сервер
}
Этот пример во многом напоминает файловый сервер из стандартной библиотеки,
но имеет несколько отличий. Первое — здесь задается функция-обработчик на
случай, если отсутствует запрашиваемый файл. Именно она будет формировать ответ в таких ситуациях. Хотя в данном примере мы обрабатываем только
ошибку 404, мы можем назначить пользовательскую функцию для обработки
всех типов ошибок. Настройка раздачи каталога с файлами выполняется так
же, как и в случае со стандартным файловым сервером, то есть путем создания
294
Глава 10. Отправка и получение данных
экземпляра http.Dir, указывающего на нужный каталог. Второе отличие касается самой раздачи: вместо http.FileServer здесь используется функция
fs.FileServer, гарантирующая вызов подходящих обработчиков ошибок.
ПРИМЕЧАНИЕ Пакет go-fileserver был создан специально для этой книги. Из-за
большого объема кода, а также по причине практической полезности данный файловый сервер был выделен в отдельный пакет, чтобы им можно было пользоваться
в приложениях.
10.1.3. Встраивание файлов в исполняемый файл
Выбор места хранения неисполняемых файлов нередко вызывает затруднения,
особенно при развертывании приложения. Такие файлы можно разместить рядом
с исполняемым двоичным файлом, но в этом случае обычно требуется дополнительная настройка: либо для того, чтобы само приложение могло их найти, либо
для того, чтобы веб-сервер ограничил к ним прямой доступ. Иногда возникает
необходимость включить все ресурсы непосредственно в двоичный файл: в этом
случае искать их в файловой системе не нужно — все они уже встроены в приложение. Таким образом, распространению подлежит только один файл, а не
набор из исполняемого файла и сопровождающих его ресурсов.
Проблема. Мы хотим встроить статические ресурсы (например, изображения
или таблицы стилей) прямо в исполняемый файл Go-приложения.
Решение. Начиная с версии Go 1.16, мы можем встраивать файлы прямо
в приложение и присваивать их содержимое строкам или срезам байтов.
Обсуждение. Идея проста и сводится к тому, чтобы преобразовать файл
в строку или набор байтов, сохранить его (вместе с дополнительной информацией) в переменной, а затем использовать эту переменную для предоставления содержимого через функцию ServeContent из пакета http. Таким
образом, нам больше не нужно поставлять файлы отдельно.
Пакет embed позволяет сделать приложение полностью самодостаточным. Это
особенно удобно при контейнеризации, когда важно минимизировать количество
дополнительных ресурсов, которые нужно учитывать и включать в итоговый
образ (листинг 10.5).
Как видите, мы оборачиваем файловую систему, полученную с помощью директивы go:embed, в http.FS, чтобы преобразовать ее в обработчик, совместимый
с методом http.ListenAndServe.
При запуске или сборке приложения все файлы встраиваются непосредственно
в исполняемый файл. Обратите внимание: директива go:embed не может располагаться внутри функции. Это связано с тем, что такие директивы похожи на
типажи (traits) в языке Rust или директивы #pragma в C/C++ — они предназначены исключительно для компилятора и не являются частью самого языка.
10.1. Раздача статического содержимого
295
Листинг 10.5. В
страивание файлов в исполняемый файл
с помощью go:embed
package main
import (
"embed"
"net/http"
)
//go:embed files
var f embed.FS
Импортирует пакет embed
из стандартной библиотеки
Директива
компилятора go:embed
Присваивает значение
переменной
func main() {
if err := http.ListenAndServe(
":8088",
http.FileServer(http.FS(f)),
); err != nil {
Раздает файлы из файловой
panic(err)
системы go:embed
}
}
При этом встраивать всю файловую систему вовсе не обязательно. Если вам
нужен только один файл (например, HTML-шаблон), достаточно указать лишь
его. Пакет embed позволяет извлекать данные из отдельного файла в виде строки
или среза байтов. Таким же способом можно встроить конфигурационные файлы, которые вы хотите включить в приложение, но не собираетесь развертывать
отдельно (листинг 10.6).
Листинг 10.6. Встраивание шаблонов в качестве файлов
package main
import (
_ "embed"
"fmt"
"log"
)
Подключает пакет embed
с подчеркиванием
для использования его
побочных эффектов
Встраивает
одиночный файл
//go:embed files/example.html
var myString string
Компилятор присваивает содержимое
файла переменной типа string
func main() {
log.Println(fmt.Sprintf("embedded value: %s", myString))
}
Теперь значение
этой переменной
доступно остальной
части приложения
При использовании директивы go:embed вы не ограничены исключительно текстовыми файлами. Однако в случае других типов данных логичнее применить
альтернативные подходы, например сеть доставки контента (CDN, Content
Delivery Network), поскольку их встраивание может значительно увеличить
размер итогового исполняемого файла.
296
Глава 10. Отправка и получение данных
Хотя в контексте системы шаблонов, рассмотренной в главе 9, директива go:embed
не имеет широкого применения, нередко она упрощает развертывание приложения за счет его упаковки в один исполняемый файл.
10.1.4. Раздача содержимого из альтернативного
местоположения
Иногда возникает необходимость хранить и раздавать файлы отдельно от приложения. Типичным примером является раздача JavaScript, CSS и других статических
ресурсов веб-приложения через CDN. Такой подход снижает нагрузку на сервер
и позволяет приложению сосредоточиться на обработке динамических данных.
Проблема. Вместо того чтобы раздавать файлы через тот же сервер, что и приложение, мы хотим использовать альтернативный источник, совместимый
с различными средами, включая рабочую, тестовую и среду разработки.
Решение. Раздавать файлы из альтернативных источников, например, через
CDN в рабочей среде. При этом для каждой среды важно обеспечить развертывание файлов вместе с приложением и передавать путь к ним через
конфигурацию. Этот путь следует указывать в шаблонах, чтобы браузер знал,
откуда загружать нужные файлы.
Обсуждение. В каждой среде должна быть доступна полная или репрезентативная копия ресурсов приложения. Хотя эти файлы могут предоставляться
отдельно от страниц приложения, не следует раздавать их из одного и того же
источника во всех средах. Четкое разделение, показанное на рис. 10.2, позволяет создавать полноценные тестовые среды и свободно экспериментировать
в среде разработки, а также обеспечивает безопасную разработку и тестирование, при которых ошибки, допущенные в одной из соответствующих сред,
не затрагивают пользователей приложения, развернутого в рабочей среде.
Рис. 10.2. В каждой среде браузер получает отдельное приложение и набор ресурсов
10.1. Раздача статического содержимого
297
Если местоположение файлов отличается в зависимости от среды, его нужно
передавать в приложение в виде параметра конфигурации. Это можно сделать
через распределенное хранилище конфигурации (например, Etcd), конфигурационные файлы, аргументы командной строки при запуске приложения или
иным способом передачи параметров. В листинге 10.7 путь к файлам передается
через аргумент командной строки, что позволяет динамически задавать его при
развертывании в разных средах.
Листинг 10.7. Передача URL-адреса в шаблон
var t *template.Template
Получает расположение статических
var l = flag.String(
файлов из аргументов командной строки
"location",
"http://localhost:8080",
"A location."
)
var tpl = `<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>A Demo</title>
<link rel="stylesheet" href="{{.Location}}/styles.css">
</head>
<body>
<p>A demo.</p>
</body>
</html>`
func servePage(res http.ResponseWriter, req *http.Request) {
data := struct{ Location *string }{
Location: l,
}
t.Execute(res, data)
}
Путь к CSS-файлу
задается
относительно
указанного
расположения
HTTP-обработчик
передает путь
в шаблон
Этот код принимает путь к файлам в виде аргумента командной строки и использует его в шаблоне. Если значение не передано, применяется значение по
умолчанию — пустая строка.
Данный пример иллюстрирует общую идею, суть которой сводится к передаче
конкретных параметров конфигурации во время выполнения приложения.
В реальных приложениях для этого могут потребоваться более сложные решения.
Передача пути в виде параметра конфигурации. Различные способы решения этой задачи, в том числе с помощью конфигурационных файлов,
хранилища Etcd и аргументов командной строки, были подробно описаны
в главе 2.
298
Глава 10. Отправка и получение данных
Если значение не передано, его отсутствие следует зафиксировать в журнале и, возможно, вызвать панику. Отсутствие конфигурационного значения
не должно позволять ни рабочей, ни тестовой среде раздавать контент с недействительными URL-адресами. Это как раз тот случай, когда значение по
умолчанию может принести вред, так как даже при ошибочной конфигурации система будет создавать иллюзию исправной работы.
Путь может быть сохранен в глобальном объекте конфигурации и использоваться повторно при формировании ответов в приложении. Это значение
может храниться, например, в базе данных, распределенной памяти или
другой системе хранения данных.
Если сервер, обрабатывающий эти файлы, предназначен исключительно для их
раздачи, его следует оптимизировать для работы со статическим содержимым.
Например, при использовании веб-сервера Apache или Nginx можно отключить
все ненужные модули.
Функциональные возможности протоколов HTTP/2 и HTTP/3 делают целесообразной совместную раздачу файлов и страниц приложения. Когда браузер
запрашивает страницу, сервер, поддерживающий HTTP/2 или HTTP/3, может
отправить не только саму страницу, но и связанные с ней ресурсы — даже до
того, как браузер успеет их запросить. При этом все файлы могут передаваться
через то же соединение, через которое была запрошена страница. Чтобы реализовать эту схему, сервер должен обслуживать как страницы приложения,
так и файлы.
ПРИМЕЧАНИЕ Спецификация HTTP/2 описана в документе RFC 7540, опубликованном Инженерным советом Интернета (IETF) и доступном по адресу https://
datatracker.ietf.org/doc/html/rfc7540. Протокол HTTP/3 основан на протоколе QUIC
и описан в документе RFC 9114: https://datatracker.ietf.org/doc/html/rfc9114.
Раздача содержимого — лишь половина рабочего процесса современных интерактивных приложений. Чтобы завершить цикл, сервер также должен обрабатывать
взаимодействия с пользователями. В следующем разделе мы рассмотрим это на
примере обработки форм с пользовательскими данными.
10.2. Продвинутая обработка форм
В главе 8 мы обсудили примитивные способы обработки форм, такие как извлечение параметров POST-запроса и получение данных из них. Обработка HTMLформ и запросов POST или PUT — это обычная часть работы веб-приложений
и сайтов. Пакет http из стандартной библиотеки Go предоставляет практически
все необходимое для ее выполнения. Однако, несмотря на наличие соответствующей функциональности, способы ее использования не всегда бывают
10.2. Продвинутая обработка форм
299
очевидными. В этом разделе описаны типовые подходы к работе с данными,
будь то содержимое формы или составной запрос (multipart) с методом POST
или PUT.
10.2.1. Обработка запросов с данными формы
Когда на сервер поступает запрос с данными формы, они не преобразуются в пригодную для использования структуру по умолчанию. В листинге 8.3 главы 8 мы
создали форму и обработали ее единственное именованное поле.
Напомним, что первым шагом при работе с данными формы является их парсинг (листинг 10.8). Внутри обработчика запроса доступны два метода объекта
Request, позволяющие парсировать данные формы и преобразовать их в структуру данных Go. Метод ParseForm обрабатывает поля, содержащие текст. Для обработки двоичных данных или файлов, переданных через форму, необходимо
использовать метод ParseMultipartForm. Как следует из названия, этот метод
предназначен для обработки составных форм, то есть форм, содержащих данные
различных MIME-типов. Метод ParseMultipartForm автоматически вызывается
методом FormValue, если данные еще не были парсированы.
Листинг 10.8. Парсинг простой формы
func exampleHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
Парсирует простую форму, содержащую только текстовые поля
if err != nil {
Обрабатывает возможные ошибки,
fmt.Println(err)
возникшие при парсинге формы
}
}
name := r.FormValue("name")
Извлекает первое значение
поля name из формы
В листинге 10.8 показана обработка простой формы. Этот пример актуален
только для форм с текстовыми полями. Если бы форма содержала поле для загрузки файла, оно оказалось бы недоступным и не было бы парсировано. Кроме
того, этот пример работает только с полями формы, допускающими лишь одно
значение. Между тем HTML-формы могут возвращать нескольких значений для
одного поля. Оба этих случая рассматриваются в следующих разделах. После
парсинга данные формы распределяются по двум структурам.
Свойство Form объекта Request будет содержать значения как из URL-запроса,
так и из тела запроса, переданного методом POST или PUT. Каждый ключ
в Form представляет собой массив значений. Метод FormValue возвращает
первое значение для заданного ключа, то есть элемент с индексом 0.
Чтобы получить только значения из тела запроса (POST или PUT), игнорируя
данные URL-запроса, можно воспользоваться свойством PostForm объекта
Request. Как и FormValue, метод PostFormValue, возвращает первое значение
из PostForm по заданному ключу.
300
Глава 10. Отправка и получение данных
10.2.2. Обращение к нескольким значениям одного поля формы
Поле формы может содержать несколько значений для одного имени. Классический пример — набор флажков (check box). С их помощью мы можем создать
список с выбором одного или нескольких вариантов опций, у которых в HTML
одно и то же имя.
Проблема. Методы FormValue и PostFormValue возвращают только первое
значение поля формы. Мы хотим получать все возможные значения.
Решение. Вместо использования методов FormValue или PostFormValue для
получения значения поля следует обратиться напрямую к свойствам Form
или PostForm объекта Request, а затем перебрать все значения в цикле.
Обсуждение. Если поле формы допускает несколько значений, для получения доступа к ним потребуется выполнить дополнительные действия. В листинге 10.9 показан процесс парсинга формы с извлечением всех значений
одного поля.
Листинг 10.9. Парсинг формы с несколькими значениями одного поля
func exampleHandler(w http.ResponseWriter, r *http.Request) {
maxMemory := 16 << 20
err := r.ParseMultipartForm(maxMemory)
Парсит форму
if err != nil {
Обрабатывает возможные
с составным
fmt.Println(err)
ошибки, возникшие
содержимым
}
при парсинге формы
}
for k, v := range r.PostForm["names"] {
fmt.Println(v)
}
Перебирает все значения
поля names, переданные
в теле POST-запроса
Максимальный
объем памяти
для хранения
частей файлов;
все, что превышает
этот объем,
сохраняется
на диск
Обработчик HTTP-запроса начинает с задания максимального объема памяти,
который можно использовать при парсинге формы с составным содержимым.
В нашем случае он составляет 16 Мбайт. При вызове метода ParseMultipartForm
необходимо явно указать верхний предел памяти. Часть файлов, превышающая
заданный объем, будет сохранена на диск. Значение по умолчанию, используемое методами FormValue и PostFormValue при вызове ParseMultipartForm,
составляет 32 Мбайт.
Вместо использования метода FormValue или PostFormValue для получения
только первого значения поля в этом примере перебираются все значения поля
names. Для этого используется свойство PostForm объекта запроса, что позволяет
получить только те данные, которые были переданы в теле POST- или PUT-запроса.
СОВЕТ При отображении и обработке форм настоятельно рекомендуется использовать защитные механизмы, например токен CSRF, предотвращающий межсайтовую
подделку запросов. Более подробную информацию можно найти по адресу https://
owasp.org/www-community/attacks/csrf.
10.2. Продвинутая обработка форм
301
10.2.3. Работа с файлами и отправка
составного содержимого
При переходе от базовой обработки форм к взаимодействию с файлами и составными формами, содержащими данные нескольких типов, подход к работе
существенно меняется. Простейшим примером служит загрузка файла через
веб-форму: загружаемый файл имеет собственный тип содержимого (например,
изображение), а также текстовые поля формы. Таким образом, в одном запросе
передается как минимум два типа содержимого, каждый из которых должен
обрабатываться по-своему.
В этом подразделе мы обсудим обработку составных запросов, которые чаще
всего ассоциируются с загрузкой файлов. Такие запросы могут предусматривать как простую и быструю передачу файлов, так и загрузку объемных данных,
требующих особого подхода.
На этот раз речь идет не об отдельных, самодостаточных значениях, а о последовательной обработке фрагментов одного крупного объекта. Работа
с файлами отличается от обработки текстовых полей формы, так как каждый
файл представляет собой блок двоичных данных, сопровождаемый метаданными.
Проблема. Как обработать и сохранить файл, загруженный через форму?
Как обрабатывать крупные фрагменты двоичных данных, отправленные на
веб-сервер Go?
Решение. При загрузке файла форму следует обрабатывать как форму с составным содержимым с помощью метода Process-MultipartForm объекта
Request. Этот метод парсит части файла. Затем можно получить доступ
к каждому отдельному файлу через метод FormFile, который возвращает
как метаданные файла, так и сам файл в виде объекта, аналогичного объекту
File из пакета os.
Обсуждение. Обработка данных файла почти так же проста, как и работа
с текстовыми полями формы. Основное отличие заключается в необходимости обработки двоичного содержимого файла и связанных с ним метаданных
(например, имени файла). В листинге 10.10 показана простая форма для
загрузки файла.
У этой формы есть несколько важных особенностей. В качестве метода отправки
формы указан POST, а в качестве способа кодирования ее данных — multipart.
Использование такого способа кодирования позволяет загружать и обрабатывать текстовую часть формы как обычный текст, а передаваемый файл — как
отдельный тип содержимого. Поле ввода имеет тип file, что говорит браузеру
отобразить диалоговое окно для выбора файла и загрузить его содержимое. Эта
форма предоставляется и обрабатывается функцией-обработчиком из пакета
http, как показано в листинге 10.11.
302
Глава 10. Отправка и получение данных
Листинг 10.10. Ф
орма с полем для загрузки файла, допускающим
одно значение
<!doctype html>
<html>
<head>
<title>File Upload</title>
Для загрузки файлов форма
</head>
должна иметь тип multipart
<body>
<form action="/" method="POST" enctype="multipart/form-data">
<label for="file">File:</label>
Поле для загрузки файла с именем
<input type="file" name="file" id="file"> file, допускающее одно значение
<br>
Для отправки формы
<button type="submit" name="submit">Submit</button>
необходима кнопка
</form>
</body>
</html>
Листинг 10.11. Обработка данных при загрузке одного файла
HTTP-обработчик для отображения
и обработки формы из файла file.html
func fileForm(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
При получении GET-запроса отображает
t, _ := template.ParseFiles("file.html")
HTML-страницу с формой
t.Execute(w, nil)
} else {
Извлекает обработчик файла, информацию
f, h, err := r.FormFile("file")
из заголовка и ошибку, возникшую
при обращении к полю формы по его имени
if err != nil {
Обрабатывает ошибки,
panic(err)
возникшие при извлечении
Создает локальный путь
}
поля формы
для сохранения файла,
defer f.Close()
Не забудьте закрыть файл, полученный
включая его имя. Здесь
из поля формы, перед выходом из функции
используется временный
filename := "/tmp/" + h.Filename
каталог, но в рабочем
out, err := os.Create(filename)
приложении для этого
if err != nil {
нужно постоянное
Не забудьте закрыть
panic(err)
хранилище
локальный
файл
перед
}
выходом
из
функции
Создает локальный
defer out.Close()
файл для сохранения
загруженного содержимого
io.Copy(out, f)
fmt.Fprint(w, "Upload complete")
Копирует загруженный файл
}
в локальное хранилище
}
Обработчик, предназначенный для использования с веб-сервером из пакета http,
выполняет две функции: отображает форму при поступлении GET-запроса и обрабатывает данные отправленной формы. Сначала он определяет метод запроса.
Если это GET, он возвращает форму из листинга 10.10. Если применяется другой
метод (например, POST или PUT), он обрабатывает полученную форму. На практике имеет смысл ограничиться методом POST, поскольку другие HTTP-методы
не всегда соответствуют выполняемой операции. Однако текущая реализация
служит универсальным решением.
10.2. Продвинутая обработка форм
303
Первым этапом обработки поля с файлом является его извлечение с помощью
метода FormFile объекта Request. Если форма еще не была парсирована, метод
FormFile вызовет ParseMultipartForm автоматически. Метод FormFile возвращает
объект multipart.File, объект *multipart.FileHeader и возможную ошибку.
Объект *multipart.FileHeader содержит имя файла в поле Filename, используемом в качестве части пути в локальной файловой системе, по которому будет
сохранен загруженный файл. Чтобы сохранить файл локально, создается новый
файл и содержимое загруженного файла копируется в него.
Такой подход хорошо работает, если в поле формы есть лишь один файл. Однако
HTML-формы допускают поля с несколькими значениями, и данный способ
позволяет извлечь только первый из переданных файлов. Способ загрузки нескольких файлов мы обсудим далее.
10.2.4. Загрузка нескольких файлов
Поля формы для загрузки файлов могут содержать атрибут multiple. Если этот
атрибут задан, пользователь может загрузить произвольное количество файлов.
В таком случае метод FormFile не сработает, так как он предполагает, что в поле
формы загружается только один файл, и возвращает лишь первый из них.
Проблема. Как обработать несколько файлов, загруженных через одно и то же
поле формы?
Решение. Вместо использования метода FormFile, предназначенного для загрузки одного файла, следует парсировать форму и получить срез файлов из
свойства MultipartForm объекта Request. Затем нужно перебрать элементы
этого среза в цикле и обработать каждый файл по отдельности.
Обсуждение. Чтобы поле ввода поддерживало загрузку нескольких файлов,
достаточно добавить к нему атрибут multiple. В этом заключается единственное отличие между формой из листинга 10.12 и формой для загрузки одного
файла из листинга 10.11.
Листинг 10.12. Форма с полем для загрузки нескольких файлов
<!doctype html>
<html>
<head>
Для загрузки файлов форма
<title>File Upload</title>
должна использовать
</head>
кодировку multipart
<body>
<form action="/" method="POST" enctype="multipart/form-data">
<label for="files">File:</label>
Поле
для загрузки
<input type="file" name="files" id="files" multiple>
нескольких
<br>
файлов
<button type="submit" name="submit">Submit</button>
с именем files
</form>
Для отправки формы
и атрибутом
</body>
требуется кнопка
multiple
</html>
304
Глава 10. Отправка и получение данных
Эта форма использует метод кодировки multipart и содержит поле <input>,
принимающее несколько файлов благодаря заданному атрибуту multiple. В листинге 10.13 показан процесс обработки такой формы.
Листинг 10.13. Обработка поля формы, принимающего несколько файлов
func fileForm(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
t, _ := template.ParseFiles("file_multiple.html")
t.Execute(w, nil)
} else {
err := r.ParseMultipartForm(16 << 20)
if err != nil {
Парсит форму
fmt.Fprint(w, err)
и обрабатывает
return
возникшие ошибки
}
data := r.MultipartForm
files := data.File["files"]
for _, fh := range files {
f, err := fh.Open()
if err != nil {
fmt.Fprint(w, err)
return
}
defer f.Close()
Обработчик
HTTP-запроса,
отображающий
и обрабатывающий
форму из файла
file_multiple.html
При получении GET-запроса
отображает HTML-страницу
с формой
Извлекает срез файлов, переданных в поле формы
с именем files, из структуры MultipartForm
Перебирает файлы, загруженные через поле files
Открывает файловый обработчик
для одного из загруженных файлов
Не забудьте закрыть файл
и обработать возможные ошибки
Создает локальный файл
для сохранения содержимого
загруженного файла
out, err := os.Create("/tmp/" + fh.Filename)
if err != nil {
Не забудьте закрыть локальный
fmt.Fprint(w, err)
файл и обработать ошибки,
return
возникшие при его создании
}
defer out.Close()
Копирует загруженный файл в указанное
место файловой системы
_, err = io.Copy(out, f)
}
}
}
if err != nil {
fmt.Fprintln(w, err)
return
}
Обрабатывает ошибки, возникшие
при копировании загруженного файла
в локальный файл
fmt.Fprint(w, "Upload complete")
Здесь представлен обработчик для веб-сервера из пакета http. При получении
GET-запроса он отображает HTML-форму. Если же поступает запрос другого
типа, он переходит к обработке данных формы.
Прежде чем обращаться к полям формы, необходимо ее парсировать. Вызов
метода ParseMultipartForm у объекта Request инициирует этот процесс. В рамках
описанных ранее приемов парсинг выполнялся неявно внутри таких методов,
как FormFile. Передаваемое в метод значение задает объем памяти (в нашем
10.2. Продвинутая обработка форм
305
случае 16 Мбайт), доступный для хранения данных формы; все, что превышает
этот объем, будет сохранено на диск в виде временных файлов.
После парсинга формы ее поля становятся доступны через свойство MultipartForm.
Загруженные файлы из поля формы с именем files можно получить через свойство File структуры MultipartForm в виде среза значений, каждое из которых
представляет собой указатель на объект *multipart.FileHeader.
Чтобы последовательно обработать все файлы, необходимо перебрать их в цикле.
Вызов метода Open у объекта *multipart.FileHeader возвращает File — обработчик для чтения содержимого файла. Чтобы сохранить файл на диск, нужно
создать новый локальный файл, в который будет записано содержимое. Имя загружаемого файла доступно через поле Filename объекта *multipart.FileHeader.
Определившись с местом хранения, используйте io.Copy, чтобы скопировать
данные из загруженного файла в локальный.
Такой подход требует более низкоуровневого взаимодействия с API пакета,
но вместе с тем предоставляет дополнительный контроль над рядом аспектов.
10.2.5. Проверка допустимости типа загружаемого файла
Загружаемый через форму файл может иметь любой тип: изображение, документ
или что-то еще. Как убедиться в получении именно того типа файла, который
мы ожидали? И что делать в случае загрузки файла некорректного типа?
Иногда в качестве защитной меры выступает проверка на стороне клиента. Поле
ввода типа файла можно снабдить атрибутом accept, в котором указываются
допустимые расширения или MIME-типы (также называемые типами содержимого). Однако атрибут accept поддерживается не всеми браузерами. Даже
в программах, в которых он реализован, его значение легко изменить, что делает
такую защиту ненадежной. Таким образом, проверку типа загружаемого файла
необходимо выполнять внутри приложения.
Проблема. Как определить тип загружаемого файла внутри приложения?
Решение. Чтобы получить MIME-тип файла, можно использовать один из
нескольких подходов, различающихся по степени достоверности результата:
при загрузке файла в заголовках запроса посмотреть на поле Content-Type,
в котором указан либо конкретный тип (например, image/png), либо общее
значение application/octet-stream;
можно попытаться определить MIME-тип загружаемого файла по его
расширению;
проанализировать содержимое самого файла и определить его тип по
содержимому.
Обсуждение. Значение Content-Type задается клиентским приложением,
а расширение файла — самим пользователем. В первых двух случаях точность
зависит от внешних факторов. Третий способ требует анализа содержимого
306
Глава 10. Отправка и получение данных
файла и знания характерных признаков, по которым можно сопоставить тип
данных. Это наиболее сложный и ресурсоемкий метод, но вместе с тем —
самый надежный. Чтобы понять, как применять каждый из этих подходов,
давайте рассмотрим их по отдельности.
При загрузке файла (как показано в листинге 10.13) нам становится доступен
объект типа *multipart.FileHeader. Это второй результат, возвращаемый методом FormFile объекта Request. Объект *multipart.FileHeader содержит поле
Header, в котором находятся все заголовки, переданные при загрузке файла,
включая Content-Type:
file, header, err := r.FormFile("file")
contentType := header.Header["Content-Type"][0]
В этом примере метод FormFile вызывается для поля формы с именем file. Заго
ловки могут содержать несколько значений, поэтому даже при наличии одного
значения нам следует явно обратиться к первому элементу. Тип содержимого
в этом случае будет либо конкретным MIME-типом, например image/png, либо
обобщенным значением application/octet-stream, если тип не был определен.
В качестве альтернативы заголовку, переданному вместе с файлом, для определения типа содержимого можно использовать расширение имени файла. В пакете
mime есть функция TypeByExtension, которая пытается определить MIME-тип
на основе расширения файла:
file, header, err := r.FormFile("file")
extension := filepath.Ext(header.Filename)
type := mime.TypeByExtension(extension)
Однако данный способ определения типа файла не очень точен, так как расширение файла легко изменить вручную. В стандартной библиотеке Go есть лишь
небольшой набор соответствий между расширениями и MIME-типами, хотя при
необходимости он может быть дополнен сведениями из операционной системы.
Еще один подход к определению типа файла предполагает парсинг его содержимого. Эта задача решается двумя способами. Один из них сводится к использованию функции DetectContentType из пакета http, способной определять
ограниченный набор типов файлов, включая HTML, текст, XML, PDF, PostScript,
распространенные форматы изображений, сжатые файлы (RAR, ZIP, GZIP),
аудиофайлы .WAV и видеофайлы WebM. Ниже приведен пример использования
этой функции:
file, header, err := r.FormFile("file")
buffer := make([]byte, 512)
_, err = file.Read(buffer)
filetype := http.DetectContentType(buffer)
Размер буфера ограничен 512 байтами, поскольку функция DetectContentType
анализирует только первые 512 байт файла. Если ей не удается определить
точный тип содержимого, она возвращает значение application/octet-stream.
10.2. Продвинутая обработка форм
307
Поскольку список типов, поддерживаемых функцией DetectContentType, ограничен, для распознавания других популярных форматов, например документов
Microsoft Word или видеофайлов MP4, требуется иной подход. Самый простой
способ — применить внешнюю библиотеку для распознавания MIME-типов,
такую как libmagic. На данный момент написания книги в Go было несколько
пакетов, предоставляющих привязки к библиотеке libmagic, упрощающих ее
использование в Go-приложениях.
ПРИМЕЧАНИЕ Спецификация механизма определения MIME-типов доступна по
адресу https://mimesniff.spec.whatwg.org.
10.2.6. Инкрементное сохранение загружаемого файла
Представьте, что вы создаете систему, предназначенную для загрузки большого
количества крупных файлов. Эти файлы хранятся не на вашем сервере API,
а в специальном бэкенд-сервисе. Метод ParseMultipartForm размещает файлы
в каталоге временных файлов на сервере API до завершения их загрузки. Чтобы
такая схема работала с большими файлами, серверу потребуется достаточно
объемный локальный кэш и надежная система управления, исключающая его
переполнение при параллельной загрузке нескольких файлов.
Стандартная библиотека Go предлагает как высокоуровневые вспомогательные
функции для обработки файлов, так и низкоуровневые средства, которые подходят для более редких или необычных сценариев. Обработчик вызывается сразу
при поступлении запроса, то есть до завершения загрузки файла. Во многих
случаях запросы выполняются быстро, и вспомогательные функции учитывают
потенциальные задержки. Однако если вы работаете с большими файлами, то
у вас появляется возможность обрабатывать их еще во время загрузки.
Вместо того чтобы вызывать метод ParseMultipartForm объекта Request внутри
функции-обработчика HTTP-запроса, можно напрямую обратиться к необработанному потоку запроса через объект *multipart.Reader, доступ к которому
предоставляет метод MultipartReader.
Проблема. Мы хотим сохранять файл по мере его загрузки в выбранное нами
место: в локальном каталоге на сервере, общей сетевой папке или в любом
другом хранилище.
Решение. Вместо использования метода ParseMultipartForm данные составного запроса можно читать прямо по мере их поступления. Для этого следует
воспользоваться методом MultipartReader объекта Request. Передаваемые
фрагменты файлов и другой информации можно обрабатывать сразу, не дожидаясь завершения загрузки.
Обсуждение. Использование API-сервера в качестве промежуточного звена
между клиентом и системой хранения — это весьма распространенный подход. Данные, не являющиеся файлами, обычно сохраняются в базе данных.
Однако при работе с крупными файлами или с большим количеством файлов,
308
Глава 10. Отправка и получение данных
загружаемых одновременно, возникает проблема, так как для временного
хранения таких объемов информации требуется внушительный размер кэша.
Простое решение заключается в том, чтобы сразу передавать файлы в конечное хранилище, которое изначально предназначено для работы с большими
объемами данных. Таким образом, кэшировать файлы на API-сервере имеет
смысл только в случае крайней необходимости.
Чтобы получить доступ к составному потоку напрямую (что и делает метод
ParseMultipartForm), необходимо извлечь объект Reader из объекта Request с помощью метода MultipartReader. После этого можно перебирать все фрагменты
в цикле, считывая их по мере поступления.
При обработке составного содержимого форм мы часто вынуждены работать как
с файлами, так и с текстовыми полями. В листинге 10.14 представлена простая
HTML-форма с текстовым полем, полем для загрузки файла и кнопкой отправки.
Листинг 10.14. HTML-форма с текстовым полем и полем для загрузки файла
<!doctype html>
<html>
<head>
<title>File Upload</title>
</head>
<body>
<form action="/" method="POST" enctype="multipart/form-data">
<label for="name">Name:</label>
Поле для ввода текста
<input type="text" name="name" id="name">
<br>
Поле для загрузки файла,
<label for="file">File:</label>
требующее использования
<input type="file" name="file" id="file">
кодировки multipart
<br>
<button type="submit" name="submit">Submit</button>
</form>
Кнопка для отправки формы
</body>
также представлена в виде поля
</html>
В листинге 10.15 содержится функция-обработчик, которая отображает форму,
обрабатывает ее данные и сохраняет содержимое загружаемого файла по мере
его поступления.
Листинг 10.15. Инкрементное сохранение загружаемых файлов
Обработчик HTTP-запроса для отображения
При получении
и обработки формы из файла file_plus.html
GET-запроса
отображает
func fileForm(w http.ResponseWriter, r *http.Request) {
HTML-страницу
if r.Method == "GET" {
с формой
t, _ := template.ParseFiles("file_plus.html")
t.Execute(w, nil)
Извлекает объект Reader,
} else {
предоставляющий
mr, err := r.MultipartReader()
доступ к загружаемым
if err != nil {
файлам, и обрабатывает
panic("Failed to read multipart message")
возникающие ошибки
}
10.2. Продвинутая обработка форм
309
Ассоциативный массив для хранения
значений текстовых полей формы
Счетчик на 10 Мбайт для ограничения
values := make(map[string][]string)
объема текстовых полей
maxValueBytes := int64(10 << 20)
Продолжает выполнять цикл вплоть до получения
последней части составного сообщения
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
name := part.FormName()
if name == "" {
continue
}
Пытается прочитать следующую часть;
при достижении конца запроса выходит
из цикла
Извлекает имя поля формы;
если имя отсутствует, продолжает
выполнять цикл
Извлекает имя файла,
если оно есть
filename := part.FileName()
var b bytes.Buffer
Буфер для хранения значения текстового поля
Если имя файла отсутствует, рассматривает поле как текстовое
if filename == "" {
n, err := io.CopyN(&b, part, maxValueBytes)
Копирует содержимое
if err != nil && err != io.EOF {
фрагмента в буфер
fmt.Fprint(w, "Error processing form")
При возникновении ошибки
return
при чтении фрагмента
}
обрабатывает ее
maxValueBytes -= n
if maxValueBytes == 0 {
msg := "multipart message too large"
fmt.Fprint(w, msg)
return
}
}
values[name] = append(values[name], b.String())
continue
Создает файл в файловой системе
для хранения загружаемого содержимого
dst, err := os.Create("/tmp/" + filename)
if err != nil {
return
Закрывает файл при выходе
}
из функции-обработчика
defer dst.Close()
}
}
}
Использование счетчика
байтов позволяет убедиться
в том, что размер текстовых
данных не превышает лимит
for {
buffer := make([]byte, 100000)
cBytes, err := part.Read(buffer)
if err == io.EOF {
break
}
dst.Write(buffer[0:cBytes])
}
fmt.Fprint(w, "Upload complete")
Сохраняет
содержимое
поля формы
в ассоциативный
массив
для последующего
использования
Записывает содержимое
фрагмента в файл
по мере его загрузки
310
Глава 10. Отправка и получение данных
Этот листинг начинается с определения функции-обработчика HTTP-запросов.
При поступлении GET-запроса она отображает HTML-форму, а при отправке
формы в виде POST-запроса — обрабатывает ее.
Поскольку в нашем случае метод ParseMultipartForm не используется, необходимо вручную настроить некоторые элементы перед обработкой формы. Для
получения доступа к поступающим данным нам потребуется объект Reader.
Метод MultipartReader объекта Request возвращает указатель на *mime.Reader,
с помощью которого можно итеративно обрабатывать фрагменты составного запроса. Этот объект считывает данные по мере необходимости. Нам также следует
подготовить место для хранения значений текстовых полей формы. В данном
случае для этого создается ассоциативный массив.
После подготовки начинается перебор фрагментов составного сообщения. В рамках каждой итерации цикла предпринимается попытка извлечения очередного
фрагмента. Если частей больше нет, возвращается ошибка io.EOF и цикл прерывается. (EOF означает end of file, то есть «конец файла»).
Теперь начинается обработка фрагментов составного сообщения. Сначала проверяется имя поля формы с помощью метода FormName; если имя отсутствует,
цикл переходит к следующему фрагменту. У файлов, помимо имени поля, есть
имя файла, которое можно извлечь с помощью метода FileName. Наличие этого
имени позволяет отличить поле для загрузки файла от текстового поля.
При отсутствии имени файла обработчик копирует содержимое поля в буфер
и уменьшает значение счетчика, изначально установленного на 10 Мбайт.
Когда значение счетчика достигает нуля, парсинг прерывается и возвращается
ошибка. Это ограничение позволяет предотвратить чрезмерное потребление
памяти. Значение 10 Мбайт достаточно велико для хранения содержимого текстовых полей и совпадает со значением по умолчанию, используемым в методе
ParseMultipartForm. Если ошибок не возникает, содержимое текстового поля
формы сохраняется в ранее созданном ассоциативном массиве values и цикл
переходит к обработке следующего фрагмента.
Если цикл достиг этой точки, значит, текущее поле формы предназначено для
загрузки файла, содержимого которого хранится в файле, созданном в операционной системе. Однако эти данные можно отправить и в другое хранилище,
например в облачное. В таком случае вместо создания файла в локальной
файловой системе следует открыть соединение с внешней системой хранения.
После открытия целевого хранилища запускается цикл чтения данных из текущего фрагмента составного сообщения. Байты будут записываться в указанное
хранилище по мере поступления до тех пор, пока не придет уведомление о завершении фрагмента (в виде ошибки io.EOF). При загрузке большого файла вы
сможете наблюдать за тем, как данные постепенно записываются в выходной
файл. После завершения цикла все файлы будут доступны на диске, а значения
текстовых полей — в ассоциативном массиве values.
Резюме
311
Резюме
Язык Go предоставляет простые и эффективные инструменты для обработки
данных форм и файлов, передаваемых через POST-, PUT- или PATCH-запросы,
что позволяет серверной части легко парсить и обрабатывать информацию,
поступающую от клиентов.
Работа с файлами из корневого или вложенного каталога в веб-серверах
и других Go-приложениях реализована предельно просто. Внутри вебсервера можно создавать как отдельные маршруты, так и полноценные
маршрутизаторы для непосредственной раздачи файлов клиентам.
Файлы можно использовать для отображения собственных страниц ошибок
вместо стандартных сообщений браузера.
Файлы можно встраивать непосредственно в исполняемый файл Goприложения, что упрощает его развертывание, распространение и контейнеризацию.
Продвинутые средства обработки форм предоставляют возможность работать с двоичными данными, составными сообщениями и полями формы,
имеющими одинаковые имена.
Потоковые методы Go позволяют осуществлять инкрементную обработку
данных, то есть сохранять их по мере поступления, а при возникновении
ошибки прерывать обработку, не дожидаясь завершения процесса загрузки.
11
Работа с внешними сервисами
В этой главе
3 Выполнение REST-запросов.
3 Обнаружение тайм-аутов и возобновление процесса загрузки.
3 Передача ошибок по протоколу HTTP с дополнительными
метаданными.
3 Парсинг JSON-данных, в том числе имеющих произвольную
структуру.
3 Реализация системы версионирования для поддержки нескольких
версий REST API.
3 Использование gRPC в качестве альтернативы архитектурам
вроде REST.
В главе 10 мы рассмотрели способы приема всевозможных данных, включая
двоичные, от пользователей через POST-запросы, вы также научились возвращать клиенту данные разных типов. Однако, когда мы сами выступаем в роли
клиента, то есть потребителя данных, нам необходимо понимать принципы
использования различных протоколов и соглашений.
11.1. Использование REST API в качестве полнофункционального клиента
313
Мы начнем с обсуждения основ REST API — одного из краеугольных камней
современной веб-разработки, а затем перейдем к рассмотрению нестандартных
ситуаций. Поговорим о том, как обрабатывать тайм-ауты, возобновлять передачу
файлов и передавать информацию об ошибках от конечной точки API к клиенту,
отправившему запрос.
Многие API используют формат JSON для обмена информацией, и в этой главе
вы научитесь обрабатывать JSON-ответы даже в тех случаях, когда структура
данных заранее не известна. Это особенно полезно при работе с плохо определенными, неопределенными или изменяющимися JSON-данными. Вы также
познакомитесь с несколькими подходами к версионированию REST API, необходимость в котором возникает в процессе их эволюции.
Наконец, мы обсудим альтернативу REST, которая набирает популярность среди
разработчиков публичных API, — протокол gRPC. Он позволяет точнее сформулировать ожидания от API и избежать некоторых проблем, характерных для
REST-интерфейсов с расплывчатыми определениями. В этой главе вы узнаете
о том, как перейти от базового взаимодействия с API к использованию более
надежных и продвинутых решений.
11.1. Использование REST API в качестве
полнофункционального клиента
Стандартная библиотека Go предоставляет HTTP-клиент, который хорошо
подходит для большинства распространенных задач. Однако за пределами этих
типичных сценариев вы можете столкнуться с трудностями, для преодоления
которых встроенных средств не предусмотрено. Прежде чем перейти к рассмотрению таких ситуаций, давайте вспомним принцип работы HTTP-клиента в Go.
11.1.1. Использование HTTP-клиента
HTTP-клиент входит в состав пакета net/http стандартной библиотеки. Он предоставляет вспомогательные функции для выполнения практически любых
HTTP-запросов, включая GET, HEAD и POST, и поддерживает гибкую настройку.
Наиболее распространенными функциями для получения данных являются
http.Get, http.Head, http.Post и http.PostForm. За исключением http.PostForm,
каждая из этих функций соответствует HTTP-методу, указанному в ее названии.
Например, http.PostForm применяется для обработки POST-запросов, в рамках
которых отправляемые данные передаются в виде формы. В качестве демонстрации принципа работы этих функций в листинге 11.1 показан простой пример
использования http.Get. Все остальные HTTP-методы представлены в пакете
в виде аналогичных констант.
314
Глава 11. Работа с внешними сервисами
Листинг 11.1. Простой GET-запрос
package main
import (
"fmt"
"io"
"net/http"
)
Выполняет
func main() {
GET-запрос
res, _ := http.Get("https://www.manning.com/")
b, err := io.ReadAll(res.Body)
Считывает содержимое
if err != nil {
тела ответа в буфер
panic(err)
}
Отправляет содержимое тела ответа
defer res.Body.Close()
в стандартный поток вывода
fmt.Printf("%s", b)
}
Все вспомогательные функции опираются на стандартный HTTP-клиент, который доступен в пакете и способен выполнять любые HTTP-запросы. В листинге 11.2 показано, как использовать этот клиент для выполнения DELETE-запроса.
Листинг 11.2. В
ыполнение DELETE-запроса с помощью стандартного
HTTP-клиента
package main
import (
"fmt"
"net/http"
)
Создает объект запроса
с методом DELETE
func main() {
req, err := http.NewRequest("DELETE", "http://example.com/foo/bar", nil)
if err != nil {
panic(err)
Выполняет запрос с помощью
}
стандартного клиента
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
Отображает код состояния, полученный
}
в результате выполнения запроса
fmt.Printf("%s", res.Status)
}
В данном случае вы, скорее всего, получите ответ с кодом 404 (Not Found) или
405 (Method Not Allowed), однако в типичной REST-архитектуре аутентифицированный клиент может отправить запрос на удаление данных, обратившись
к соответствующей конечной точке. Процесс выполнения HTTP-запроса состоит из двух этапов. Сначала создается запрос в виде объекта http.Request,
содержащего метаданные вроде протокола и заголовков. Затем этот запрос
11.1. Использование REST API в качестве полнофункционального клиента
315
выполняется клиентом. В нашем примере используется клиент по умолчанию.
Такое разделение этапов обеспечивает четкое разграничение ответственности,
а также позволяет настраивать каждый из компонентов по отдельности. Некоторые вспомогательные функции, в частности http.Get, объединяют оба этапа, то
есть создают объект запроса и немедленно выполняют его с помощью клиента.
Стандартный клиент (DefaultClient) поддерживает HTTP-перенаправления,
cookie и тайм-ауты, а также использует транспортный слой по умолчанию, который при необходимости допускается переопределять.
Клиенты можно настраивать под конкретные задачи, задавая нужные параметры
запросов. В листинге 11.3 демонстрируется создание простого клиента с таймаутом 1 секунда.
Листинг 11.3. Простой пользовательский HTTP-клиент
package main
import (
"fmt"
"io"
"net/http"
"time"
)
func main() {
cc := &http.Client{Timeout: time.Second}
res, err := cc.Get("http://www.manning.com")
if err != nil {
Обрабатывает ошибки,
panic(err)
например тайм-аут клиента
}
b, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
defer res.Body.Close()
fmt.Printf("%s", b)
}
Создает пользовательский HTTP-клиент
с тайм-аутом 1 секунда
Выполняет GET-запрос с помощью
пользовательского клиента
Пользовательские клиенты позволяют настраивать множество элементов, включая транспортный слой, обработку cookie и HTTP-перенаправления.
11.1.2. Обработка ошибок
Интернет изначально проектировался с учетом принципа отказоустойчивости.
Всегда существует риск, что какие-то элементы выйдут из строя или будут
работать не так, как ожидается. В таких случаях имеет смысл либо повторить
попытку, либо зафиксировать ошибку, чтобы позволить системе оповещения на
нее среагировать. При работе с HTTP-соединениями важно уметь обнаруживать
сбои, сообщать о них и по возможности автоматически устранять.
316
Глава 11. Работа с внешними сервисами
Обнаружение тайм-аутов
Тайм-ауты соединений — это распространенная проблема, которую нужно уметь
обнаруживать. Если соединение прерывается по тайм-ауту, особенно посреди
выполнения операции, имеет смысл повторить попытку. При повторном запросе
сервер, к которому вы обращались, уже может быть доступен, либо вас перенаправят на другой работоспособный экземпляр.
Для выявления тайм-аутов в пакете net выводятся ошибки, реализующие метод
Timeout, который возвращает значение true в случае превышения времени ожидания. Однако бывают ситуации, когда тайм-аут произошел, но метод Timeout
возвращает false либо ошибка поступает из другого пакета (например, url), где
метод Timeout отсутствует.
Обычно тайм-ауты корректно определяются пакетом net, когда они заданы явно
(см. листинг 11.3). В таких случаях выполнение запроса (включая чтение тела
ответа) должно полностью завершиться в пределах заданного временного периода. Однако тайм-аут может произойти и без явной установки соответствующего
лимита. Подобные ситуации возникают при проблемах с сетевым соединением,
которые оказываются вне зоны контроля механизма отслеживания.
Проблема. Нам нужен надежный способ обнаружения сетевых тайм-аутов.
Решение. При возникновении тайм-аутов может возвращаться несколько
различных типов ошибок. В каждом случае следует проверить ошибку, чтобы
определить, была ли она спровоцирована сетевым тайм-аутом.
Обсуждение. Если ошибка возникла при вызове функции из пакета net или
пакета, построенного на его основе (например, http), следует проверить
ошибку на соответствие одному из известных сценариев тайм-аута. Часть
ошибок будет относиться к случаям, когда тайм-аут был явно установлен
и корректно обнаружен системой, другие — к ситуациям, когда тайм-аут
не был установлен, но все же произошел.
В листинге 11.4 содержится функция, способная обрабатывать различные типы
ошибок и определять, были ли они спровоцированы именно тайм-аутом.
Листинг 11.4. Обнаружение сетевого тайм-аута с помощью ошибки
Функция, возвращающая true или false
в зависимости от того, вызвана ли
ошибка сетевым тайм-аутом
Использует переключатель
типов для определения
func hasTimedOut(err error) bool {
типа исходной ошибки
switch err := err.(type) {
case *url.Error:
if err, ok := err.Err.(net.Error); ok && err.Timeout() {
return true
}
Ошибка типа
url.Error может быть
вызвана вложенной
ошибкой из пакета
net, которую
можно проверить
на наличие
тайм-аута
case net.Error:
if err.Timeout() {
return true
}
case *net.OpError:
if err.Timeout() {
return true
}
}
}
11.1. Использование REST API в качестве полнофункционального клиента
317
Проверяет наличие
тайм-аутов, зафиксированных
средствами пакета net
errTxt := "use of closed network connection"
if err != nil && strings.Contains(err.Error(), errTxt) {
return true
}
return false
Некоторые ошибки,
не имеющие
специального типа
или переменной
для проверки,
также могут указывать
на тайм-аут
Данная функция позволяет выявлять различные ситуации, связанные с таймаутами. В следующем фрагменте показано, как использовать эту функцию для
проверки того, была ли ошибка вызвана тайм-аутом:
res, err := http.Get("http://example.com/test.zip")
if hasTimedOut(err) {
panic("request has timed out")
}
if err != nil {
panic("something else has happened")
}
Как говорилось в главе 4, вместо переключателя типа можно применить метод
errors.Is(), суть от этого не поменяется. Надежное определение тайм-аутов
критически важно, и описанная далее техника наглядно это демонстрирует.
Тайм-ауты и возобновление передачи данных по протоколу HTTP
Если во время загрузки большого файла возникает тайм-аут, начинать этот процесс с самого начала нецелесообразно. Иногда объем файла может превышать
гигабайт, и в таких случаях важно избежать лишней траты времени и сетевых
ресурсов на повторную передачу данных.
Проблема. Нужно возобновить загрузку данных с того места, где она была
прервана из-за тайм-аута.
Решение. Повторите попытку загрузки, используя HTTP-заголовок Range,
в котором указывается диапазон байтов. Это позволит запросить файл, начиная с места прерывания процесса загрузки.
Обсуждение. Серверы, включая те, что входят в стандартную библиотеку Go,
способны раздавать отдельные части файла. Это весьма распространенная
функция файловых серверов, а сама спецификация диапазонов байтов была
стандартизирована еще в 1999 году после выхода протокола HTTP/1.1.
318
Глава 11. Работа с внешними сервисами
В приведенном ниже фрагменте кода создается локальный файл, в который
загружается содержимое, полученное из удаленного источника, отображается
число загруженных байтов и осуществляются повторные попытки (до 100 раз)
в случае сетевого тайм-аута. Основная логика реализована внутри функции
download, представленной в листинге 11.5.
func main() {
file, err := os.Create("file.zip")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
Создает локальный
файл для хранения
загружаемых данных
location := "https://example.com/file.zip"
err = download(location, file, 100)
if err != nil {
fmt.Println(err)
return
}
Загружает удаленный файл
в локальный, осуществляя
до 100 повторных попыток
при возникновении тайм-аута
fi, err := file.Stat()
if err != nil {
fmt.Println(err)
return
}
}
Отображает
размер файла
после завершения
загрузки
fmt.Printf("Got it with %v bytes downloaded", fi.Size())
Листинг 11.5. Ф
ункция загрузки данных, предусматривающая
повторные попытки
func download(location string, file *os.File, retries int64) error {
req, err := http.NewRequest("GET", location, nil)
Создает новый GET-запрос
if err != nil {
для загружаемого файла
return err
}
Открывает локальный файл
для получения сведений о нем
fi, err := file.Stat()
if err != nil {
return err
}
Определяет размер
локального файла
current := fi.Size()
if current > 0 {
start := strconv.FormatInt(current, 10)
req.Header.Set("Range", "bytes="+start+"-")
}
cc := &http.Client{Timeout: 5 * time.Minute}
res, err := cc.Do(req)
При наличии данных в локальном
файле задает заголовок
с диапазоном, начинающимся
с текущей длины, которая
соответствует позиции
следующего требуемого байта
HTTP-клиент, настроенный
на явную проверку тайм-аута
Выполняет запрос всего
файла или его фрагмента,
если часть данных уже
была сохранена локально
11.2. Обработка и передача ошибок по HTTP
if err != nil && hasTimedOut(err) {
if retries > 0 {
return download(location, file, retries-1)
}
return err
} else if err != nil {
return err
}
При обнаружении ошибки,
вызванной тайм-аутом,
повторяет запрос
if res.StatusCode < 200 || res.StatusCode > 300 {
errFmt := "Unsuccessful HTTP request. Status: %s"
return fmt.Errorf(errFmt, res.Status)
}
Обрабатывает коды
неуспешных HTTP-ответов
if res.Header.Get("Accept-Ranges") != "bytes" {
retries = 0
Копирует данные из ответа
}
в локальный файл
_, err = io.Copy(file, res.Body)
if err != nil && hasTimedOut(err) {
if retries > 0 {
return download(location, file, retries-1)
}
return err
} else if err != nil {
return err
}
}
319
Если сервер не поддерживает
раздачу фрагментов файла,
обнуляет счетчик повторов
Если при копировании
возникает тайм-аут,
запрашивает оставшиеся
данные повторно
return nil
Хотя функция download обрабатывает тайм-ауты достаточно прямолинейно, ее
поведение можно адаптировать под конкретные задачи. В нашем случае таймаут установлен на пять минут. Это значение можно скорректировать: для одних
приложений целесообразнее меньшее время, для других — большее. Например,
если время загрузки типичного файла превышает пять минут, то увеличение
тайм-аута поможет сократить количество HTTP-запросов, выполняемых в ходе
этого процесса.
Если для файла известна контрольная сумма, можно реализовать проверку
целостности, сравнив хеш загруженных данных с ожидаемым значением. Такая
проверка повышает доверие к результату, даже если загрузку пришлось возобновлять несколько раз. Способы обработки ошибок и обхода возникающих
проблем формируют основу для создания отказоустойчивых приложений.
11.2. Обработка и передача ошибок по HTTP
Ошибки — неотъемлемая часть обмена данными по протоколу HTTP. К наиболее распространенным примерам относятся Not Found и Access Denied, которые
настолько типичны, что спецификация протокола HTTP изначально преду
сматривает возможность передачи сведений о них. Стандартная библиотека Go
320
Глава 11. Работа с внешними сервисами
предлагает простые средства для передачи информации об ошибках, которых
в большинстве случаев бывает вполне достаточно. В листинге 11.6 показан простой HTTP-сервер, генерирующий сообщение об ошибке.
Листинг 11.6. Передача ошибки по протоколу HTTP
package main
import "net/http"
func displayError(w http.ResponseWriter, r *http.Request) {
http.Error(w, "An Error Occurred", http.StatusForbidden)
}
Назначает обработчик
displayError для всех путей
func main() {
http.HandleFunc("/", displayError)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
Возвращает код
HTTP-ответа 403
с сообщением
Этот простой сервер всегда возвращает сообщение об ошибке An Error Occurred.
Помимо сообщения в виде простого текста (text/plain), в ответ включается код
HTTP-ответа 403, означающий «доступ запрещен». Как было показано в главе 10,
оборачивание логики аутентификации в функцию промежуточной обработки
с помощью WithContext позволяет выполнить проверку прав доступа перед вызовом целевого обработчика.
СОВЕТ В пакете http стандартной библиотеки определены константы для различных
кодов состояния HTTP-ответов. Полный их список с описаниями можно найти по
адресу https://developer.mozilla.org/ru/docs/Web/HTTP/Reference/Status.
Клиент может проанализировать коды состояния, возвращаемые сервером,
чтобы понять, что произошло с запросом. При проверке res.StatusCode в листинге 11.5 клиент ожидает код из диапазона 20x, который указывает на успешное выполнение запроса. Ниже приведен простой пример отображения кода
состояния:
res, _ := http.Get("http://example.com")
fmt.Println(res.Status)
fmt.Println(res.StatusCode)
Поле res.Status содержит текстовое описание состояния HTTP-ответа, например 200 OK или 404 Not Found. Если вам нужно числовое представление кода, обратитесь к полю res.StatusCode, которое возвращает соответствующее значение
в виде целого числа.
Клиенты, взаимодействующие с таким сервером, должны анализировать коды
состояния, чтобы оценить целесообразность очередного выполнения запроса.
11.2. Обработка и передача ошибок по HTTP
321
Например, при получении кодов 429 Too Many Requests или 500 Internal Server
Error имеет смысл повторять попытки и постепенно увеличивать временной
интервал между ними:
func requestUntil(url string, ops retryableRequest) (*http.Response, error) {
for i := 0; i < ops.maxRetries; i++ {
// если запрос проходит успешно, возвращаем результат раньше времени
}
return nil, nil
}
11.2.1. Создание пользовательских ошибок
Обычной текстовой строки с описанием ошибки и кода HTTP-ответа может
оказаться недостаточно. Если вы создаете веб-интерфейс, скорее всего, вам захочется, чтобы страницы ошибок были оформлены в едином стиле. А если вы
разрабатываете API-сервер, возвращающий данные в формате JSON, то, вероятно, захотите передавать сообщения об ошибках в этом же формате.
Первый этап работы с пользовательскими ошибками сводится к их генерации
на сервере. Функция Error из стандартного пакета http предоставляет лишь
базовые возможности и практически не допускает настройки. Тип содержимого
ответа жестко задан как text/plain (обычный текст), а заголовок X-ContentType-Options установлен в значение nosniff, что пресекает попытки некоторых
инструментов определить тип содержимого. В данном случае изменить можно
только текст сообщения об ошибке.
Проблема. Нужен способ задать пользовательское тело ответа и тип содержимого в случае возникновения ошибки.
Решение. Вместо встроенной функции Error использовать пользовательские
функции, возвращающие нужный код состояния и более уместное тело
ответа.
Обсуждение. Сообщения об ошибке, не ограничивающиеся ее текстовым
описанием, могут оказаться весьма полезными для пользователей. Например, при переходе на несуществующую страницу человек может столкнуться
с ошибкой 404 Not Found. Если при этом страница ошибки оформлена в том же
стиле, что и остальной сайт, и содержит полезную информацию, помогающую
пользователю найти нужный ему контент, то такая страница становится
средством навигации, а не поводом для замешательства.
Еще один пример касается сообщений об ошибках в REST API. Такие интерфейсы обычно используются SDK (software development kit — набор инструментов
для разработки) или другими клиентскими приложениями. Если при вызове
API возвращается ответ с кодом 409 Conflict, стоит дополнить его, например
указать код ошибки, специфичный для приложения, или предложить пользователю рекомендации (повторить запрос как есть либо изменить его параметры).
322
Глава 11. Работа с внешними сервисами
Чтобы показать, как это работает, рассмотрим сообщение об ошибке, возвращае
мое в виде данных JSON. Оно оформлено так же, как и другие ответы REST API,
и содержит код ошибки, специфичный для приложения, помимо обычного кода
состояния HTTP-ответа. Хотя данный пример ориентирован на API, тот же
принцип применим и к веб-страницам (листинг 11.7).
Листинг 11.7. Пользовательское сообщение об ошибке в формате JSON
type Error struct {
HTTPCode int
`json:"-"`
Code
int
`json:"code,omitempty"`
Message string `json:"message"`
}
Тип для хранения информации
об ошибке, включая метаданные,
описывающие ее JSON-структуру
func JSONError(w http.ResponseWriter, e Error) {
data := struct {
Оборачивает структуру Error
Err Error `json:"error"`
в анонимную структуру
}{e}
со свойством error
b, err := json.Marshal(data)
if err != nil {
http.Error(w, "Internal Server Error", 500)
return
}
}
Функция JSONError аналогична
http.Error, но тело ответа
формируется в формате JSON
Преобразует данные об ошибке
в JSON и обрабатывает ошибку
(если есть)
Задает application/json
в качестве MIME-типа ответа
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(e.HTTPCode)
Устанавливает соответствующий код состояния HTTP-ответа
fmt.Fprint(w, string(b))
Выводит тело ответа
в формате JSON
func displayError(w http.ResponseWriter, r *http.Request) {
e := Error{
HTTPCode: http.StatusForbidden,
Создает экземпляр структуры
Code:
123,
Error для формирования ответа
Message: "An Error Occurred",
}
JSONError(w, e)
Возвращает сообщение
}
об ошибке в формате JSON
при вызове HTTP-обработчика
func main() {
http.HandleFunc("/", displayError)
http.ListenAndServe(":8080", nil)
}
Этот листинг концептуально схож с листингом 11.6. Разница в том, что в листинге 11.6 возвращается строка с сообщением об ошибке, тогда как в листинге 11.7
ответ формируется в формате JSON, например:
{
}
"error": {
"code": 123,
"message": "An Error Occurred"
}
11.2. Обработка и передача ошибок по HTTP
323
При передаче информации об ошибках в формате JSON приложение, которое
их обрабатывает, получает возможность использовать эти данные в единой
структурированной форме. Способ применения таких ошибок описан в следующем разделе.
11.2.2. Обработка пользовательских ошибок
Любой клиент способен определить ошибку, проанализировав код состояния
HTTP-ответа следующим образом:
res, err := http.Get("https://www.manning.com/")
switch res.StatusCode {
case 300 <= res.StatusCode && res.StatusCode < 400:
fmt.Println("Redirect message")
case 400 <= res.StatusCode && res.StatusCode < 500:
fmt.Println("Client error")
case 500 <= res.StatusCode && res.StatusCode < 600:
fmt.Println("Server error")
}
Коды в диапазоне 30x связаны с перенаправлениями. В браузерах они обычно
не отображаются, так как HTTP-клиент автоматически следует по цепочке
перенаправлений, обычно включающей от 10 до 20 звеньев. К диапазону 400 относятся ошибки, возникшие на стороне клиента (например, Access Denied или
Not Found). Коды в диапазоне 500 соответствуют внутренним ошибкам сервера.
Анализ кода состояния HTTP-ответа позволяет лучше разобраться в произошедшем. Например, если сервер возвращает код 401 или 403, это означает, что
для доступа к ресурсу требуется аутентификация. В таком случае для повторной
отправки запроса пользователь может войти в систему, а SDK — попытаться
пройти (или повторить) процедуру аутентификации.
Если приложение возвращает пользовательские ошибки (как в листинге 11.7),
это означает, что структура ответа API отличается от ожидаемой.
Проблема. Мы хотим различать и по-разному обрабатывать пользовательские
ошибки, отличающиеся по структуре от обычных ответов API.
Решение. Проверить код состояния и MIME-тип на предмет ошибки при
получении ответа. Если они сигнализируют о проблеме или возвращают
неожиданный результат, следует преобразовать ответ в ошибку, вернуть
и обработать ее.
Обсуждение. Для языка Go характерна явная обработка ошибок, которая
распространяется и на коды состояния HTTP-ответа. Получение неожиданного кода при выполнении HTTP-запроса можно интерпретировать как
ошибку. Первым делом при этом следует возвратить ошибку, как показано
в листинге 11.8.
324
Глава 11. Работа с внешними сервисами
Листинг 11.8. Преобразование HTTP-ответа в ошибку
type Error struct {
HTTPCode int
`json:"-"`
Code
int
`json:"code,omitempty"`
Message string `json:"message"`
}
Структура для хранения
данных об ошибке
func (e Error) Error() string {
fs := "HTTP: %d, Code: %d, Message: %s"
return fmt.Sprintf(fs, e.HTTPCode, e.Code, e.Message)
}
Метод Error реализует
интерфейс error
для структуры Error
Для выполнения запросов вместо http.Get
func get(u string) (*http.Response, error) {
следует использовать функцию get
res, err := http.Get(u)
Использует метод http.Get
if err != nil {
для получения ресурса и возвращения
Проверяет, выходит ли
return res, err
соответствующих ошибок
код ответа за пределы
}
диапазона 200
(успешные ответы)
if res.StatusCode < 200 || res.StatusCode >= 300 {
if res.Header.Get("Content-Type") != "application/json" {
sm := "Unknown error. HTTP status: %s"
return res, fmt.Errorf(sm, res.Status)
}
Проверяет тип
содержимого
ответа
b, _ := io.ReadAll(res.Body)
Считывает тело
и возвращает ошибку,
res.Body.Close()
ответа в буфер
если он некорректен
var data struct {
Err Error `json:"error"`
Парсит JSON-ответ,
}
помещает данные
в структуру
err = json.Unmarshal(b, &data)
и обрабатывает
if err != nil {
возникшие ошибки
sm := "Unable to parse json: %s. HTTP status: %s"
}
}
}
return res, fmt.Errorf(sm, err, res.Status)
Добавляет код HTTP-ответа
в экземпляр Error
data.Err.HTTPCode = res.StatusCode
return res, data.Err
Возвращает пользовательскую
return res, nil
ошибку и сам ответ
При отсутствии ошибки
возвращает ответ как обычно
Этот код заменяет стандартную функцию http.Get , используемую для обращения к серверу, на функцию get, которая обрабатывает пользовательские
ошибки. Структура Error, содержащая сведения об ошибке, по форме совпадает
с ошибкой из листинга 11.7. Такая схема рассчитана на работу с сервером, который возвращает ошибки в аналогичном формате. В рамках обеих техник можно
использовать общий пакет, определяющий структуру ошибки.
Добавление метода Error к типу Error позволяет ему реализовать интерфейс
error. Благодаря этому экземпляры типа Error можно передавать между функциями как обычные ошибки.
11.3. Парсинг и преобразование данных JSON
325
В следующем фрагменте кода продемонстрировано использование функции
get вместо http.Get в main. При возникновении пользовательской ошибки
эта программа выводит сведения из JSON-ответа в консоль и завершает выполнение:
func main() {
res, err := get("http://localhost:8080")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
}
fmt.Printf("%s", b)
Применение этой техники для получения и передачи HTTP-ошибок между
компонентами приложения позволяет воспользоваться преимуществами других
средств обработки ошибок, предусмотренных в языке Go. В частности, мы можем
задействовать конструкции switch для проверки типа ошибки и соответствующей реакции на нее (как показано в листинге 11.4). Причем такой подход будет
работать и в случае с пользовательскими ошибками.
11.3. Парсинг и преобразование данных JSON
При взаимодействии через REST API самым распространенным форматом
передачи данных является JSON. Возможность легко и быстро преобразовывать
строки JSON в нативные структуры данных Go — крайне полезна, и пакет стандартной библиотеки encoding/json предоставляет для этого все необходимые
средства. В листинге 11.9 показано, как парсировать простую JSON-структуру
в структуру языка Go.
Листинг 11.9. Простой пример парсинга JSON-структуры
package main
import (
"encoding/json"
"fmt"
)
type Person struct {
Name string `json:"name"`
}
var JSON = `{
"name": "Miracle Max"
}`
Структура, которая также представляет данные
в формате JSON. Тег json сопоставляет свойство Name
с полем name в JSON-структуре
Данные JSON представлены
в виде строки
326
Глава 11. Работа с внешними сервисами
func main() {
var p Person
Экземпляр структуры Person
для хранения парсированных
данных JSON
err := json.Unmarshal([]byte(JSON), &p)
if err != nil {
fmt.Println(err)
Обрабатывает ошибки,
return
возникшие при парсинге
}
}
fmt.Println(p)
Парсирует данные
JSON и заполняет ими
структуру Person
Выводит содержимое
объекта Person на экран
Хотя средства стандартной библиотеки покрывают все базовые сценарии парсинга данных JSON, на практике вы можете столкнуться с весьма распространенными задачами, не имеющими очевидного решения.
Структура JSON-данных обычно определяется на основе анализа документации,
примеров использования или реальных ответов сервера. Существующие схемы
описания JSON (например, JSON Schema) не получили широкого распространения. Формат JSON по своей природе не требует строгой схемы, а API нередко
возвращают данные с переменной структурой. В некоторых случаях структура
ответа вообще может быть неизвестна.
При парсинге данных JSON средствами Go они помещаются в структуры,
определяемые в коде. Если структура таких данных не известна заранее или
подвержена изменению, это становится проблемой. Может показаться, что анализировать данные JSON и работать с документами, имеющими изменяемую
структуру, очень сложно, однако в Go это вполне возможно за счет обобщения
представления данных и проверки их типа во время выполнения.
Проблема. Как парсировать данные JSON в структуру данных Go, если их
структура заранее не известна?
Решение. Парсировать данные JSON и преобразовать их не в структуру,
а в значение типа interface{}. После этого их можно будет проанализировать
и использовать.
Обсуждение. Малозаметная, но важная особенность пакета encoding/json
заключается в умении парсить произвольный JSON в переменную типа
interface{}. В листинге 11.10 приведен пример такого подхода.
Листинг 11.10. Парсинг данных JSON в переменную типа interface
package main
import (
"encoding/json"
"fmt"
"os"
)
11.3. Парсинг и преобразование данных JSON
327
var ks = []byte(`{
"firstName": "Jean",
"lastName": "Bartik",
"age": 86,
"education": [
{
"institution": "Northwest Missouri State Teachers College",
"degree": "Bachelor of Science in Mathematics"
},
{
"institution": "University of Pennsylvania",
"degree": "Masters in English"
}
],
"spouse": "William Bartik",
"children": [
"Timothy John Bartik",
"Jane Helen Bartik",
"Mary Ruth Bartik"
]
Переменная типа interface{}
}`)
для хранения данных JSON
func main() {
JSON-документ,
Парсирует данные JSON
var f interface{}
подлежащий
и сохраняет их в переменную
парсингу
типа interface{}
err := json.Unmarshal(ks, &f)
и демаршалингу
if err != nil {
Обрабатывает ошибки,
fmt.Println(err)
например синтаксические
os.Exit(1)
}
}
fmt.Println(f)
Обращается к данным JSON,
хранящимся в переменной interface{}
Здесь данные JSON для парсинга содержат элементы с различной структурой. Это важно, поскольку работа с типом interface{} отличается от работы
с JSON-данными, преобразованными в структуру. Чуть позже вы узнаете, как
обращаться с такими данными.
После преобразования данных JSON в структуру доступ к ним осуществляется
очень просто. В примере из листинга 11.9 имя человека из парсированного JSON
можно получить через p.Name. Однако обратиться к полю firstName переменной
interface{} тем же способом не получится:
fmt.Println(f.firstName)
Попытка обращения к firstName как к свойству приводит к ошибке:
f.firstName undefined (type interface {} is interface with no methods)
Прежде чем приступить к работе с этими данными, их необходимо привести
к типу, отличному от interface{}. В нашем случае данные JSON представляют
собой объект, поэтому мы можем использовать тип map[string]interface{},
328
Глава 11. Работа с внешними сервисами
позволяющий получить доступ к вложенным данным. Ниже показан способ
обращения к полю firstName:
m := f.(map[string]interface{})
fmt.Println(m["firstName"])
Теперь нам доступны все ключи верхнего уровня и значение поля firstName
можно извлечь по его имени.
Для программного обхода структуры, полученной в результате парсинга JSON,
полезно понимать, как Go преобразует разные типы данных при их парсинге.
При демаршалинге значений JSON используются следующие типы Go:
bool — для логических значений JSON;
float64 — для чисел JSON;
[]interface{} — для массивов JSON;
map[string]interface{} — для объектов JSON;
nil — для null-значений JSON;
string — для строковых значений JSON.
Зная эти правила преобразования, вы можете обойти структуру данных, рекурсивно извлекая все вложенные элементы. В листинге 11.11 показана функция,
которая выполняет рекурсивный обход парсированных данных JSON и выводит
имена ключей, типы и значения.
Листинг 11.11. Обход произвольной JSON-структуры
func printJSON(v interface{}) {
Выполняет переключение в зависимости от типа данных значения
switch vv := v.(type) {
case string:
fmt.Println("is string", vv)
case float64:
fmt.Println("is float64", vv)
case []interface{}:
fmt.Println("is an array:")
for i, u := range vv {
fmt.Print(i, " ")
printJSON(u)
}
case map[string]interface{}:
fmt.Println("is an object:")
for i, u := range vv {
fmt.Print(i, " ")
printJSON(u)
}
}
default:
fmt.Println("Unknown type")
}
Отображает информацию
о типе и значении для
каждого типа данных JSON.
Для объектов и массивов
рекурсивно вызывает функцию
printJSON для вывода значений
вложенных свойств
11.4. Версионирование REST API
329
Если все, что нужно, — просто вывести необработанные данные в формате по
умолчанию, вы можете воспользоваться спецификатором формата %v . Тем
не менее функция в листинге 11.11 предоставляет дополнительную гибкость
при отображении значений. Хотя возможность парсировать и обрабатывать данные JSON с неизвестной структурой весьма полезна, желательно иметь заранее
определенные и задокументированные структуры либо предусмотреть механизм
обработки их изменений. В следующем разделе мы поговорим о версионировании
API, которое позволяет учитывать изменения в том числе в структуре JSON.
11.4. Версионирование REST API
Веб-сервисы со временем развиваются, что влечет за собой изменения в API,
через которые осуществляется доступ к этим сервисам или управление ими.
Чтобы обеспечить стабильный API-контракт для потребителей, корректировки
в API необходимо версионировать. Поскольку «потребителями» API являются
программы, их необходимо обновлять с учетом внесенных правок, а это требует
времени.
Обычно версии API различаются по номерам, например v1, v2, v3. Такая схема
позволяет сигнализировать о значительных изменениях, способных нарушить
обратную совместимость. Приложение, рассчитанное на работу с версией v2,
может оказаться неспособным взаимодействовать с версией v3 из-за слишком
сильных отличий.
Но как быть с изменениями, которые просто расширяют уже существующий API?
Допустим, в API версии v1 была добавлена новая функциональность. В этом
случае можно использовать номер версии с десятичной частью, например v1.1.
Это покажет разработчикам и приложениям, что в API появились дополнения.
Далее описаны два подхода к публикации версионированных API.
11.4.1. Версия API в URL-адресе
Изменения в версии API должны быть очевидны и удобны в использовании.
Чем проще разработчикам увидеть, понять и применить новую версию API, тем
охотнее они будут с ним работать.
Важно также, чтобы версионированные API корректно взаимодействовали
с существующими инструментами. Например, возможность быстро тестировать
API-вызовы с помощью cURL или Postman (популярного расширения для
Chrome) облегчает процесс разработки и тестирования ПО.
Проблема. Мы хотим предоставить версионированный API наиболее доступным способом.
Решение. Указать версию API непосредственно в URL-адресе REST API.
Например, вместо https://example.com/api/todos использовать адрес https://
example.com/api/v1/todos.
330
Глава 11. Работа с внешними сервисами
Обсуждение. На рис. 11.1 показан очень популярный способ версионирования
API с помощью URL-адреса. Этот подход используют, в частности, Google,
OpenStack, Salesforce и Facebook.
Рис. 11.1. Указание версии REST API в URL-адресе
Как показано в листинге 11.12, реализация такой структуры URL выполняется
на этапе сопоставления пути с обработчиками.
Листинг 11.12. Регистрация пути API с указанием версии
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type testMessage struct {
Message string `json:"message"`
}
Пример функции-обработчика,
возвращающей ответ
в формате JSON
func displayTest(w http.ResponseWriter, r *http.Request) {
data := testMessage{"A test message."}
b, err := json.Marshal(data)
if err != nil {
http.Error(w, "Internal Server Error", 500)
return
}
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, string(b))
func main() {
http.HandleFunc("/api/v1/test", displayTest)
http.ListenAndServe(":8080", nil)
}
При сопоставлении
обработчика с URL в путь
включается версия API
В этом примере способ сопоставления функции-обработчика с путем не позволяет с легкостью обрабатывать запросы, выполняемые с использованием разных
методов, таких как POST, PUT и DELETE. Если конечная точка представляет ресурс,
11.4. Версионирование REST API
331
то обычно один и тот же URL обслуживает все эти типы запросов. Приемы сопоставления нескольких HTTP-методов с одним URL описаны в главах 2 и 8.
Хотя такой способ передачи версии API через URL-адрес весьма прост, с технической точки зрения он не является реализацией семантического версионирования. Сам по себе URL представляет не объект, а лишь доступ к объекту
в рамках определенной версии API. Основное преимущество такого подхода
заключается в удобстве для разработчиков: включение версии API в URL-адрес
упрощает взаимодействие с ним.
11.4.2. Версия API в типе содержимого
Предыдущий способ был ориентирован на удобство для разработчиков, но
не реализовывал схему семантического версионирования. Согласно изначальной
концепции REST URL-адрес должен представлять некую сущность — объект,
список или что-то иное. В зависимости от параметров запроса, таких как тип
запрашиваемого содержимого или HTTP-метод, результат обращения к этому
объекту может различаться.
Проблема. Мы хотим указывать версии API с соблюдением принципов семантического версионирования.
Решение. Вместо использования стандартного типа содержимого application/
json в запросах и ответах применяйте пользовательский тип с указанием
версии API. Например, application/vnd.mytodo.v1.json или application/
vnd.mytodo.json; version=1.0. Такие пользовательские типы явно обозначают предполагаемую схему данных.
Обсуждение. Чтобы реализовать поддержку нескольких версий API по
одному и тому же адресу, как показано на рис. 11.2, помимо всего прочего,
необходимо учитывать тип содержимого. В листинге 11.13 показан один из
способов определения типа содержимого и генерации ответа на его основе.
Рис. 11.2. Различия между семантическими URL и URL, содержащими версию API
332
Глава 11. Работа с внешними сервисами
Листинг 11.13. Передача версии API в типе содержимого
func main() {
http.HandleFunc("/test", displayTest)
http.ListenAndServe(":8080", nil)
}
Регистрирует путь, поддерживающий
несколько типов содержимого
func displayTest(w http.ResponseWriter, r *http.Request) {
t := r.Header.Get("Accept")
Определяет запрошенный
var err error
тип содержимого
var b []byte
var ct string
switch t {
case "application/vnd.mytodos.json; version=2.0":
data := testMessageV2{"Version 2"}
b, err = json.Marshal(data)
ct = "application/vnd.mytodos.json; version=2.0"
case "application/vnd.mytodos.json; version=1.0":
fallthrough
default:
data := testMessageV1{"Version 1"}
b, err = json.Marshal(data)
ct = "application/vnd.mytodos.json; version=1.0"
}
Генерирует разные
данные в зависимости
от типа содержимого
if err != nil {
В случае ошибки при создании JSON
http.Error(w, "Internal Server Error", 500)
возвращает сообщение о ней
return
}
Задает тип содержимого в соответствии
с типом сгенерированных данных
w.Header().Set("Content-Type", ct)
}
fmt.Fprint(w, string(b))
Отправляет
данные клиенту
type testMessageV1 struct {
Message string `json:"message"`
}
type testMessageV2 struct {
Info string `json:"info"`
}
Когда клиент запрашивает некое содержимое, он может не указывать его тип,
чтобы получить ответ по умолчанию. Но чтобы воспользоваться API версии 2,
ему необходимо отказаться от простого GET-запроса и предоставить дополнительные сведения. В следующем примере показано, как запросить вторую версию
API и отобразить полученный ответ:
Тип содержимого с указанием
запрашиваемой версии API
ct := "application/vnd.mytodos.json; version=2.0"
Создает новый
GET-запрос к серверу,
реализованному
в листинге 11.13
req, _ := http.NewRequest("GET", "http://localhost:8080/test", nil)
11.5. Работа с протоколом gRPC
req.Header.Set("Accept", ct)
333
Добавляет указанный тип содержимого в заголовки запроса
res, _ := http.DefaultClient.Do(req)
Выполняет запрос
if res.Header.Get("Content-Type") != ct {
Проверяет тип содержимого
fmt.Println("Unexpected content type returned")
ответа на соответствие
return
ожидаемому
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
fmt.Printf("%s", b)
Отображает содержимое
тела ответа
Хотя этот способ позволяет поддерживать несколько версий API на одной конечной точке, стоит учитывать следующие моменты:
типы содержимого в пространстве имен vnd. должны быть зарегистрированы
в организации IANA (Internet Assigned Numbers Authority — Управление по
распределению адресов в Интернете);
при обращении к версии API, отличной от версии по умолчанию, необходимо
выполнить дополнительные действия для указания соответствующего типа
содержимого, что усложняет задачу для приложений, использующих API.
11.5. Работа с протоколом gRPC
Хотя REST остается наиболее распространенным протоколом для взаимодействия в веб-среде на уровне API, у него существует менее популярная альтернатива, интерес к которой то угасает, то возобновляется. Речь идет о протоколе
gRPC (рекурсивная аббревиатура от gRPC Remote Procedure Calls), основанном
на концепции удаленных вызовов процедур (Remote Procedure Calls, RPC) —
старой технологии распределенных вычислений. Изначально RPC представлял
собой подход, при котором приложение разбивалось на части, а отдельные
функции размещались в разных местах, откуда их можно было вызывать по
мере необходимости.
Компания Google одной из первых внедрила этот подход в свою архитектуру
и в 2015 году опубликовала собственную реализацию RPC, предназначенную
для микросервисов и взаимодействия через HTTP API. Кроме того, ею был
разработан механизм Protocol Buffers — формализованный и более безопасный
способ сериализации, совместимый с различными языками программирования.
Вот его основные преимущества.
Такой формат сериализации позволяет быстрее создавать, анализировать
и передавать данные, чем JSON, что существенно влияет на производительность при масштабировании.
Хотя Go может обеспечить строгую типизацию при демаршалинге, на практике нередко приходится работать с типами вроде json.RawMessage или
334
Глава 11. Работа с внешними сервисами
interface{}, а затем использовать рефлексию — особенно если структура
входных или выходных данных неизвестна или переменчива.
Автоматическая генерация части шаблонного кода для обработки сообщений
избавляет от необходимости писать его вручную.
Тем не менее для небольших проектов применение gRPC может оказаться избыточным, поскольку у него есть и недостатки.
По умолчанию вы не можете обрабатывать входные и выходные данные запросов так же, как в REST, поскольку они, как правило, не предназначены
для чтения человеком или недоступны напрямую. Существуют варианты
использования альтернативных схем кодирования и транскодирования, но
по умолчанию gRPC работает с двоичными данными.
Тестировать такие запросы в браузере сложнее, поскольку отсутствуют
встроенные инструменты для их обработки и отображения. Тем не менее
популярный инструмент Postman поддерживает протокол gRPC.
Протокол gRPC предполагает гарантированную обратную совместимость.
Он расширяемый, но изменение контракта может привести к тому, что
клиент перестанет корректно обрабатывать входные данные.
Создать API на базе gRPC сложнее, чем реализовать REST API.
Этот выбор сродни выбору между языками со строгой и слабой типизацией.
Строгая типизация обеспечивает безопасность и проверку на этапе компиляции, но заставляет вас жертвовать гибкостью и скоростью разработки. Выбор
gRPC в качестве архитектурного решения для небольших проектов может быть
оправдан только намерением использовать преимущества данного протокола.
Тем не менее, даже если вы не применяете gRPC для создания собственных API,
вы, скорее всего, столкнетесь с ним при работе со сторонними сервисами. При
этом, как потребителю, вам будет приятно иметь дело с API, который гарантирует строго определенный протокол взаимодействия и типизацию данных на
стороне сервера.
Проблема. Мы хотим использовать API с точным, типобезопасным контрактом, более надежным, чем REST API.
Решение. Если API реализован с помощью gRPC, для взаимодействия с ним
следует применять Protocol Buffers (protobuf) — механизм, обеспечивающий
универсальное описание структуры для сериализации и десериализации
строго типизированных данных. Такие данные можно использовать аналогично параметрам локальных функций.
Обсуждение. Хотя механизм protobuf иногда называют схемой, он сам по
себе ею не является. Вам, как клиенту, необходимо понимать, какие данные
поступают с сервера, прежде чем пытаться их распаковать.
11.5. Работа с протоколом gRPC
335
Существует множество API, реализованных с использованием gRPC, но почти
все они требуют аутентификации и зачастую оплаты. Чтобы продемонстрировать рассматриваемый подход, мы создадим gRPC-приложение на языке Python
и организуем взаимодействие с ним средствами Go. За основу мы возьмем
чат-приложение, разработанное в предыдущих главах. Использование gRPC
вместо REST позволяет создать более надежный и устойчивый сервис, а также
обеспечивает более четко определенную структуру данных, чем при обмене
JSON-сообщениями через серверные события или веб-сокеты. Поскольку речь
идет об удаленных вызовах процедур, есть смысл использовать сгенерированный
код повторно — на разных языках программирования.
Основой любого gRPC-сервера является файл с расширением .proto, в котором
определяются API и структура передаваемых данных, включая типы сообщений
и маршруты. Этот файл пишется на языке описания интерфейсов proto3, третьей
версии языка Protocol Buffers. Go-разработчику его синтаксис покажется знакомым, впрочем, он универсален и поддерживается всеми языками, совместимыми
с gRPC (листинг 11.14).
Листинг 11.14. Базовый файл proto3 для сервера чата
syntax = "proto3";
package chat;
Определяет формат protobuf
Объявляет пакет, используемый
для организации пространства имен
import "google/protobuf/timestamp.proto";
option go_package = "./ch11";
Объявляет пакет для генерации Go-кода
message CommentRequest {
string username = 1;
string text = 2;
google.protobuf.Timestamp sent = 3;
}
message CommentResponse {
int32 commentLength = 1;
int32 previousCommentCount = 2;
}
Импортирует внешний тип
Обобщенные
структуры типов
Определяет
gRPC-сервис
и его методы
service ChatService {
rpc RouteComments (CommentRequest) returns (CommentResponse) {}
}
Сходство этого фрагмента с Go-кодом объясняется тем, что оба языка были
созданы в стенах Google. Обратите внимание на строку с option. Для каждого
поддерживаемого языка предусмотрен ряд флагов и параметров, и эта строка
указывает компилятору protoc, к какому Go-пакету должен принадлежать сгенерированный код.
336
Глава 11. Работа с внешними сервисами
Для начала нужно установить protobuf и плагины Go, как описано в руководстве: https://grpc.io/docs/languages/go/quickstart. После этого для генерации кода
необходимо выполнить команду protoc с указанием нашего файла с расширением .proto:
protoc - --go_out=. --go-grpc-out=._protos/chat.proto
Для серверной стороны инструменты gRPC автоматически генерируют Pythonклассы (или их аналоги для других языков), избавляя нас от необходимости
писать шаблонный код вручную. После генерации Python-классов мы можем использовать их в Python-сервере. Для этого достаточно указать соответствующий
параметр: --python_out=. (То же справедливо в отношении других поддерживаемых языков.) Вам также может потребоваться установить Python-библиотеку,
обеспечивающую поддержку gRPC. Это легко сделать через пакетный менеджер
pip: python -m pip install grpcio.
ПРИМЕЧАНИЕ На официальном сайте gRPC есть инструкции по генерации кода
для каждого языка. Пример на Python доступен по адресу https://grpc.io/docs/
languages/python/basics.
В примере на Python из листинга 11.15 содержится не так уж много кода, и если
вы не очень хорошо знакомы с этим языком, не беспокойтесь, так как большая
часть логики реализована в сгенерированном gRPC-коде.
Листинг 11.15. gRPC-сервер на языке Python
import concurrent.futures as futures
import grpc
import chat_pb2_grpc
import chat_pb2
class ChatServiceServicer(chat_pb2_grpc.ChatServiceServicer):
def __init__(self, *args, **kwargs):
self.comments = []
pass
Определяет
реализацию
метода (-ов)
сервиса на Python
def RouteComments(self, request, context):
response = {
'commentLength': len(request.text),
'previousCommentCount': len(self.comments)
}
self.comments.append(request)
return chat_pb2.CommentResponse(**response)
def main():
print("starting server")
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
11.5. Работа с протоколом gRPC
337
chat_pb2_grpc.add_ChatServiceServicer_to_server(
ChatServiceServicer(), server
Запускает сервер и подключает
)
к нему реализацию
server.add_insecure_port("localhost:50051")
server.start()
server.wait_for_termination()
if __name__ == '__main__':
main()
Определяет реализацию
метода (-ов) сервиса на Python
Теперь, когда у нас есть конечная точка gRPC, давайте подключимся к ней из Go
и посмотрим, как можно отправлять сообщения через созданный канал. Для этого
сгенерируем Go-код аналогично тому, как делали в листинге 11.15. В результате
будут созданы файлы с расширением .go, содержащие код, автоматически сгенерированный gRPC. Эти файлы редактировать не следует (соответствующее
предупреждение вы найдете прямо в их комментариях).
Листинг 11.16. Подключение к gRPC-сервису и отправка запроса
package ch11
import (
"context"
"fmt"
"log"
"time"
)
"google.golang.org/grpc"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
Создает структуру
подключения
func ConnectToGRPC() error {
conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
if err != nil {
return err
}
Создает нового gRPC-клиента,
используя это подключение
client := NewChatServiceClient(conn)
meta, err := client.RouteComments(context.Background(), &CommentRequest{
Username: "Nick",
Text:
"Hello World!",
Sent:
timestamppb.New(time.Now())
Вызывает удаленную процедуру,
})
передавая типизированную структуру
if err != nil {
CommentRequest в качестве аргумента
return err
}
}
log.Println(fmt.Sprintf("Message length: %d, previous comments: %d",
meta.CommentLength, meta.PreviousCommentCount))
Отправляет ответ
в стандартный
defer conn.Close()
поток вывода
return nil
338
Глава 11. Работа с внешними сервисами
Хотя приведенный пример реализует простейшую схему «запрос — ответ», он
уже дает то, чего нет в REST: гарантию того, что входные и выходные данные
имеют корректный тип и структуру, предоставляемую на уровне соединения.
Случайные данные JSON, присланные клиентом, могут легко нарушить работу
REST-сервиса. Использование gRPC обеспечивает степень типобезопасности,
характерную для вызовов функций внутри одного и того же приложения. Это
позволяет устранить одну из серьезнейших проблем, присущих микросервисным
архитектурам, задействующим множество языков программирования.
Более того, благодаря использованию HTTP/2 gRPC может работать с постоянными соединениями и поддерживает как одностороннюю, так и двустороннюю
потоковую передачу. Создать сервис, принимающий или возвращающий поток
данных, можно следующим образом:
rpc GetComments() returns (stream CommentResponse) {}
Хотя прикладную логику вам все равно придется реализовывать самостоятельно, gRPC предоставляет каркас конечной точки, которая соответствует строго
заданному определению, и обеспечивает относительную типобезопасность приложения. Воспринимайте этот протокол как HTTP-фреймворк, который берет
на себя большую часть рутинных задач, связанных с созданием веб-сервисов.
Резюме
При разрыве сетевого соединения применяются методы обнаружения ошибок (в том числе тайм-аутов), повторного подключения и возобновления
загрузки данных.
Существует множество способов передачи сведений об ошибках в ответах.
Причем мы не ограничены одними лишь HTTP-кодами состояния. Стандартные ответы можно дополнять пользовательскими сообщениями (особенно
в REST-сервисах).
При взаимодействии с внешними сервисами вы можете столкнуться с протоколом gRPC — альтернативой REST. Вы также вправе использовать gRPC
в собственных API-сервисах для повышения их надежности.
Часть IV
Go в облаке,
микросервисы и другие
специализированные темы
Заключительная часть книги посвящена проработке деталей, обсуждению граничных случаев и развертыванию приложений, написанных на Go.
В главе 12 мы поговорим о развертывании приложений в облаке, их обнаружении и контейнеризации с помощью Docker. Мы подробно обсудим протоколы
Protocol Buffers и gRPC, а также способы ускорения взаимодействия внутри
облачной инфраструктуры.
В главе 13 рассматриваются некоторые пусть и необязательные, но важные
темы Go, знакомство с которыми качественно расширит арсенал разработчика.
Вы узнаете о рефлексии во время выполнения, взаимодействии с языком C
и генерации кода с помощью встроенных средств Go. Более глубокое понимание аннотаций структур позволит вам создавать удобные для пользователя
конструкции, способные взаимодействовать с сетевыми подключениями, файлами конфигурации и не только. Помимо всего прочего, мы обсудим некоторые
граничные случаи в работе с языком, включая точную настройку поведения
сборщика мусора.
12
Облачные приложения
и взаимодействие
между сервисами
В этой главе
3 Лучшие практики контейнеризации и развертывания
Go-приложений.
3 Обеспечение высокой доступности микросервисов в облаке.
3 Ускорение взаимодействия между микросервисами.
3 Компиляция под различные операционные системы
и архитектуры.
3 Мониторинг среды выполнения Go в приложении.
3 Методы управления обратным давлением в сервисах.
В предыдущих главах мы обсуждали процесс создания полноценных приложений
на Go. Теперь, когда мы разобрались с вопросами разработки и тестирования,
пора поговорить о развертывании. Существует множество способов развертывания, но в наши дни стандартным считается размещение приложения в облаке.
Мы начнем эту главу с краткого обзора темы облачных вычислений, чтобы понять, как этот подход реализуется на практике. Вы узнаете, как облачная модель
соотносится с традиционными способами работы на базе физических серверов
и виртуальных машин.
12.1. Основы облачных вычислений
341
Облачные вычисления — это сфера со множеством поставщиков услуг, от которых легко впасть в зависимость. Вы узнаете, как избежать подобной зависимости,
применяя обобщенные подходы и проектируя код таким образом, чтобы его
было удобно разрабатывать и тестировать локально.
Перед развертыванием и запуском приложения в облаке вам придется решить ряд задач, от определения характеристик хоста до организации мониторинга среды выполнения Go и кросс-компиляции под нужные платформы.
В данной главе вы научитесь справляться со всем этим, избегая подводных
камней.
Освоив ключевые концепции облачных технологий и развертывания, представленные в этой главе, вы будете иметь все необходимые знания для создания
и эксплуатации облачных Go-приложений.
12.1. Основы облачных вычислений
Термин «облако» нередко звучит слишком абстрактно, его любят использовать
маркетологи, чтобы создать впечатление современности, экономичности и простоты. Однако из-за обилия расплывчатых формулировок далеким от этой
сферы людям может быть сложно разобраться в сути данного понятия. В этом
разделе вы узнаете, что такое облачные вычисления и как их можно применить
при разработке и эксплуатации программного обеспечения.
12.1.1. Модели облачных вычислений
В самой простой модели облачных вычислений часть системы управляется другой стороной непрозрачным для пользователя способом. Этой сто
роной может быть другой отдел вашей компании, внешний поставщик услуг
или автоматизированная система (либо их комбинация). Если в работу во
влечен внешний провайдер, важно понимать, за какие именно части стека
он отвечает. На рис. 12.1 приведено сравнение трех основных моделей облачных вычислений с традиционной средой, в которой весь стек контролируется
вами.
При использовании физического сервера или серверной стойки вы вынуждены
самостоятельно управлять всеми компонентами стека. При необходимости
внести изменения кому-то придется заказать новое оборудование, дождаться его доставки, подключить и организовать сопровождение. Облачные вычисления были призваны сократить издержки, связанные с ручным управ
лением серверами, правда, зачастую это достигается ценой дополнительных
затрат.
Глава 12. Облачные приложения и взаимодействие между сервисами
Рис. 12.1. Модели облачных вычислений
342
12.1. Основы облачных вычислений
343
Инфраструктура как услуга (IaaS)
В рамках модели IaaS (Infrastructure as a Service — инфраструктура как услуга)
доступ к ресурсам предоставляется не так, как при традиционной работе с виртуальными машинами (ВМ). Хотя поставщики услуг по аренде ВМ и физических
серверов существуют уже давно, с появлением IaaS изменился сам принцип их
использования. В модели IaaS виртуальные серверы создаются и удаляются по
мере необходимости. Когда вам требуется сервер, создается его абстрактный
образ. Управление такими ресурсами осуществляется через программируемый
API, в качестве которого чаще всего выступает REST API или консольное приложение.
При этом модель IaaS охватывает не только серверы. Хранилища, сети и другие
компоненты инфраструктуры тоже доступны и управляются аналогичным образом. Как следует из названия, именно инфраструктура является настраиваемой
частью. Операционная система, среда выполнения, приложение и данные остаются в зоне ответственности пользователя и его ПО. Примерами IaaS-платформ
являются Amazon Web Services (AWS), Microsoft Azure и Google Cloud.
Платформа как услуга (PaaS)
Модель PaaS (Platform as a Service — платформа как услуга) отличается от IaaS
в первую очередь способом взаимодействия. Чтобы развернуть приложение на
платформе, вы используете API и передаете не только исходный код, но и сопутствующую информацию, например, о языке программирования, на котором
он написан. Платформа принимает эти данные, а затем самостоятельно собирает
и запускает приложение.
В рамках модели PaaS операционная система и среда выполнения управляются
платформой. Вам не нужно запускать виртуальные машины, задавать объем
ресурсов, выбирать ОС или устанавливать системное ПО — все эти задачи берет
на себя PaaS-сервис. В результате вы освобождаете время, которое в противном
случае ушло бы на обслуживание инфраструктуры, и можете сосредоточиться
на развитии самого приложения.
Помимо AWS, Google и Azure, известными поставщиками PaaS-сервисов являются Heroku, DigitalOcean и Engine Yard.
12.1.2. Контейнеры и облачно-ориентированные приложения
С появлением системы управления контейнерами Docker использование
контейнеров для запуска и распространения приложений стало чрезвычайно
популярным способом разворачивать относительно легковесные среды без избыточности, свойственной классическим виртуальным машинам. Контейнеры
отличаются от ВМ и традиционных серверов тем, что четко определяют весь
процесс построения среды для приложения и ведут себя предсказуемо на этапе
сборки и развертывания.
344
Глава 12. Облачные приложения и взаимодействие между сервисами
На рис. 12.2 приведено сравнение виртуальных машин с контейнерами. Слева
изображена система, использующая виртуализацию на уровне аппаратного
сервера, а справа — отдельная система с контейнерной архитектурой. В каждом
случае запускаются по два приложения в качестве рабочей нагрузки. При этом
приложение A масштабировано по горизонтали и имеет два экземпляра, что
в сумме дает по три рабочих экземпляра в каждой системе.
Рис. 12.2. Сравнение контейнеров и виртуальных машин
При запуске ВМ гипервизор предоставляет среду, в которой ОС может работать как на эмулированном оборудовании, так и с использованием специальных
аппаратных средств для виртуализации. Приложения выполняются в этой
среде. Когда два экземпляра приложения запускаются параллельно (так работает горизонтальное масштабирование), происходит воспроизведение всей
среды, включая гостевую ОС, программы, библиотеки и ваше приложение.
При использовании виртуальных машин важно учитывать ряд нюансов:
при запуске ВМ необходимо дождаться загрузки ядра и гостевой ОС;
гипервизоры и современное оборудование обеспечивают надежную изоляцию
виртуальных машин;
каждая ВМ представляет собой изолированный сервер, для которого выделяются ресурсы (эти ресурсы либо используются данной ВМ, либо резервируются для нее).
Контейнеры работают по иной схеме. Хост-сервер, будь то физическая или
виртуальная машина, запускает менеджер контейнеров. Каждый контейнер выполняется в пределах ОС хоста, а благодаря ядру хоста запуск осуществляется
практически мгновенно. Контейнеры используют ядро совместно, а их изоляция
от хоста и других контейнеров обеспечивается средствами ОС и драйверами
12.1. Основы облачных вычислений
345
оборудования. При этом двоичные файлы и библиотеки, обычно ассоциируемые
с операционной системой, могут быть полностью независимыми.
Облачно-ориентированные (или просто облачные) приложения, часто упоминае
мые в контексте контейнеризации, абстрагируют множество низкоуровневых
деталей взаимодействия с физическим оборудованием. Они используют такие
возможности облака, как автоматическое масштабирование путем добавления
экземпляров по требованию, автовосстановление при сбоях (что делает многие
проблемы системы незаметными для конечных пользователей), объединение
микросервисов в сложные приложения и не только.
Одним из лучших подходов к контейнеризации является многоэтапная сборка,
при которой компиляция происходит прямо во время сборки. Хотя Go позволяет
компилировать приложение для целевой среды с последующим копированием
в Docker-образ, гораздо надежнее выполнять компиляцию непосредственно
в целевой среде. Лаконичный пример Dockerfile, приведенный в листинге 12.1,
демонстрирует именно такой подход к сборке приложения.
Листинг 12.1. П
ростая многоэтапная сборка Docker-образа
Go-приложения
FROM golang:1.22 as build
RUN useradd --create-home --shell /bin/bash appbuild
RUN mkdir /home/appbuild/bin
Копирует все файлы
COPY * /home/appbuild
в домашний каталог
Собирает
WORKDIR /home/appbuild
Go-приложение
Run go build -o bin/app/main.go
FROM ubuntu:mantic
RUN useradd --create-home --shell /bin/bash apprun
COPY --from=build /home/appbuild/bin/app /home/apprun
CMD ["/home/apprun/app"]
Точка входа
Создает пользователя
и домашний каталог
для сборки приложения
Копирует скомпилированный
исполняемый файл
в запущенный контейнер
При таком подходе приложение компилируется непосредственно в процессе сборки,
что позволяет системе Docker кэшировать результаты отдельных этапов и сокращать
объем передаваемых данных. Разумеется, развертывание не всегда ограничивается
одним приложением. Мы можем использовать несколько файлов Dockerfile для
создания служебных процессов и вспомогательных контейнеров, работающих
в составе таких кластерных сред, как Kubernetes. Эта тема выходит за рамки книги,
однако учтите, что вы можете контейнеризировать несколько приложений из одной
кодовой базы, а также организовать их взаимодействие между собой.
Одним из важнейших аспектов облачных сервисов является управление ими.
Интерфейсы управления облачными сервисами служат точками взаимодействия
для Go-приложений.
346
Глава 12. Облачные приложения и взаимодействие между сервисами
12.2. Микросервисы и обеспечение
их высокой доступности
Распространенным, хотя и необязательным подходом к развертыванию приложений в облачной среде является использование микросервисной архитектуры.
Приложения, построенные на базе этой модели, представляют собой набор
независимых сервисов, каждый из которых решает свою узкоспециализированную задачу и развертывается отдельно от остальных. Рост сложности систем,
потребность в масштабировании отдельных компонентов и стремление сделать
приложения более устойчивыми и менее хрупкими привели к широкому распространению микросервисов. Хорошей аналогией для микросервисной архитектуры служат конвейеры команд в *nix-системах, в рамках которых каждая
команда выполняет простую, обособленную задачу и может быть запущена как
отдельно, так и в составе последовательности. Как правило, микросервисы обладают следующими характеристиками.
Выполняют одно действие. Например, хранят конфигурацию или преобразуют медиафайлы из одного формата в другой.
Эластичны и поддерживают горизонтальное масштабирование. По мере увеличения или снижения нагрузки такой сервис способен масштабироваться.
Устойчивы к сбоям. Приложение продолжает работать даже при выходе из
строя отдельных экземпляров.
Как видно из примера с shell-командами, данный подход схож с философией
UNIX: «Делайте что-то одно, но делайте это хорошо» — и во многом вдохновлен ею.
Представьте, что вы создаете сервис для преобразования мультимедийных
файлов из одного формата в другой. Пользователь загружает файл, и после его
перекодирования готовая версия в новом формате становится доступной для
скачивания. Такой сервис можно реализовать как монолитное приложение, все
компоненты которого входят в состав одного исполняемого файла, либо в виде
набора микросервисов, каждый из которых отвечает за решение своей задачи.
На рис. 12.3 показан упрощенный пример приложения для перекодирования
файлов, построенного на основе микросервисной архитектуры.
Пользователь этой системы загружает файл на API-сервер через интерфейс.
API-сервер помещает медиафайл в файловое хранилище и ставит задание на его
перекодирование в очередь сообщений. Микросервис-преобразователь получает
задание из очереди, обрабатывает файл, сохраняет его версию в новом формате
в файловом хранилище и добавляет в очередь новое задание — уведомить пользователя о завершении процесса. Затем пользователь может получить готовый
файл через интерфейс, который обращается к API-серверу, чтобы извлечь его
из хранилища.
12.2. Микросервисы и обеспечение их высокой доступности
347
Рис. 12.3. Простое приложение для перекодирования файлов
в виде набора микросервисов
В листинге 12.2 показано, как отдельный микросервис (в данном случае компонент, отвечающий за получение медиафайла) может быть реализован в виде
самодостаточного модуля, оставаясь частью более крупной системы. В этом
упрощенном примере мы извлекаем задание на обработку медиафайла из очереди сообщений Kafka и используем его для выполнения следующей операции.
Листинг 12.2. Пример микросервиса
package main
import (
"context"
"log"
"os"
"time"
)
kafka "github.com/segmentio/kafka-go"
func main() {
topic := "media"
kafkaHost := os.Getenv("KAFKA_HOST")
if kafkaHost == "" {
panic("KAFKA_HOST environment variable not set")
}
conn, err := kafka.DialLeader(
context.Background(), "tcp", kafkaHost, topic, 0,
)
Подключается
к экземпляру Kafka
по протоколу TCP
348
Глава 12. Облачные приложения и взаимодействие между сервисами
if err != nil {
panic(err)
}
Создает пакет для чтения
сообщений с указанием
допустимого диапазона
размеров
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
batch := conn.ReadBatch(10e3, 1e6)
message := make([]byte, 10e3) // максимум 10 Кбайт на сообщение
for {
n, err := batch.Read(message)
Обрабатывает (читает)
if err != nil {
сообщения
break
}
log.Println(string(message[:n]))
// извлечь сведения о загрузке, перекодировать медиа
// или передать другому сервису
Осуществляет следующий
}
этап выполнения задания
}
batch.Close()
conn.Close()
Последний этап данного процесса во многом зависит от архитектурного решения. Использование очередей сообщений означает, что взаимодействие
между компонентами может происходить исключительно через них. Однако
в нек оторых микросервисных архитектурах очереди применяются лишь
в качестве источника истины, например, для логирования и осуществления
повторных попыток, а передача заданий между этапами организована последовательно.
Каждый из таких микросервисов может быть реализован на своем языке программирования, использован в разных приложениях и даже предоставлен
как отдельная услуга. Например, хранилище файлов можно реализовать
в виде объектного хранилища, выступающего в качестве самодостаточного
SaaS-сервиса.
Масштабирование сервисов выполняется в зависимости от потребностей каждого
из них. Например, сервис перекодирования медиафайлов масштабируется вверх
или вниз по мере изменения объема данных, подлежащих обработке. API-сервер
и сервис уведомлений, в свою очередь, масштабируются иначе — в зависимости
от уровня нагрузки и потребности в ресурсах. При этом потребности сервисов
могут меняться ежечасно и даже ежеминутно, поэтому при проектировании
решений, способных эффективно адаптироваться к изменениям, важную роль
играет логирование.
Пользователи ожидают, что сервисы будут доступны постоянно. Эпоха «окон
технического обслуживания» ушла в прошлое. Неожиданные отключения подрывают доверие пользователей и могут привести к финансовым потерям. Одно
из преимуществ микросервисов — возможность обеспечить высокую доступность
каждого компонента с учетом его особенностей.
12.3. Взаимодействие между сервисами
349
12.3. Взаимодействие между сервисами
Одним из ключевых аспектов микросервисной архитектуры является организация
взаимодействия между микросервисами. Неэффективная коммуникация может
стать узким местом производительности приложения, особенно когда связи между
сервисами устанавливаются по схеме «один к одному» или «один ко многим».
В примере с перекодированием медиафайлов, представленном на рис. 12.3, четыре микросервиса взаимодействуют друг с другом при загрузке нового файла.
Если для обмена данными между ними используется REST и протокол TLS
(что вполне типично), значительная часть времени будет тратиться на сетевую
передачу данных.
Эффективность этих взаимодействий становится все более актуальной по мере
роста количества микросервисов. Крупные компании вроде Google, активно использующие микросервисную архитектуру, создают собственные, более быстрые
методы коммуникации между сервисами и даже сетевые уровни, превосходящие
по производительности рыночные решения. Одним из примеров является протокол gRPC, о котором шла речь в главе 11.
Вы тоже можете реализовать ускоренную коммуникацию в своих приложениях. Как вы увидите далее, сделать это не так уж и сложно. Мы расширим рассмотренные ранее примеры и продемонстрируем более эффективные способы
использования REST.
12.3.1. Ускорение REST-взаимодействия
REST остается самым распространенным способом взаимодействия в веб- и облачных сервисах. Хотя передача данных о состоянии представления через протокол HTTP применяется повсеместно, этот способ уступает альтернативным
протоколам в эффективности и скорости. Кроме того, большинство RESTфреймворков и подходов по умолчанию не оптимизированы для высокой производительности. Именно поэтому коммуникационный слой микросервисной
архитектуры часто оказывается первым кандидатом на ускорение.
Повторное использование соединений
Нередко каждый HTTP-запрос выполняется через отдельное соединение. Установление соединения требует времени — в том числе на согласование параметров
TLS для обеспечения безопасной передачи данных. Затем запускается механизм
медленного старта TCP, предназначенный для предотвращения перегрузки сети
и равномерного распределения трафика. На этом этапе передача даже одного
сообщения может потребовать нескольких раундов обмена данными между
клиентом и сервером.
Проблема. Когда каждый запрос передается по отдельному соединению,
значительная часть времени тратится на выполнение сетевых операций. Как
нам минимизировать эти потери?
350
Глава 12. Облачные приложения и взаимодействие между сервисами
Решение. Используйте соединения повторно. Через одно и то же соединение
можно отправить несколько HTTP-запросов. При этом его достаточно согласовать и настроить только один раз, после чего передача сообщений будет
происходить гораздо быстрее.
Обсуждение. Независимо от того, работаете ли вы с протоколом HTTP/2
(доступный в Go, начиная с версии 1.6) или более старой версией HTTP/1
и HTTP/1.1, возможность повторного использования соединений доступна.
Go по умолчанию стремится задействовать их вновь, однако некоторые антипаттерны проектирования приложения могут этому помешать.
Когда соединения используются снова, как показано на рис. 12.4, сокращается
время, затрачиваемое на их открытие и закрытие. После завершения фазы медленного старта передача сообщений ускоряется. Именно поэтому второй, третий
и четвертый запросы отправляются гораздо быстрее.
Рис. 12.4. Передача сообщений при повторном использовании соединения
и без него
Встроенный сервер из пакета net/http поддерживает механизм HTTP keep-alive.
Большинство систем также поддерживают механизм TCP keep-alive, позволяющий использовать одно и то же соединение для передачи разных запросов. Начиная с версии Go 1.6, пакет net/http предусматривает прозрачную поддержку
протокола HTTP/2, который обладает дополнительными преимуществами,
позволяющими ускорить взаимодействие между сервисами.
12.3. Взаимодействие между сервисами
351
ПРИМЕЧАНИЕ Важно различать HTTP keep-alive и TCP keep-alive. HTTP keepalive — это функция протокола HTTP, которую должен явно поддерживать веб-сервер.
В этом режиме сервер периодически проверяет соединение на наличие входящих
запросов в течение заданного временного интервала и закрывает его при отсутствии
активности. В свою очередь, TCP keep-alive реализуется на уровне операционной
системы в рамках TCP-соединений. Учтите, что параметр DisableKeepAlives в Go
отключает оба этих механизма.
На практике большинство проблем, мешающих повторному использованию
соединений, возникают на стороне клиентов, обращающихся к HTTP-серверам.
Наиболее распространенной из них является применение пользовательских
экземпляров http.Transport с отключенным механизмом keep-alive.
Когда вы задействуете стандартные функции пакета net/http , такие как
http.Get() или http.Post(), они используют клиент http.DefaultClient с включенным по умолчанию механизмом keep-alive и заданным интервалом 30 секунд.
Если приложение создает собственный клиент, но не задает для него транспорт,
применяется http.DefaultTransport. Этот же транспорт используется в http.
DefaultClient и по умолчанию инициализируется с включенным механизмом
keep-alive.
Передача данных без keep-alive встречается в приложениях с открытым исходным кодом, в примерах из Интернета и даже в официальной документации Go.
В частности, в этой документации содержится следующий пример:
tr := &http.Transport{
TLSClientConfig:
&tls.Config{RootCAs: pool},
DisableCompression: true,
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://example.com")
Здесь создается пользовательский экземпляр объекта http.Transport с измененным набором корневых сертификатов, отключенным сжатием и неактивированным механизмом keep-alive. Причин для отключения этого механизма может
быть множество: ограничение потребления ресурсов, необходимость передать
управление балансировщику нагрузки или защититься от DoS-атак. В листинге 12.3 приведен схожий пример, но с включенным механизмом keep-alive.
Эта конфигурация совпадает с настройками http.DefaultTransport.
Работа с http.Transport имеет одну особенность, которая сбивает с толку.
Установка свойства DisableKeepAlives в значение true отключает повторное
использование соединений. Но если установить его в false, это не гарантирует
их переиспользование, а лишь означает, что вы можете активировать механизмы
HTTP keep-alive и TCP keep-alive независимо друг от друга.
352
Глава 12. Облачные приложения и взаимодействие между сервисами
Листинг 12.3. П
ользовательский объект http.Transport, поддерживающий
повторное использование соединений
tr := &http.Transport{
TLSClientConfig:
DisableCompression:
Dial: (&net.Dialer{
Timeout:
30 *
KeepAlive: 30 *
}).Dial,
}
&tls.Config{RootCAs: pool},
true,
time.Second,
time.Second,
Функция Dial с настроенными
с параметрами KeepAlive
и Timeout
client := &http.Client{Transport: tr}
resp, err := client.Get("https://example.com")
Если у вас нет серьезных причин отключать механизм keep-alive (вроде перечисленных выше), лучше оставить его включенным, так как это дает значительный
прирост производительности при многократном обращении к одной и той же
конечной точке.
Еще одной причиной, препятствующей повторному использованию соединения,
может являться незакрытое тело ответа. До появления HTTP/2 механизмы
конвейеризации (pipelining) практически не применялись. Конвейеризация
позволяет передавать несколько запросов и соответствующих ответов параллельно, а не последовательно, как показано на рис. 12.5. До внедрения протокола HTTP/2, чтобы отправить следующий запрос, нужно было дождаться
получения ответа на предыдущий. Поэтому перед использованием соединения
для выполнения следующего запроса тело предыдущего ответа необходимо
было явно закрыть.
В листинге 12.4 показана типичная ситуация, в которой тело ответа не закрывается перед отправкой очередного HTTP-запроса.
Рис. 12.5. HTTP-конвейеризация и последовательные запросы
12.3. Взаимодействие между сервисами
353
Листинг 12.4. Незакрытое тело HTTP-ответа
func main() {
r, err := http.Get("http://example.com")
if err != nil {
Откладывает закрытие
// …
тела ответа до выхода
}
из функции main()
defer r.Body.Close()
o, err := ioutil.ReadAll(r.Body)
if err != nil {
// …
}
// использовать содержимое тела ответа
r2, err := http.Get("http://example.com/foo")
if err != nil {
// …
}
defer r2.Body.Close()
}
Отправляет HTTP-запрос
и получает ответ
Выполняет второй HTTP-запрос.
Поскольку тело первого ответа
еще не закрыто, необходимо
создать новое соединение
o, err = ioutil.ReadAll(r2.Body)
if err != nil {
// …
}
// …
В данном примере использование оператора defer для закрытия тела ответа
не является оптимальным решением. Вместо этого тело ответа следует закрывать
сразу после завершения работы с ним. В листинге 12.5 показан тот же пример,
но с закрытием тела ответа, позволяющим использовать соединение повторно.
Листинг 12.5. Использование и быстрое закрытие HTTP-ответа
func main() {
r, err := http.Get("http://example.com")
if err != nil {
// …
}
o, err := ioutil.ReadAll(r.Body)
if err != nil {
// …
}
r.Body.Close()
Отправляет HTTP-запрос
и получает ответ
Считывает тело ответа
в переменную и закрывает его
после завершения обработки
// использовать содержимое тела ответа
r2, err := http.Get("http://example.com/foo")
if err != nil {
// …
}
Выполняет следующий HTTP-запрос
через то же соединение, которое
использовалось для отправки
предыдущего
354
Глава 12. Облачные приложения и взаимодействие между сервисами
o, err = ioutil.ReadAll(r2.Body)
if err != nil {
// …
}
r2.Body.Close()
}
// …
Это на первый взгляд незначительное изменение может существенно повлиять
на сетевую коммуникацию и улучшить общую производительность приложения,
особенно при его масштабировании.
Ускорение маршалинга и демаршалинга данных JSON
В настоящее время большинство REST-взаимодействий предполагает обмен
данными в формате JSON. Для маршалинга и демаршалинга данных JSON
используется пакет encoding/json, который применяет механизм рефлексии,
или отражения, для определения типов и значений при выполнении каждого
вызова. Рефлексия, реализуемая пакетом reflect, требует дополнительного
времени на исследование типов и значений при обработке сообщения. При
многократной обработке одних и тех же структур значительная часть времени
уходит на рефлексию. Мы уже кратко касались этой темы, но более подробно
она будет рассмотрена в главе 13.
Проблема. Мы хотим избежать многократного определения типов при маршалинге и демаршалинге данных JSON.
Решение. Применить специальный пакет, способный сгенерировать код для
маршалинга и демаршалинга данных JSON. Такой код не использует ре
флексию и работает быстрее, потребляя меньше памяти.
Обсуждение. Рефлексия в Go работает достаточно быстро. Она требует выделения памяти, которую затем нужно освобождать сборщику мусора, и влечет
небольшие вычислительные издержки. Использование оптимизированного
сгенерированного кода позволяет сократить эти издержки и повысить производительность.
Существует несколько пакетов, предназначенных для решения данной задачи.
Один из них — github.com/ugorji/go/codec, который наряду с JSON поддерживает форматы Binc, MessagePack и CBOR (Concise Binary Object Representation —
компактное двоичное представление объектов). Хотя ни один из этих форматов
не получил такой популярности, как JSON, они являются вполне эффективной
его альтернативой. Пример реализации пакета codec показан в листинге 12.6.
Структура с аннотациями для codec выглядит почти так же, как и для json.
Единственное отличие заключается в имени codec. Чтобы сгенерировать код,
необходимо установить инструмент codecgen. Это делается с помощью команды:
$ go get -u github.com/ugorji/go/codec/codecgen
12.3. Взаимодействие между сервисами
355
Листинг 12.6. Структура с аннотациями для пакета codec
Комментарий для команды go:generate,
показывающий, как сгенерировать код из этого файла
//go:generate codecgen -o user_generated.go user.go
package user
type User struct {
Name string `codec:"name"`
Email string `codec:",omitempty"`
}
Эта аннотация codec пропустит поле
Email при генерации JSON, если его
значение окажется пустым
Пакет codec не умеет
генерировать код для
пакета main. В данном
случае пользовательская
функциональность
реализована в пакете user
Структура User аннотирована
для codec, а не для json
Свойство Name будет сериализовано как name
в JSON-файле. Отличие только в регистре
первой буквы
После установки codecgen вы можете использовать этот инструмент для генерации кода на основе файла user.go, выполнив следующую команду:
$ codecgen -o user_generated.go user.go
В результате будет создан файл user_generated.go. В нем вы увидите, что к типу
User добавлены два публичных метода: CodecEncodeSelf и CodecDecodeSelf. Если
они присутствуют, пакет codec будет использовать их для кодирования и декодирования типа. Если же их нет, пакет codec задействует механизм рефлексии
во время выполнения.
После установки инструмента codecgen его можно интегрировать с утилитой
go generate. Она распознает первую строку комментария в файле (если она
оформлена в специальном формате) и запускает codecgen автоматически. Чтобы
использовать go generate, выполните следующую команду:
$ go generate ./...
ПРИМЕЧАНИЕ
в главе 13.
Генераторы и механизм рефлексии подробно рассматриваются
После подготовки типа User операции кодирования и декодирования можно
будет интегрировать в приложение, как показано в листинге 12.7.
Листинг 12.7. Кодирование экземпляра в JSON с помощью пакета codec
jh := new(codec.JsonHandle)
Создает новый обработчик JSON для кодировщика.
Пакет codec имеет отдельный обработчик для каждого
из поддерживаемых типов
u := &user.User{
Создает экземпляр
Name: "Inigo Montoya",
структуры User
Email: "inigo@montoya.example.com",
и заполняет его
}
данными
Создает срез байтов для хранения
var out []byte
результата — данных JSON,
сгенерированных на основе
экземпляра User
356
Глава 12. Облачные приложения и взаимодействие между сервисами
err := codec.NewEncoderBytes(&out, jh).Encode(&u)
if err != nil {
// …
Преобразует срез
}
байтов в строку
и выводит ее на экран
fmt.Println(string(out))
Кодирует экземпляр User в JSON
с помощью JSON-обработчика.
Пакет codec выполняет эту
операцию в два этапа, которые
здесь объединены
Вот как выглядит результат выполнения этого кода:
{"name":"Inigo Montoya","Email":"inigo@montoya.example.com"}
Обратите внимание, что ключ name начинается со строчной буквы, а Email — с заглавной. В типе User, определенном в листинге 12.6, имена полей обозначаются
с заглавной буквы, что напрямую отражается в соответствующих ключах JSON.
Однако для поля Name задан пользовательский ключ name, который и используется в данном случае.
Срез байтов с JSON-данными, созданный в листинге 12.7, можно декодировать
обратно в экземпляр структуры User, как показано в листинге 12.8.
Листинг 12.8. Декодирование данных JSON в экземпляр типа
Создает переменную для хранения
декодированных данных JSON
var u2 user.User
err = codec.NewDecoderBytes(out, jh).Decode(&u2)
if err != nil {
// …
}
Выводит заполненный
экземпляр User на экран
fmt.Println(u2)
Декодирует данные JSON
с помощью JSON-обработчика,
созданного в листинге 12.6,
и записывает результат в новый
экземпляр User. Декодер
выполняет эту операцию
в два этапа, которые можно
объединить. Обработчик JSON
можно использовать повторно
Хотя API пакета github.com/ugorji/go/codec отличается от пакета encoding/
json, входящего в стандартную библиотеку, он достаточно прост для повсе
дневного использования.
Генерация кода — это довольно распространенный подход к решению поставленной задачи, но, как видите, она добавляет дополнительные этапы в рабочий
процесс. Если вы ищете решение, более соответствующее духу стандартной
библиотеки, стоит обратить внимание на Sonic — инструмент от компании
Bytedance (разработчика TikTok). Инженеры компании заметили, что значительная часть времени уходит на обработку JSON, и выявили аномалии
в производительности в зависимости от размера входных данных. Если в двух
словах, то их подход основан на использовании JIT-компилятора (just-intime), который служит полноценной заменой стандартного пакета и способен
обеспечить серьезный прирост производительности при работе с большими
объемами JSON-данных. Причем приложение не требует доработки, достаточно
просто подключить пакет:
$ go get github.com/bytedance/sonic
12.4. Запуск приложений на облачных серверах
357
Дальнейшая реализация выглядит вполне ожидаемым образом. В листинге 12.9
показано, как можно обработать данные JSON из листингов 12.7 и 12.8.
Листинг 12.9. Декодирование данных JSON с помощью инструмента Sonic
package main
import (
"log"
"strings"
)
"github.com/bytedance/sonic"
Импортирует
пакет sonic
func main() {
var receiver = []map[string]interface{}
[
]
`)
Создает приемник данных,
аналогичный структуре,
используемой при стандартной
сериализации
jsonData := strings.NewReader(`
{ "email": "inigo@montoya.example.com", "name": "Inigo Montoya" },
{ "email": "fezzik@example.com", "name": "Fezzik" }
decoder := sonic.ConfigDefault.NewDecoder(jsonData)
decoder.Decode(&receiver)
Декодирует данные
}
for k := range receiver {
log.Println(receiver[k])
}
Создает декодер на основе
наших JSON-данных
в структуру
Перебирает элементы структуры и отправляет
значения в стандартный поток вывода
Однако у подобных пакетов есть некоторые ограничения. В частности, поддержка сборки ограничена определенными типами процессоров, например, при
компиляции на чипах Apple серии M производительность будет ниже.
12.4. Запуск приложений на облачных серверах
При разработке приложений для запуска в облаке в одних случаях вы можете
точно знать, в каком окружении они будут работать, а в других — иметь лишь
ограниченную информацию об этом из-за особенностей реализации виртуализации на стороне облачного провайдера. Создание приложений, устойчивых
к подобной неопределенности, позволяет быстрее выявлять и устранять возникающие проблемы.
Кроме того, приложение может разрабатываться в одной операционной системе
и архитектуре, а запускаться — в другой. Например, вы вполне можете создавать
приложение в среде Windows или macOS, а запускать его в рабочей среде на базе
Linux. В этом разделе мы поговорим о том, как избежать проблем, вызванных
излишней уверенностью в характеристиках среды выполнения.
358
Глава 12. Облачные приложения и взаимодействие между сервисами
12.4.1. Определение среды во время выполнения
Обычно предпочтительнее определять характеристики окружения во время
выполнения, а не фиксировать их в коде. Поскольку Go-приложения взаимодействуют с ядром операционной системы, важно понимать: независимо от того,
работаете ли вы в Linux, Windows или другой ОС, многие параметры, выходящие за рамки сведений о самом ядре, можно определить во время выполнения
программы. Это позволяет запускать Go-приложение в Red Hat Linux, Ubuntu
или любой другой поддерживаемой системе. Кроме того, приложение может
сообщить об отсутствии необходимых зависимостей, что значительно упрощает
диагностику и устранение проблем.
Сбор данных о хосте
Облачные приложения могут запускаться в самых разных средах, включая среду разработки, тестирования и эксплуатации. Они могут масштабироваться по
горизонтали, использовать множество динамически создаваемых экземпляров
и работать сразу в нескольких дата-центрах. В таких условиях трудно заранее
описать или жестко задать все параметры окружения через конфигурацию.
Вместо этого их можно определить на уровне самого приложения.
Проблема. Как получить информацию о хосте внутри Go-приложения?
Решение. Использовать пакет os, который предоставляет доступ к данным
о базовой системе. Эти сведения можно дополнить сведениями, полученными через другие пакеты, такие как net, или путем обращения к внешним
приложениям.
Обсуждение. Пакет os позволяет получить множество параметров среды
выполнения. Вот некоторые примеры:
os.Hostname() возвращает имя хоста, заданное в ядре ОС;
os.Getpid() позволяет получить идентификатор текущего процесса;
разные операционные системы используют разные символы-разделители в путях и списках путей. Использование os.PathSeparator
и os.PathListSeparator вместо жестко заданных символов предоставляет
приложению возможность корректно работать в текущей среде;
os.Getwd() позволяет получить текущий рабочий каталог.
Информацию, полученную с помощью пакета os, можно комбинировать с другими сведениями, чтобы получить более полное представление о хосте. Если
попытаться получить список всех IP-адресов, связанных с сетевыми интерфейсами машины, на которой работает приложение, он окажется довольно длинным.
В него войдут локальный адрес для обратной связи (loopback), а также адреса
IPv4 и IPv6, даже если некоторые из них фактически недоступны для маршрутизации из внешней сети. Чтобы получить пригодный для использования
IP-адрес, приложение может определить имя хоста, известное системе, а затем
12.4. Запуск приложений на облачных серверах
359
найти IP-адрес, связанный с данным именем. В листинге 12.10 демонстрируется
применение этого подхода.
Листинг 12.10. Получение IP-адресов хоста по его имени
func main() {
name, err := os.Hostname()
if err != nil {
fmt.Println(err)
return
}
Извлекает имя хоста,
заданное в ядре ОС
addrs, err := net.LookupHost(name)
if err != nil {
fmt.Println(err)
return
}
}
for _, a := range addrs {
fmt.Println(a)
}
Выполняет поиск IP-адресов,
связанных с этим именем хоста
Выводит каждый из найденных
IP-адресов на экран
Система знает имя собственного хоста, и при обращении к нему вернет локальный адрес. Это особенно полезно для приложений, запускаемых в различных
средах или масштабируемых по горизонтали. Имя хоста и связанный с ним адрес
могут меняться или сильно варьироваться.
Приложения на Go можно компилировать под разные операционные системы
и запускать в самых разных средах. Возможность определять характеристики
среды во время выполнения вместо того, чтобы полагаться на предположения,
помогает избежать ошибок и непредвиденных ситуаций.
Обнаружение зависимостей
Помимо взаимодействия с ядром ОС, Go-приложения могут вызывать другие
программы, установленные в системе. Обычно это делается с помощью пакета
os/exec из стандартной библиотеки. Но что произойдет, если вызываемая программа не установлена? В таком случае ОС вернет неясное сообщение об ошибке.
Предположение о том, что зависимость точно присутствует, может привести
к неожиданному поведению. А если ошибка никак не фиксируется, ее будет
крайне трудно отследить.
Проблема. Как убедиться в доступности внешней программы до ее вызова?
Решение. Перед первым вызовом этой программы удостоверьтесь в том, что
она установлена и доступна для запуска. Если она отсутствует, зафиксируйте
это в журнале, чтобы упростить отладку.
Обсуждение. Go-приложения могут выполняться в разных операционных
системах. Например, скомпилированное под Linux приложение способно
360
Глава 12. Облачные приложения и взаимодействие между сервисами
запускаться в одном из множества дистрибутивов с разным набором установленных программ. Если ваше приложение полагается на внешнюю программу, учтите, что она может как присутствовать в системе, так и отсутствовать.
Ситуация осложняется тем, что в облачной среде используется множество
различных дистрибутивов. Некоторые специализированные облачные версии
Linux имеют минимальный размер и содержат крайне ограниченное число
утилит, а иногда вовсе обходятся без них.
Поэтому, если облачное приложение зависит от наличия внешней программы,
оно должно явно проверить эту зависимость и в случае ее отсутствия зафиксировать данный факт в журнале. Это довольно легко реализуется с помощью
пакета os/exec. В листинге 12.11 показана функция, предназначенная для выполнения такой проверки.
Листинг 12.11. Функция для проверки доступности приложения
func checkDep(name string) error {
if _, err := exec.LookPath(name); err != nil {
es := "Could not find '%s' in PATH: %s"
return fmt.Errorf(es, name, err)
}
Возвращает значение nil,
return nil
если ошибка не возникла
}
Проверяет наличие переданной
зависимости в одном из путей,
указанных в переменной PATH. Если
она отсутствует, генерирует ошибку
Возвращает ошибку,
если зависимость
не найдена
Эту функцию можно использовать в ходе выполнения приложения, чтобы
проверять наличие зависимости. Следующий фрагмент кода показывает, как
вызвать эту функцию и обработать возможную ошибку:
err := checkDep("fortune")
if err != nil {
log.Fatalln(err)
}
fmt.Println("Time to get your fortune")
В данном примере ошибка регистрируется в журнале, если зависимость не установлена. Однако логирование — это не единственный возможный способ
реагирования. Иногда можно прибегнуть к запасному методу получения отсутствующей зависимости или воспользоваться ее альтернативной. В каких-то
случаях отсутствие зависимости критично для приложения, а в каких-то приводит к пропуску определенного действия. Зная о том, что чего-то не хватает,
вы можете соответствующим образом отреагировать на ситуацию.
Часть проблем можно предотвратить еще на этапе контейнеризации. Например, в процессе сборки Docker-образа следует попытаться собрать и установить
все необходимые зависимости. Но даже это не гарантирует работоспособность
образа, поэтому встраивание подобных проверок непосредственно в облачное
приложение обеспечивает дополнительную страховку.
12.4. Запуск приложений на облачных серверах
361
12.4.2. Сборка приложения для облачной среды
У облачных сред нет единой аппаратной архитектуры или операционной системы. Вы можете написать приложение для архитектуры AMD64 под управлением
Windows, а затем столкнуться с необходимостью запускать его на ARM8 и одном
из дистрибутивов Linux. При разработке облачных решений важно изначально
учитывать поддержку разных сред. Лучше всего реализовать это на этапе проектирования, и стандартная библиотека Go поможет вам.
Кроме того, в сфере облачных вычислений нередко бывает так, что Go-приложение
создается под ОС Windows или macOS, а работать должно в среде под управлением Linux. Или же требуется распространять одну и ту же программу через
облако в версиях для Windows, macOS и Linux. Часто программа пишется под
одну ОС, а эксплуатируется в совершенно другой.
Проблема. Как скомпилировать приложение под архитектуры и операционные системы, отличные от текущих?
Решение. Инструментарий go позволяет выполнять кросс-компиляцию под
другие архитектуры и операционные системы. Помимо средств самого языка, можно использовать стороннюю утилиту gox, позволяющую выполнять
кросс-компиляцию нескольких исполняемых файлов параллельно. Кроме
того, вместо жестко заданных значений вроде POSIX-разделителя путей /
для учета различий между операционными системами можно задействовать
такие пакеты, как filepath.
Обсуждение. Компилятор, входящий в состав инструментария Go, поддерживает кросс-компиляцию «из коробки». Для ее выполнения нужно задать
переменные окружения GOARCH и GOOS, указывающие соответствующую архитектуру и операционную систему. Переменная GOARCH определяет аппаратную
архитектуру (например, amd64, 386 или arm), а GOOS — операционную систему
(windows, linux, darwin или freebsd).
Вот пример быстрой сборки:
$ GOOS=windows GOARCH=386 go build
Эта команда приказывает Go собрать исполняемый файл для Windows под
архитектуру 386. В результате будет создан файл в формате PE32 (Portable
Executable 32-bit) для MS Windows (консольное приложение), предназначенный
для 32-битных процессоров Intel 80386.
ВНИМАНИЕ Если ваше приложение использует cgo для взаимодействия с C-библио
теками, при кросс-компиляции могут возникнуть сложности. Обязательно тестируйте
такие приложения на целевых платформах.
Если вы хотите собрать приложение для нескольких операционных систем и архитектур, можно воспользоваться уже упомянутым инструментом gox, который
362
Глава 12. Облачные приложения и взаимодействие между сервисами
позволяет компилировать несколько исполняемых файлов в конкурентном
режиме (рис. 12.6).
Рис. 12.6. Инструмент gox параллельно собирает исполняемые файлы
для различных ОС и архитектур в конкурентном режиме
Установить gox можно с помощью следующей команды:
$ go get -u github.com/mitchellh/gox
После этого вы сможете собирать исполняемые файлы параллельно, используя команду gox, которая по своему синтаксису напоминает go build. Главное
преимущество gox заключается в поддержке параллельной сборки. Если такой
функционал не нужен, достаточно стандартного инструментария Go. В листинге 12.12 показан пример сборки приложения для macOS, Windows и Linux
и архитектур AMD64 и 386.
Листинг 12.12. Кросс-компиляция приложения с помощью gox
Флаг -os позволяет указать
несколько операционных систем
Флаг -arch используется для указания
одной или нескольких архитектур
$ gox \
-os="linux darwin windows" \
-arch="amd64 386" \
-output="dist/{{.OS}}-{{.Arch}}/{{.Dir}}" .
Шаблон задает путь для вывода.
Благодаря этому одноименные
исполняемые файлы
помещаются в разные, уникально
идентифицируемые каталоги
При сборке исполняемых файлов под другие операционные системы (особенно
если они будут работать в облаке) рекомендуется протестировать результат
перед развертыванием, чтобы выявить ошибки, связанные со средой, до запуска
в целевой системе. Такие проверки можно реализовать с помощью нескольких
версий Dockerfile, но локальная компиляция, безусловно, быстрее и требует
меньше ресурсов.
12.4. Запуск приложений на облачных серверах
363
Помимо компиляции под разные среды, различия между операционными системами нужно учитывать и в самом приложении. В стандартной библиотеке
Go для этого предусмотрены два механизма.
Во-первых, многие пакеты предоставляют единый интерфейс, абстрагирующий
зависимые от платформы детали реализации. Ярким примером является различие в разделителях путей и их списков: в Linux и других POSIX-системах
применяются символы / и :, а в Windows — \ и ; соответственно. Вместо того
чтобы жестко задавать такие значения, используйте пакет path/filepath, чтобы обеспечить корректную обработку путей в разных операционных системах.
Он предоставляет, в частности, следующие возможности:
filepath.Separator и filepath.ListSeparator — возвращают корректные
значения разделителей пути и списка путей, используемые в той операционной системе, под которую скомпилировано приложение;
filepath.ToSlash — преобразует строку, содержащую путь, заменяя разде-
лители на соответствующие текущей ОС;
filepath.Split и filepath.SplitList — разбивают путь на составные части
или разделяют список путей на отдельные пути (при этом автоматически
используются корректные разделители);
filepath.Join — объединяет список фрагментов пути в один путь, применяя
корректный для целевой ОС разделитель.
Во-вторых, инструментарий Go поддерживает теги сборки (build tags), позволяющие включать или исключать файлы из сборки в зависимости от таких
параметров окружения, как операционная система или архитектура. Тег сборки
размещается в начале файла и выглядит следующим образом:
// +build !windows
Этот специальный комментарий указывает компилятору пропустить данный
файл при сборке приложения под Windows. Теги сборки (или константы сборки) могут принимать несколько значений. Следующий пример показывает, как
исключить файл из сборки под Linux и macOS:
// +build !linux,!darwin
За тегом обязательно должен следовать символ новой строки. Значения тегов
соотносятся с параметрами GOOS и GOARCH.
В Go также предусмотрен механизм условной компиляции, способный определять целевую платформу с помощью специальных суффиксов в именах
файлов. Например, файл foo_windows.go будет использован при сборке приложения для Windows, а foo_386.go — при сборке под архитектуру 386 (также
называемую x86). Этот механизм позволяет создавать кросс-платформенные
приложения, учитывающие специфику различных операционных систем и архи
тектур, а также использующие их уникальные особенности.
364
Глава 12. Облачные приложения и взаимодействие между сервисами
12.5. Мониторинг среды выполнения
Мониторинг играет ключевую роль при эксплуатации приложений в рабочей
среде. Обычно к нему прибегают для выявления неполадок, определения момента,
когда нагрузка достигает уровня, требующего масштабирования, или для анализа
внутренних процессов приложения с целью повышения его производительности.
Проще всего организовать такой мониторинг через логирование ошибок, проблем и других важных событий. Подсистема логирования может записывать
эти данные на диск, откуда их будет считывать другое приложение, либо сразу
передавать их в программу мониторинга.
Приложения на Go включают не только прикладной код и сторонние библиотеки,
но и среду выполнения Go (Go runtime), которая управляет конкурентностью, сборкой мусора, потоками и другими аспектами работы программы в фоновом режиме.
Среда выполнения имеет доступ к обширному набору сведений, включая число
процессоров, доступных приложению, текущее количество запущенных горутин,
сведения о выделении и использовании памяти, работе сборщика мусора и многом другом. Эти данные могут пригодиться для диагностики проблем в приложении и инициирования таких действий, как горизонтальное масштабирование.
Как и работу с файлами, процесс логирования можно унифицировать, настроив
прямую отправку журналов в соответствующий сервис облачного провайдера.
Проблема. Мы хотим организовать мониторинг среды выполнения Go и соответствующее логирование.
Решение. Пакеты runtime и runtime/debug предоставляют доступ ко внутренним данным среды выполнения. Эти пакеты можно использовать для
регулярного сбора сведений и их последующей записи в журнал или передачи
в систему мониторинга.
Обсуждение. Представьте, что после обновления сторонней библиотеки
в ней появилась ошибка, мешающая завершению созданных ею горутин.
Со временем их становится все больше, из-за чего вместо сотен горутин среда
выполнения вынуждена обслуживать миллионы. Мониторинг состояния
среды выполнения позволяет выявлять такие ситуации.
При запуске приложения можно создать вспомогательную горутину, которая
будет отслеживать параметры среды выполнения и записывать сведения об использовании ресурсов в журнал. Решение такой задачи в отдельной горутине
позволяет выполнять мониторинг и логирование параллельно с основным кодом
приложения, как показано в листинге 12.13.
Следует учитывать, что вызов runtime.ReadMemStats на короткое время приостанавливает выполнение программы, что может негативно сказаться на ее
производительности. Поэтому подобные операции следует выполнять нечасто,
например только при отладке.
12.5. Мониторинг среды выполнения
365
Листинг 12.13. Мониторинг среды выполнения приложения
Функция для мониторинга
среды выполнения
func monitorRuntime() {
log.Println("Number of CPUs:", runtime.NumCPU())
m := &runtime.MemStats{}
Бесконечный цикл с паузой
10 секунд между итерациями
for {
r := runtime.NumGoroutine()
log.Println("Number of goroutines", r)
runtime.ReadMemStats(m)
log.Println("Allocated memory", m.Alloc)
}
}
time.Sleep(10 * time.Second)
func main() {
go monitorRuntime()
}
Сообщает количество
доступных процессоров
при запуске функции
мониторинга
Фиксирует количество
горутин и объем
выделенной памяти
Бесконечный цикл с паузой
10 секунд между итерациями
Начинает мониторинг работы
приложения при его запуске
i := 0
for i < 40 {
go func() {
time.Sleep(15 * time.Second)
}()
i = i + 1
time.Sleep(1 * time.Second)
}
Создает демонстрационные
горутины и занимает память
в течение 40 секунд
Такая организация мониторинга среды выполнения позволяет заменить
процесс логирования на взаимодействие с внешней системой мониторинга.
При использовании стороннего сервиса можно отправлять данные о работе
приложения через его API или специализированную библиотеку. Пакет
runtime предоставляет большое количество ценной информации, включая:
сведения о сборке мусора, а именно время последнего прохода, объем кучи,
при котором запустится следующий проход, продолжительность последнего
прохода и многое другое;
статистику по куче, то есть число объектов, размер кучи, объем занятой
памяти и т. д.;
количество активных горутин, процессоров и вызовов cgo.
Практика показывает, что мониторинг среды выполнения помогает выявлять
неожиданные закономерности, обнаруживать ошибки, проблемы с горутинами, утечки памяти и другие сбои. В следующей главе мы рассмотрим способы
организации взаимодействия между облачными сервисами, не требующие использования REST API.
366
Глава 12. Облачные приложения и взаимодействие между сервисами
Резюме
Мы можем работать с различными облачными провайдерами, не впадая в зависимость от кого-либо из них.
Приложения на Go можно контейнеризировать, разделяя этапы сборки
и выполнения.
У нас есть возможность получить достоверную информацию о хосте вместо
того, чтобы делать предположения о нем.
Приложения можно компилировать под разные операционные системы, избегая зависимости от конкретной платформы.
Повторное использование сетевых соединений повышает производительность за счет исключения многократного запуска алгоритма TCP Slow Start
и согласования параметров.
Если маршалинг и демаршалинг JSON становятся узким местом, для ускорения обработки таких данных стоит воспользоваться сторонними библио
теками.
Мониторинг среды выполнения Go позволяет выявлять проблемы и собирать
сведения о работе приложения.
13
Рефлексия, генерация
кода и продвинутые
возможности Go
В этой главе
3 Использование значений, типов и видов из системы рефлексии Go.
3 Парсинг пользовательских аннотаций в структурах.
3 Написание генераторов кода для использования
с инструментом go generate.
3 Взаимодействие с кодом на других языках.
Глава 12 была посвящена развертыванию Go-приложений в облачной среде,
организации взаимодействия между сервисами и их обнаружению. В этой главе
мы сосредоточимся на некоторых продвинутых возможностях Go. Прежде всего
обсудим систему рефлексии — механизм, позволяющий программе исследовать
собственную структуру во время выполнения. Хотя система рефлексии в Go
менее универсальна, чем, например, в Java, она все же остается весьма мощной.
Еще одна возможность, получившая активное применение, — это аннотирование
структур. В разделе 13.2 вы научитесь создавать собственные теги для полей
структур. Несмотря на полезность механизма рефлексии, в некоторых случаях лучше избегать сложной и ресурсоемкой обработки во время выполнения
и вместо этого писать код, который сам генерирует код, о чем мы поговорим
в разделе 13.3. Наконец, хотя в Go теперь поддерживаются обобщения, генерация кода продолжает использоваться в ходе разработки ПО, и мы сравним эти
два подхода.
368
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
13.1. Три составляющие системы рефлексии
Разработчики прибегают к рефлексии для анализа объектов во время выполнения
программы. При работе с таким строго типизированным языком, как Go, вам
может потребоваться, например, выяснить, реализует ли объект конкретный
интерфейс, определить его базовый вид или обойти все его поля, чтобы изменить содержащиеся в них данные. Выполнение подобных задач требует более
глубокого изучения типа и его возможностей.
Инструменты для работы с рефлексией в Go содержатся в пакете reflect ,
с которым вы уже встречались в предыдущих главах. Прежде чем использовать
эти средства, важно разобраться с несколькими ключевыми понятиями. Тремя
важнейшими компонентами механизма рефлексии Go являются значения, типы
и виды.
Чтобы понять суть термина значение (value), подумайте о переменной и хранящихся в ней данных. Переменная — это имя, указывающее на определенный
фрагмент информации (рис. 13.1). В зависимости от типа переменной, ее значением может быть nil, указатель (то есть ссылка на другой объект) или некие
данные. Например, в случае x := 5 значением переменной x будет 5. Для var
b bytes.Buffer значение b — это пустой буфер, а для myFunc := strings.Split
значением myFunc является функция. В пакете reflect для представления таких
значений используется тип reflect.Value.
Рис. 13.1. Объявления переменных и их значений
Поскольку Go — это строго типизированный язык, каждое значение в нем
имеет только один четко определенный тип. В выражении var b bytes.Buffer
этим типом будет bytes.Buffer. Мы можем определить тип любого значения,
представленного с помощью reflect.Value. Сведения о типе доступны через
интерфейс reflect.Type.
Наконец, в Go определен набор таких примитивных видов или категорий
(kind), как struct (структура), ptr (указатель), int (целое число), float64
(число с плавающей точкой), string (строка), slice (срез), func (функция)
и др. В пакете reflect все возможные виды представлены значениями типа
reflect.Kind. (Например, значение типа string на рис. 13.1 также относится
к виду string.)
13.1. Три составляющие системы рефлексии
369
Как правило, работа с механизмом рефлексии опирается на эти три понятия.
Обычно процесс начинается с получения значения и анализа его содержимого,
типа и вида. На основе этой информации можно составить более полное представление о типе значения.
13.1.1. Переключение логики на основе типа и вида
Одним из наиболее частых вариантов применения механизма рефлексии является определение типа или вида значения. Для получения этой информации
в Go предусмотрено несколько инструментов.
Проблема. Нужно написать функцию, которая принимает обобщенные значения (interface{}) и выполняет над ними определенные операции с учетом
их типов.
Решение. Go предлагает разные методы решения этой задачи — от переключения логики на основе типа до использования типов reflect.Type
и reflect.Kind. У каждого подхода свои особенности. Сначала мы обсудим
переключение логики на основе типа, а затем применим пакет reflect для
ее переключения на основе вида.
Обсуждение. Допустим, вы хотите написать функцию с сигнатурой sum(...
interface{}) float64, которая должна принимать аргументы произвольных
типов, преобразовывать их в тип float64 и суммировать полученные значения.
Наиболее удобным средством Go для решения подобных задач является переключатель логики на основе типа. Эта разновидность оператора switch позволяет
выполнять над значением разные операции в зависимости от его типа, а не содержимого. В стандартных пакетах Go переключатель на основе типа применяется довольно часто (в отличие от переключателя на основе вида, о котором
мы поговорим чуть позже). Переключение на основе типа нередко используется
внутри обобщенных функций по вполне понятным причинам. Для начала давайте
рассмотрим простой (хотя и неполный) пример, приведенный в листинге 13.1.
Листинг 13.1. С
уммирование значений с переключением логики
на основе типа
package main
import (
"fmt"
"strconv"
)
func main() {
var a uint8 = 2
var b int = 37
var c string = "3.2"
370
}
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
res := sum(a, b, c)
fmt.Printf("Result: %f\n", res)
Складывает значения
типов uint8, int и string
func sum(v ...interface{}) float64 {
var res float64 = 0.0
for _, val := range v {
Перебирает все переданные значения и выполняет
switch val.(type) {
переключение логики в зависимости от их типа
case int:
res += float64(val.(int))
Преобразует значения поддерживаемых
case int64:
типов (int, int64, uint8, string)
res += float64(val.(int64))
в тип float64 и суммирует их
case uint8:
}
res += float64(val.(uint8))
case string:
a, err := strconv.ParseFloat(val.(string), 64)
Для преобразования
if err != nil {
строк в значения
panic(err)
float64 используется
}
библиотека strconv
res += a
default:
fmt.Printf("Unsupported type %T. Ignoring.\n", val)
}
Если тип значения не входит в список
}
поддерживаемых, пропускает его
return res
и выводит сообщение об ошибке
Результатом выполнения кода будет строка Result: 42.200000. Этот пример демонстрирует простейшее использование переключателя логики на основе типа
значения, а также одно из его ограничений по сравнению с обычным оператором
switch. При наличии большего количества нечисловых типов мы могли бы реа
лизовать обобщенные методы для их обработки.
type myString string
func (s myString) ToFloat() (float64, error) {
fl, err := strconv.ParseFloat(string(s), 64)
if err != nil {
panic(err)
}
return fl, nil
}
В рамках стандартной конструкции switch можно объединить несколько значений в одном выражении case, например: case 1, 2, 3: println("Less than
four"). Однако такое объединение усложняет присваивание значений, поэтому
при переключении логики на основе типа каждый тип обычно указывается на
отдельной строке. Чтобы обеспечить поддержку всех целочисленных типов (int,
int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64), вам придется создать десять отдельных ветвей case. Несмотря на кажущуюся громоздкость, это
не является серьезной проблемой. Однако важно помнить, что переключатель
13.1. Три составляющие системы рефлексии
371
логики на основе типов работает именно с типами, а не с видами. А теперь дополним предыдущий пример пользовательским типом (листинг 13.2).
Листинг 13.2. Переключатель с пользовательским типом
package main
import (
"fmt"
"strconv"
)
type MyInt int64
MyInt относится
к типу int64
func main() {
Создает переменную
// …
типа MyInt со значением 1
var d MyInt = 1
res := sum(a, b, c, d)
fmt.Printf("Result: %f\n", res)
}
func sum(v ...interface{}) float64 {
var res float64 = 0
for _, val := range v {
switch val.(type) {
case int:
res += float64(val.(int))
case int64:
Эта ветвь не сработает
res += float64(val.(int64)) для значения типа MyInt
case uint8:
res += float64(val.(uint8))
case string:
a, err := strconv.ParseFloat(val.(string), 64)
if err != nil {
panic(err)
}
Значение типа MyInt будет
res += a
обработано в этом блоке
default:
fmt.Printf("Unsupported type %T. Ignoring.\n", val)
}
}
return res
}
Запуск программы дает следующий результат:
$ go run typekind.go
Unsupported type main.MyInt. Ignoring.
Result: 42.200000
Типом переменной var d MyInt является не int64, а MyInt, несмотря на то что
он основан на int64. В конструкции, переключающей логику на основе типа,
такое значение будет соответствовать ветке default, а не case int64. Иногда
372
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
требуется, чтобы MyInt воспринимался в качестве типа, отличного от int64.
Однако в данном случае было бы лучше, если бы функция sum() определяла
базовый вид значения и выбирала метод обработки на его основе.
Для решения этой проблемы следует использовать пакет reflect и выполнять
переключение логики на основе вида, а не типа. Начальная часть примера
останется прежней, но реализация функции sum() изменится, как показано
в листинге 13.3.
Листинг 13.3. Переключатель логики на основе вида
package main
import (
"fmt"
"reflect"
"strconv"
)
type MyInt int64
func main() {
var a uint8 = 2
var b int = 37
var c string = "3.2"
var d MyInt = 1
}
res := sum(a, b, c, d)
fmt.Printf("Result: %f\n", res)
Получает значение типа reflect.Value
исследуемой переменной
func sum(v ...interface{}) float64 {
На основе этого значения можно выполнять
var res float64 = 0
переключение с помощью функции Kind()
for _, val := range v {
ref := reflect.ValueOf(val)
Это обычный тип, поэтому мы можем указать
switch ref.Kind() {
несколько значений в одной ветке case
case reflect.Int, reflect.Int64:
res += float64(ref.Int())
case reflect.Uint8:
Тип reflect.Value предоставляет удобные методы
res += float64(ref.Uint())
для приведения родственных подтипов к версии
с наибольшей разрядностью (например, int, int8,
case reflect.String:
int16 и int32 — к int64)
a, err := strconv.ParseFloat
(ref.String(), 64)
if err != nil {
panic(err)
}
res += a
default:
fmt.Printf("Unsupported type %T. Ignoring.\n", val)
}
}
return res
}
13.1. Три составляющие системы рефлексии
373
В этой версии кода переключатель логики на основе типа заменен обычным
переключателем на основе значения, а для получения сведений о каждом значении interface{} используется пакет reflect. С его помощью можно получить
объект reflect.Value, описывающий значение. Одним из свойств, доступных
через reflect.Value, является базовый вид значения.
Кроме того, тип reflect.Value предоставляет набор функций, позволяющих приводить родственные типы к их версии с наибольшей разрядностью. Например,
значение reflect.Value с типом uint8 или uint16 легко привести к наибольшему
беззнаковому целочисленному типу с помощью метода Uint().
Благодаря этим возможностям можно заменить громоздкий переключатель
логики на основе типа компактным переключателем на основе вида. Вместо
десяти отдельных веток case, предусмотренных для разных целочисленных
типов, достаточно всего двух: одной — для всех знаковых и одной — для всех
беззнаковых целых чисел.
Однако помните: типы и виды — это разные понятия. В обоих рассмотренных
примерах код выполняет одну и ту же задачу. Для суммирования числовых
значений удобнее использовать переключатель на основе вида. Но иногда куда
важнее учитывать конкретный тип. Как было показано, переключатели логики на
основе типа отлично подходят для обработки ошибок. Они позволяют сортировать
ошибки подобно тому, как это делают конструкции try/catch в других языках.
Позднее мы вернемся к работе с типами и рассмотрим использование типа
reflect.Type для извлечения сведений о структуре. Но прежде, чем перейти к этой
теме, давайте разберемся, как понять, реализует ли тип заданный интерфейс.
13.1.2. Проверка реализации интерфейса
Система типов Go отличается от основанных на наследовании методов, характерных для традиционных объектно-ориентированных языков. Вместо
наследования в Go используется композиция. Интерфейс в Go определяет набор методов, который должен реализовать определенный тип, чтобы считаться
удовлетворяющим этому интерфейсу. Для наглядности рассмотрим простой
пример. В пакете fmt определен интерфейс Stringer, описывающий объект,
способный представлять себя в виде строки:
type Stringer interface {
String() string
}
Любой тип, в котором определен метод String(), не принимающий аргументов
и возвращающий строку, автоматически считается типом, реализующим интерфейс fmt.Stringer.
Проблема. Нам нужно выяснить, реализует ли конкретный тип заданный
интерфейс.
374
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Решение. Для этого можно использовать утверждение типа (type assertion)
или пакет reflect. Выбирайте тот способ, который считаете предпочтительным.
Обсуждение. Подход Go к интерфейсам отличается от подхода, принятого
в таких объектно-ориентированных языках, как Java. В Go тип не объявляется как реализующий интерфейс. Интерфейс — это лишь описание,
с которым тип можно сравнить. Однако интерфейсы в Go сами являются
типами. Именно поэтому при определении новых типов в Go мы явно
не указываем, какие интерфейсы они реализуют. Как было показано в главе 4, в Go интерфейсы пишут «задним числом», чтобы они соответствовали
уже существующему коду.
В языке Go типы выражают общность, а не наследование. При этом язык
позволяет легко проверить соответствие одного интерфейса другому. Такую
проверку можно выполнить одновременно с преобразованием типа, как показано в листинге 13.4.
Листинг 13.4. Проверка и преобразование типа
package main
import (
"bytes"
"fmt"
)
func main() {
Проверяет, реализует ли *bytes.Buffer
b := bytes.NewBuffer([]byte("Hello"))
интерфейс fmt.Stringer. Да, реализует
if isStringer(b) {
fmt.Printf("%T is a stringer\n", b)
}
Проверяет, реализует ли целое
число интерфейс fmt.Stringer.
i := 123
Нет, не реализует
if isStringer(i) {
fmt.Printf("%T is a stringer\n", i)
}
}
func isStringer(v interface{}) bool {
_, ok := v.(fmt.Stringer)
return ok
}
Принимает значение типа interface{} и проверяет его
на соответствие интерфейсу с помощью утверждения типа
Утверждение типа — это один из способов определить, реализует ли данное значение конкретный интерфейс. Но что, если вам нужно проверить этот интерфейс
во время выполнения программы? В таком случае необходимо использовать
пакет reflect и прибегнуть к небольшой хитрости.
13.1. Три составляющие системы рефлексии
375
Ранее в главе мы уже познакомились с базовыми типами из пакета reflect. Внимательный читатель мог заметить, что в нем отсутствует тип reflect.Interface.
Вместо этого тип reflect.Type (который сам является интерфейсом) предоставляет методы для проверки того, реализует ли тип заданный интерфейс. Чтобы
определить интерфейс во время выполнения, можно использовать reflect.Type,
как показано в листинге 13.5.
Листинг 13.5. Проверка реализации интерфейса
package main
import (
"fmt"
"io"
"reflect"
)
type Name struct {
First, Last string
}
func (n *Name) String() string {
return n.First + " " + n.Last
}
Создает тип Name, определяет
для него метод String()
и создает экземпляр
func main() {
n := &Name{First: "Inigo", Last: "Montoya"}
Создает нулевой указатель
типа fmt.Stringer
stringer := (*fmt.Stringer)(nil)
implements(n, stringer)
writer := (*io.Writer)(nil)
implements(n, writer)
Создает нулевой
указатель типа io.Writer
Проверяет, реализует ли n
интерфейс fmt.Stringer (то есть
содержит ли метод String())
Проверяет, реализует ли n интерфейс io.Writer
(то есть содержит ли метод Write())
func implements(concrete interface{}, target interface{}) bool {
iface := reflect.TypeOf(target).Elem()
Получает объект
v := reflect.ValueOf(concrete)
Получает тип reflect.Type конкретного
reflect.Type,
t := v.Type()
значения, переданного в функцию
описывающий тип,
на который ссылается
if t.Implements(iface) {
указатель
fmt.Printf("%T is a %s\n", concrete, iface.Name())
return true
Проверяет, реализует ли
переданный экземпляр
}
заданный интерфейс
fmt.Printf("%T is not a %s\n", concrete, iface.Name())
return false
}
}
На первый взгляд этот пример может показаться несколько запутанным. Функция implements() принимает два значения и проверяет, реализует ли первое
376
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
из них (concrete) интерфейс, соответствующий второму значению (target).
Результат выполнения программы будет выглядеть так:
$ go run implements.go
*main.Name is a Stringer
*main.Name is not a Writer
Тип Name реализует интерфейс fmt.Stringer, поскольку содержит метод String()
string. Однако он не реализует интерфейс io.Writer, так как в нем отсутствует
метод Write([]byte) (int, error).
Функция implements() предполагает, что аргумент target — это указатель на
значение, динамический тип которого представляет собой некий интерфейс.
Для проверки можно было бы добавить несколько строк кода, задействующего
механизм рефлексии. В текущей реализации передача аргумента target, не соответствующего описанию, может заставить функцию implements() завершиться
с паникой.
Чтобы проверить, реализует ли значение concrete интерфейс target, необходимо получить значения типа reflect.Type как для concrete, так и для target.
Это можно сделать двумя способами. Вызвать reflect.TypeOf() для получения
типа интерфейса, а затем применить метод Type.Elem(), чтобы получить тип, на
который ссылается указатель:
iface := reflect.TypeOf(target).Elem()
Второй способ сводится к тому, чтобы получить значение concrete, затем определить его reflect.Type и проверить, реализует ли этот тип заданный интерфейс,
с помощью метода Type.Implements():
v := reflect.ValueOf(concrete)
t := v.Type()
Основная сложность такого теста заключается в получении ссылки на интерфейс. Применить рефлексию к интерфейсному типу напрямую невозможно.
То есть нельзя просто создать экземпляр интерфейса или сослаться на него
явно. Вместо этого необходимо создать заглушку, реализующую нужный интерфейс. Самый простой путь — воспользоваться нулевым указателем (что
обычно не рекомендуется). В приведенном выше коде мы создали два нулевых
указателя: stringer := (*fmt.Stringer)(nil). По сути, это позволяет сгенерировать объект, единственной полезной информацией о котором является его тип.
Передача этих указателей в функцию implements() дает возможность применить
к ним рефлексию и определить тип. Для получения типа, на который ссылается
указатель, необходимо вызвать метод Elem().
Как показывает код в листинге 13.5, при работе с системой рефлексии Go нередко
приходится применять творческий подход. Задачи, которые на первый взгляд
кажутся простыми, могут потребовать нестандартного обращения с системой
типов.
13.1. Три составляющие системы рефлексии
377
13.1.3. Анализ полей структуры
Структуры — это наиболее распространенный инструментов Go для описания
структурированных данных. Благодаря тому что компилятор Go способен
получить всю необходимую информацию о содержимом структуры во время
компиляции, они работают весьма эффективно. Однако на этапе выполнения
вам может потребоваться выяснить, какие поля содержит структура и какие
значения в ней установлены.
Задача. Мы хотим исследовать структуру во время выполнения для получения информации о ее полях.
Решение. Применить к структуре механизм рефлексии и использовать
комбинацию типов reflect.Value и reflect.Type для получения нужных
сведений о ней.
Обсуждение. Ранее мы уже применяли механизм рефлексии к переменной,
чтобы выяснить ее значение, вид и тип. Теперь мы объединим эти приемы
для сбора данных о структуре.
Для решения поставленной задачи мы создадим простую программу, которая
считывает значение и выводит информацию о нем в консоль. Описываемые принципы пригодятся нам и в следующем разделе при работе с системой аннотаций Go.
Начнем с создания нескольких типов для дальнейшего анализа (листинг 13.6).
Листинг 13.6. Типы для анализа
package main
import (
"fmt"
"reflect"
"strings"
)
type MyInt int
type Person struct {
Name
*Name
Address *Address
}
type Name struct {
Title, First, Last string
}
type Address struct {
Street, Region string
}
Теперь у нас есть тип на основе целого числа и несколько структур. Давайте
добавим код для их анализа, как показано в листинге 13.7.
378
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Листинг 13.7. Рекурсивный анализ значения
func main() {
fmt.Println("Walking a simple integer")
var one MyInt = 1
Отображает сведения
walk(one, 0)
о простом типе
fmt.Println("Walking a simple struct")
two := struct{ Name string }{"foo"}
walk(two, 0)
}
Отображает сведения
о простой структуре
fmt.Println("Walking a struct with struct fields")
p := &Person{
Name: &Name{"Count", "Tyrone", "Rugen"},
Address: &Address{
Отображает сведения
"Humperdink Castle",
о структуре с вложенными
"Florian",
структурами
},
}
walk(p, 0)
type MyInt int
type Person struct {
Name
*Name
Address *Address
}
type Name struct {
Title, First, Last string
}
Функция walk() принимает
произвольное значение и параметр
depth (для форматирования вывода)
type Address struct {
Street, Region string
}
Получает объект reflect.Value для
значения u неизвестного типа. Если это
указатель, выполняет разыменование
Получает тип значения
func walk(u interface{}, depth int) {
Параметр depth используется
val := reflect.Indirect(reflect.ValueOf(u))
для добавления отступов
t := val.Type()
в вывод
tabs := strings.Repeat("\t", depth+1)
fmt.Printf("%sValue is type %q (%s)\n", tabs, t, val.Kind())
Если видом значения является
if val.Kind() == reflect.Struct {
структура, исследует ее поля
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
Для каждого поля требуется
fieldVal := reflect.Indirect(val.Field(i))
как reflect.StructField,
tabs := strings.Repeat("\t", depth+2)
так и reflect.Value
fmt.Printf("%sField %q is type %q (%s)\n",
tabs, field.Name, field.Type, fieldVal.Kind())
}
}
}
if fieldVal.Kind() == reflect.Struct {
walk(fieldVal.Interface(), depth+1)
}
Если поле тоже является структурой,
рекурсивно вызывает функцию walk()
13.1. Три составляющие системы рефлексии
379
В этом примере объединено практически все, что вы узнали о механизме ре
флексии. Параметр depth используется здесь исключительно для визуального
представления иерархии. При обходе анализируются значения, виды и типы,
то есть все ключевые элементы, доступные через механизм рефлексии. Если
запустить эту небольшую программу, результат будет таким:
$ go run structwalker.go
Анализ простого целого числа:
Value is type "main.MyInt" (int)
Анализ простой структуры:
Value is type "struct { Name string }" (struct)
Field "Name" is type "string" (string)
Анализ структуры со вложенными структурами:
Value is type "main.Person" (struct)
Field "Name" is type "*main.Name" (struct)
Value is type "main.Name" (struct)
Field "Title" is type "string" (string)
Field "First" is type "string" (string)
Field "Last" is type "string" (string)
Field "Address" is type "*main.Address" (struct)
Value is type "main.Address" (struct)
Field "Street" is type "string" (string)
Field "Region" is type "string" (string)
Как видите, программа поочередно исследует каждое переданное ей значение.
Сначала проверяется значение типа MyInt (вида int). Затем анализируется простая структура. Наконец, программа приступает к исследованию более сложной
структуры и рекурсивно обходит ее поля, пока не дойдет до тех, которые не являются структурами.
Функция walk() выполняет основную работу, связанную с определением типа/
вида и формированием вывода. Она принимает неизвестное значение u и исследует его. При обходе неизвестного значения важно следовать по указателям. Если вызвать reflect.ValueOf() для указателя, результатом будет объект
reflect.Value, описывающий именно указатель. В нашем случае эта информация
не представляет интереса (хотя в реальном проекте ее, возможно, стоило бы
как-то учесть). Здесь же нам важно получить значение, на которое указывает
данный указатель. Для этого используется функция reflect.Indirect(), возвращающая объект reflect.Value, описывающий интересующее нас значение.
Метод reflect.Indirect() удобен тем, что, если вызвать его для значения,
не являющегося указателем, он просто вернет reflect.Value, поэтому его можно
без опасений применять ко всем значениям:
val := reflect.Indirect(reflect.ValueOf(u))
380
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Помимо значения переменной u нам необходимо определить ее тип и вид.
В приведенном примере используются все три представления, доступные через
механизм рефлексии:
значение (если это указатель, мы получаем значение, на которое он указывает);
тип;
вид.
В данной ситуации особый интерес представляет именно вид. Некоторые виды,
в частности срезы, массивы, ассоциативные массивы и структуры, могут содержать составные элементы. Здесь нас в первую очередь волнует структура
переданного значения u. Перебирать элементы срезов и массивов нет необходимости, поскольку они, по сути, представляют собой значения базовых типов.
А вот структуры стоит проанализировать. Если видом значения является
reflect.Struct, поля этой структуры следует рассмотреть подробно.
Самый простой способ перебрать поля структуры — получить ее тип, а затем обойти поля с помощью комбинации методов Type.NumField() (возвращает количество полей в структуре) и Type.Field() (возвращает объект reflect.StructField,
описывающий конкретное поле). С помощью reflect.StructField можно узнать
имя поля и его тип данных.
Значение поля структуры нельзя получить ни через reflect.Type (описывает тип
данных), ни через reflect.StructField (описывает поле, определенное в типе
структуры). Вместо этого следует использовать тип reflect.Value, который описывает конкретное значение структуры. К счастью, зная тип и значение, можно
установить соответствие между индексами полей в типе и значении структуры.
Поэтому вызов Value.Field() с тем же индексом, что и Type.Field(), даст значение соответствующего поля. Опять же, если поле является указателем, вас,
скорее всего, будет интересовать не он сам, а значение, на которое он ссылается.
В таком случае можно применить к указателю метод reflect.Indirect(). Все это
видно в выводе представленной выше программы:
Field "Name" is type "*main.Name" (struct)
Value is type "main.Name" (struct)
Поле Name имеет тип *main.Name, однако при переходе по указателю мы получаем
значение типа main.Name. В этой небольшой программе много чего происходит,
поэтому давайте подытожим:
из значения типа interface{} можно получить объект reflect.Value с помощью reflect.ValueOf();
для получения reflect.Value объекта, на который ссылается указатель,
нужно вызвать reflect.Indirect();
из reflect.Value удобно извлекать сведения о типе и виде;
13.2. Структуры, теги и аннотации
381
для структур (вида reflect.Struct ) можно определить количество полей с помощью Type.NumField() и получить описание каждого поля (re
flect.StructField) через Type.Field();
аналогичным образом с помощью объекта reflect.Value можно получить
значения полей структуры, используя метод Value.Field().
Пакет reflect также предоставляет средства для получения информации о методах структуры, элементах списков и массивов, а также о том, какие значения
может передавать или принимать канал. Среди наиболее полезных инструментов
выделяют:
MethodByName(name string) — возвращает метод с заданным именем и булево
значение, сообщающее о том, найден ли метод для заданного типа;
AssignableTo(theType Type) — проверяет, может ли один тип быть присвоен
другому без приведения;
Comparable() — сообщает, можно ли сравнивать значения данного типа.
Несмотря на всю изящность языка Go, пакет reflect весьма сложен для освоения.
Многие его функции и методы вызывают панику вместо возврата сообщения
об ошибке. В таких случаях лучше использовать механизмы перехвата паники,
о которых мы говорили в главе 4.
Теперь, получив все необходимые знания, мы можем перейти к обсуждению
одной из наших любимейших возможностей Go — системе аннотаций. Мы уже
вскользь упоминали ее в главе 10, когда говорили о способе встраивания файлов
в исполняемый файл. Хотя существует множество вариантов применения тегов
как в стандартной библиотеке, так и за ее пределами, в следующем разделе мы
сосредоточимся на создании собственных тегов структур и их использовании.
13.2. Структуры, теги и аннотации
В языке Go нет макросов, а поддержка аннотаций в нем весьма ограничена по
сравнению с такими языками, как Java и Python. Однако при работе со структурами очень часто используются теги. Так, в главе 3 мы показывали, как их
применять для указания информации, связанной с обработкой JSON. В этом
разделе мы рассмотрим способы использования тегов для валидации данных.
13.2.1. Аннотирование структур
В предыдущих главах вы уже видели примеры использования аннотаций
в структурах, например при работе с JSON-кодировщиком, который добавляет
итоговые JSON-теги к выходным данным. Мы можем начать со структуры из
листинга 13.5 и дополнить ее аннотациями для JSON-кодировщика, как показано в листинге 13.8.
382
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Листинг 13.8. Простая JSON-структура
package main
import (
"encoding/json"
"fmt"
)
type Name struct {
First string `json:"firstName"`
Last string `json:"lastName"`
}
Добавляет аннотации к полям структуры
для кодирования и декодирования в JSON
func main() {
n := &Name{"Inigo", "Montoya"}
data, _ := json.Marshal(n)
Преобразует значение n
fmt.Printf("%s\n", data)
в JSON и выводит результат
}
Этот код объявляет единственную структуру Name с аннотациями для кодирования в JSON. Если вкратце, то он сопоставляет поле структуры First с JSONполем firstName, а поле Last — с lastName. Это очень распространенная практика,
поскольку согласно принятой конвенции теги JSON обычно содержат строчные
буквы, в то время как в Go имена полей необходимо писать с заглавной буквы,
чтобы обеспечить их видимость за пределами пакета. Запуск этого кода приведет
к следующему результату:
$ go run json.go
{"firstName":"Inigo","lastName":"Montoya"}
Аннотации структур позволяют управлять представлением данных в JSON.
С помощью тегов можно задать дополнительные правила обработки для каждого
поля структуры. По сути, аннотация представляет собой произвольную строку,
заключенную в обратные кавычки, которая следует сразу за объявлением типа
поля.
Сами по себе аннотации не играют никакой роли при компиляции, но могут
быть получены во время выполнения с помощью механизма рефлексии. Логика
обработки аннотаций зависит от конкретного парсера — именно он определяет, содержит ли аннотация полезные для него данные. Давайте изменим
предыдущий пример, добавив в него различные аннотации, как показано
в листинге 13.9.
Листинг 13.9. Разнообразные аннотации
type Name
First
Last
Other
}
struct
string
string
string
{
`json:"firstName" xml:"FirstName"`
`json:"lastName,omitempty"`
`not,even.a=tag"`
13.2. Структуры, теги и аннотации
383
Все эти аннотации считаются допустимыми с точки зрения Go, так что парсер
корректно их обработает, а JSON-кодировщик сможет выделить те, которые
относятся к нему. При этом он проигнорирует тег xml, а также нестандартно
оформленную аннотацию поля Other.
Как видно из тегов в листинге 13.9, аннотация не имеет жестко заданного формата. В ее качестве можно использовать почти любую строку. Тем не менее в сообществе Go сложился определенный стиль оформления аннотаций, который
стал фактическим стандартом. Go-разработчики называют такие аннотации
тегами.
13.2.2. Использование тегов
В рассмотренной ранее JSON-структуре использовались аннотации вида
json:"ИМЯ,ДАННЫЕ", где ИМЯ — имя поля в JSON-документе, а ДАННЫЕ — список
с дополнительными сведениями об этом поле (например, параметр omitempty
или данные о виде). На рис. 13.2 показан пример структуры с аннотациями как
для JSON, так и для XML.
Аннотации для валидации
Одним из самых интересных способов применения аннотаций является валидация данных в полях структуры. Добавив в тег регулярные выражения (например,
validate:"^[a-z]+$") и написав код, применяющий эти выражения к значениям
полей, можно реализовать компактную и выразительную проверку допустимости
данных, включаемых в структуру.
Рис. 13.2. Структура, преобразованная в формат JSON и XML
В пакетах encoding/json и encoding/xml используется схожий принцип аннотирования структур. Для преобразования структур в формат XML и обратно
применяются теги XML, которые выглядят так: xml:"body" или xml:"href,attr".
Эта форма соответствует шаблону xml:"ИМЯ,ДАННЫЕ" , где ИМЯ — имя поля,
384
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
а ДАННЫЕ — список параметров, описывающих поле (хотя XML-аннотации обычно
более выразительны и сложны по сравнению с JSON-аннотациями).
Имейте в виду, что этот формат не закреплен в языке на уровне синтаксиса —
это всего лишь соглашение, которое стало общепринятым благодаря своему
удобству. Вы можете использовать теги в своем коде любым способом. К тому
же, как будет показано далее, пакет Go reflect предоставляет удобные средства
для работы с такими тегами.
13.2.3. Обработка тегов в структуре
Аннотации могут пригодиться в самых разных ситуациях. В приведенных выше
примерах они использовались кодировщиками данных. Однако с тем же успехом
аннотации можно применять для описания соответствия между типами полей
базы данных и полями структур или для задания правил форматирования выходных данных. Нам даже встречались случаи, когда аннотации использовались
для передачи значений структур в специальные функции фильтрации. Поскольку
формат аннотаций не является жестко заданным, для создания собственных
аннотаций достаточно определить их структуру и написать соответствующую
реализацию.
Проблема. Мы хотим создать собственные аннотации структуры и программно обращаться к их данным во время выполнения.
Решение. Определите формат аннотации (желательно используя синтаксис
тегов, описанный выше). Затем с помощью пакета reflect реализуйте инструмент, извлекающий данные из аннотации структуры.
Обсуждение. Допустим, вы хотите написать кодировщик для простого
формата файла с парами «имя — значение», аналогичного INI. Вот пример
такого файла:
total=247
running=2
sleeping=245
threads=1189
load=70.87
Здесь имена располагаются слева от знака равенства, а значения — справа.
Допустим, мы хотим создать структуру для представления этих данных. Она
будет выглядеть так, как показано в листинге 13.10.
Листинг 13.10. Простая структура Processes
type Processes struct {
Total
int
Running int
Sleeping int
Threads int
Load
float32
}
13.2. Структуры, теги и аннотации
385
Для преобразования простого файлового формата в такую структуру мы можем
создать тег, соответствующий нашим потребностям, и использовать его для разметки структуры (листинг 13.11).
Листинг 13.11. Структура Processes с аннотациями
type Processes struct {
Total
int
`ini:"total"`
Running int
`ini:"running"`
Sleeping int
`ini:"sleeping"`
Threads int
`ini:"threads"`
Load
float32 `ini:"load"`
}
Такая структура тегов соответствует тому же соглашению, что и у тегов JSON
и XML, рассмотренных ранее. Однако в Go нет встроенного механизма, который
автоматически разбирал бы такой файловый формат и заполнял бы экземпляр
структуры Processes на основании аннотаций. Эту работу придется выполнять
вручную.
При разработке такого механизма мы можем вновь опереться на устоявшиеся
соглашения. Кодировщики и декодировщики в Go, как правило, предоставляют
методы Marshal() и Unmarshal() с предсказуемыми параметрами и возвращаемыми значениями. Наш декодировщик INI-файлов будет реализован по тому же
принципу, как показано в листинге 13.12.
Листинг 13.12. Шаблоны методов Marshal и Unmarshal
func Marshal(v interface{}) ([]byte, error) {}
func Unmarshal(data []byte, v interface{}) error {}
Основная часть логики обеих функций сводится к применению механизма
рефлексии для извлечения данных из значений типа interface{} или записи
данных в них. Чтобы сохранить код максимально компактным, в следующем примере рассматриваются только операции маршалинга и демаршалинга структур.
Поскольку использование рефлексии само по себе часто делает код довольно громоздким, имеет смысл разбить программу на отдельные фрагменты. Мы начнем
с описания структуры для INI-файла и функции main(). В первой части будет
создан новый тип Processes, затем в функции main() — структура Processes,
которую мы маршализуем в формат INI, а затем демаршализуем в новый экземпляр Processes (листинг 13.13).
Листинг 13.13. Тип Processes и функция main
package main
import (
"bufio"
"bytes"
Большинство этих пакетов
используется далее в коде
386
)
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
"errors"
"fmt"
"reflect"
"strconv"
"strings"
Эта структура уже встречалась
в листинге 13.11
type Processes struct {
Total
int
`ini:"total"`
Running int
`ini:"running"`
Sleeping int
`ini:"sleeping"`
Threads int
`ini:"threads"`
Load
float64 `ini:"load"`
}
func main() {
fmt.Println("Write a struct to output:")
proc := &Processes{
Total:
23,
Running: 3,
Создает экземпляр
Sleeping: 20,
структуры Processes
Threads: 34,
Load:
1.8,
}
data, err := Marshal(proc)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
Маршализует структуру
в срез байтов
Выводит результат на экран
fmt.Println("Read the data back into a struct")
proc2 := &Processes{}
Создает новую структуру Processes
if err := Unmarshal(data, proc2); err != nil {
и демаршализует в нее данные
panic(err)
}
fmt.Printf("Struct: %#v", proc2)
Отображает содержимое структуры
Основная логика этой программы довольно проста. Сначала создается экземпляр структуры Processes, который затем маршализуется в массив байтов. При
выводе на экран эти данные будут представлены в формате INI. Затем те же
данные демаршализуются в новый экземпляр структуры Processes. Запуск этой
программы с помощью команды:
$ go run load.go
приведет к получению следующего вывода:
total=23
running=3
sleeping=20
threads=34
load=1.8
Read the data back into a struct
Struct: &main.Processes{Total:23, Running:3, Sleeping:20, Threads:34, Load:1.8}
13.2. Структуры, теги и аннотации
387
Первая часть вывода содержит маршализованные данные, а вторая — демаршализованную структуру. Далее мы рассмотрим функцию Marshal(), которая
задействует рассмотренный ранее механизм рефлексии (листинг 13.14).
Листинг 13.14. Функция Marshal
Простая вспомогательная функция
для чтения тегов из полей структуры
func fieldName(field reflect.StructField) string {
if t := field.Tag.Get("ini"); t != "" {
Извлекает тег
return t
из поля структуры
}
Если
тег
отсутствует,
return field.Name
используется имя поля
Получает значение reflect.
}
Value текущего интерфейса
и разыменовывает указатели
func Marshal(v interface{}) ([]byte, error) {
var b bytes.Buffer
В этой программе обрабатываются
val := reflect.Indirect(reflect.ValueOf(v))
только структуры
if val.Kind() != reflect.Struct {
return []byte{}, errors.New("marshal can only take structs")
}
Перебирает все
t := val.Type()
поля структуры
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
Получает имя из тега
name := fieldName(f)
Использует средство
raw := val.Field(i).Interface()
форматирования вывода
fmt.Fprintf(&b, "%s=%v\n", name, raw)
для записи исходных
}
данных в буфер
Возвращает
return b.Bytes(), nil
содержимое буфера
}
Функция Marshal() принимает значение v типа interface{} и перебирает его
поля. Определив тип, она проходит по всем полям структуры и извлекает аннотации с помощью функции StructField.Tag(). Параллельно с этим можно
получить и значения соответствующих полей структуры. Вместо того чтобы
вручную преобразовывать эти значения из их исходного типа в строку, используется функция fmt.Fprintf(), которая выполняет это преобразование
автоматически.
Стоит отметить, что функция fieldName() задействует встроенный механизм
Go для парсинга тегов. Хотя в аннотации можно хранить произвольные
строки, Go позволяет получить значение любой аннотации, следующей
формату ИМЯ:"ЗНАЧЕНИЕ", с помощью метода StructField.Tag.Get(). Данное
значение возвращается в исходном виде. Довольно часто значения в тегах
указываются в виде списка параметров, разделенных запятыми (например,
json:"myField,omitempty"). Однако в нашем простом примере допускается
только одно значение. При отсутствии тега возвращается имя поля структуры.
Данная функция Marshal() не отличается универсальностью. Она работает
только со структурами и не поддерживает ассоциативные массивы, которые
тоже могут быть легко преобразованы в формат INI. Более того, эта функция
388
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
корректно работает лишь с определенными типами данных. Например, она
не сможет выдать полезный результат для полей, содержащих структуры, каналы,
срезы или массивы. Впрочем, реализация поддержки этих типов не отличается
особой сложностью, хотя и требует написания большого количества операторов
switch или применения обобщений.
Игнорирование полей структуры с помощью аннотаций
Если вам нужно, чтобы кодировщик проигнорировал определенные поля структуры, замените соответствующие поля в аннотации дефисом (-), например: json:"-".
Хотя приведенный выше пример не поддерживает такую возможность, его можно
расширить, добавив проверку и пропуск полей с именем -.
В листинге 13.15 показан процесс преобразования имеющихся данных в формате
INI в структуру. Для этого вновь используются аннотации и механизм рефлексии.
Листинг 13.15. Функция Unmarshal
func Unmarshal(data []byte, v interface{}) error {
val := reflect.Indirect(reflect.ValueOf(v))
t := val.Type()
}
Мы снова начинаем
с получения reflect.Value
и разыменовывания указателя
b := bytes.NewBuffer(data)
scanner := bufio.NewScanner(b)
Использует сканер для построчного
for scanner.Scan() {
считывания данных INI
line := scanner.Text()
pair := strings.SplitN(line, "=", 2)
Разделяет строку в месте
if len(pair) < 2 {
нахождения символа равенства
// Пропускаем некорректные строки
continue
}
setField(pair[0], pair[1], t, val)
Передает задание по установке
}
значения функции setField()
return nil
Функция Unmarshal() считывает срез байтов ([]byte) и пытается преобразовать найденные в нем поля в соответствующие поля переданной структуры
v interface{}. Сам парсер данных INI предельно прост: он последовательно обрабатывает строки файла и разбивает пары «имя — значение». Однако на этапе
заполнения структуры полученными значениями довольно большую работу
приходится проделывать вручную.
Функция unmarshal() активно использует вспомогательный метод setField(),
который задействует механизм рефлексии. В данном случае мы снова реализуем переключение логики на основе вида, что делает код весьма громоздким
(листинг 13.16).
13.2. Структуры, теги и аннотации
389
Листинг 13.16. Вспомогательная функция setField
Функция setField получает имя и значение из INI-данных,
а также тип и значение самой структуры
func setField(name, value string, t reflect.Type, v reflect.Value) {
for i := 0; i < t.NumField(); i++ {
Перебирает поля структуры в поисках того, имя
field := t.Field(i)
которого совпадает с именем поля в INI-данных
if name == fieldName(field) {
Использует переключатель логики на основе вида
var dest reflect.Value
для преобразования строки значения в нужный тип
switch field.Type.Kind() {
}
default:
fmt.Printf("Kind %s not supported.\n", field.Type.Kind())
continue
Эта версия поддерживает лишь
case reflect.Int:
несколько видов значений. Добавить
ival, err := strconv.Atoi(value)
поддержку других видов несложно,
Если вид
if err != nil {
но требует написания большого объема
не поддерживается,
однотипного кода
fmt.Printf(
поле просто
"Could not convert %q to int: %s\n",
пропускается — это
value, err)
не считается ошибкой
continue
}
dest = reflect.ValueOf(ival)
case reflect.Float64:
fval, err := strconv.ParseFloat(value, 64)
if err != nil {
После
fmt.Printf(
преобразования
"Could not convert %q to float64: %s\n",
исходного
value, err)
значения
continue
в нужный тип оно
}
оборачивается
в reflect.Value
dest = reflect.ValueOf(fval)
case reflect.String:
dest = reflect.ValueOf(value)
case reflect.Bool:
bval, err := strconv.ParseBool(value)
if err != nil {
fmt.Printf(
"Could not convert %q to bool: %s\n",
value, err)
continue
}
dest = reflect.ValueOf(bval)
}
v.Field(i).Set(dest)
Устанавливает значение
}
для соответствующего
}
поля структуры
Функция setField() принимает исходную пару «имя — значение», а также
reflect.Value и reflect.Type структуры, и пытается сопоставить эту пару с со-
ответствующими полями структуры. (Как и раньше, данная реализация работает
только со структурами, но при необходимости ее можно расширить и на ассоциа
тивные массивы.) Найти соответствующее поле по имени достаточно просто.
390
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Для этого можно повторно использовать функцию fieldName() из листинга 13.14.
Что касается значения, то его необходимо преобразовать из исходного строкового
формата в тип соответствующего поля структуры. Ради компактности в листинге 13.14 реализована поддержка лишь нескольких типов данных: int, float64,
string и bool. Производные от них типы в данном примере не рассматриваются.
Тем не менее в этот код можно легко добавить поддержку других типов. В конце
концов преобразованное значение оборачивается в reflect.Value() и записывается в соответствующее поле структуры.
Таким образом, из-за строгой типизации в Go преобразование между типами
часто требует большого объема шаблонного кода. Иногда можно использовать
встроенные средства (вроде функции fmt.Fprintf()), но в других случаях приходится писать все вручную. Избежать этого помогают генераторы кода — инструменты, которые создают исходный код автоматически. В следующем разделе мы
рассмотрим пример написания генератора, который избавляет от необходимости
использовать рефлексию и проверять типы во время выполнения программы.
13.3. Генерация кода средствами самого Go
До появления обобщений реализация методов для различных типов была трудо
емким процессом: приходилось либо многократно дублировать код, либо полагаться на механизм рефлексии и переключения логики на основе типа. Стоит
отметить, что рефлексия во время выполнения сопряжена со значительными
издержками: ее чрезмерное использование в программе приводит к ощутимым
задержкам и дополнительным затратам ресурсов. При необходимости можно
применять обобщения, однако существует и более эффективный способ создания
кода. Как уже было сказано, язык Go не поддерживает макросы, а возможности
аннотаций весьма ограниченны. В связи с этим возникает вопрос: есть ли другие
способы трансформации кода? Как реализовать метапрограммирование в Go?
Наряду с рефлексией и дублированием существует еще один распространенный
подход к генерации кода, особенно в тех случаях, когда обобщения оказываются
неприменимы.
Одной из недооцененных возможностей Go является встроенная поддержка
генерации кода. В состав Go входит специальный инструмент go generate ,
предназначенный как раз для этой цели. Метапрограммирование с помощью
генераторов является ответом на поставленные выше вопросы. Сгенерированный и скомпилированный код работает значительно быстрее по сравнению
с решениями, основанными на рефлексии. Кроме того, он зачастую оказывается
более простым. Если вам нужно создать множество однообразных, но при этом
типобезопасных объектов, генераторы могут существенно упростить процесс
разработки. Хотя многие разработчики относятся к метапрограммированию с некоторым недоверием, на практике генераторы применяются повсеместно. В частности, такие технологии, как Protobuf, gRPC и Thrift, упомянутые в главе 12,
13.3. Генерация кода средствами самого Go
391
опираются именно на генерацию кода. Многие библиотеки для работы с SQL
также реализованы в виде генераторов. В некоторых языках программирования
генераторы используются «за кулисами» для реализации макросов, обобщений
и коллекций. До появления обобщений в Go именно генерация кода считалась
предпочтительным подходом. Хорошая новость заключается в том, что Go
предоставляет мощные инструменты генерации прямо «из коробки».
В основе подхода Go к генерации кода лежит простой инструмент go generate.
Как и другие средства Go, он учитывает окружение и может применяться на
уровне как отдельных файлов, так и пакетов. По сути, его концепция предельно
проста.
Он проходит по указанным файлам, проверяет первую строку каждого из них
и при обнаружении определенного шаблона выполняет заданную команду. Этот
шаблон выглядит так:
//go:generate КОМАНДА [АРГУМЕНТ...]
Генератор ищет такой комментарий в самом начале каждого переданного ему
файла. При отсутствии этого заголовка файл просто пропускается, а при наличии — выполняется указанная в нем команда. В качестве команды можно указать
любой инструмент командной строки, который генератор способен обнаружить
и запустить. При этом можно передать любое количество аргументов. В листинге 13.17 продемонстрирован простейший пример генератора кода.
Листинг 13.17. Простейший генератор кода
//go:generate echo hello
package main
func main() {
println("Goodbyte")
}
Этот Go-код, очевидно, является корректным, однако при генерации он работает
по-другому. Если бы вы скомпилировали и запустили его, он бы вывел в консоль
строку Goodbyte. Но в первой строке файла находится генератор. В качестве его
команды используется echo — UNIX-команда, отправляющая строку в стандартный поток вывода. В нашем случае она принимает один аргумент — строку
hello. Давайте запустим генератор и посмотрим, что произойдет:
$ go generate simple.go
hello
Все, что делает генератор, — выполняет команду, которая выводит в консоль
слово hello. Хотя это, пожалуй, самая простая реализация, которую только
можно представить, сама идея в том, что вы можете указать любую команду для
генерации кода. Далее вы увидите, как это работает на практике.
392
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
Разработчики на Go используют генераторы для решения множества задач, включая создание типобезопасных пользовательских коллекций, генерацию структур
на основе таблиц базы данных, преобразование JSON-схем в код и создание
множества однотипных объектов. Иногда для генерации кода применяются пакет
AST (Abstract Syntax Tree) или инструмент yacc. В стандартной библиотеке Go
для этой цели также предусмотрены пакеты parser и text/scanner. Однако, как
показала практика, один из самых удобных и увлекательных способов генерации
Go-кода сводится к написанию специальных шаблонов.
Проблема. Мы хотим иметь возможность создавать типизированные коллекции (вроде очереди) для произвольного количества типов. При этом мы
хотим избежать проблем с производительностью и безопасностью, связанных
с приведением типов. При большом количестве типов генерация позволяет
существенно сократить объем шаблонного кода даже при работе с обобщениями.
Решение. Создать генератор, способный реализовать нужные очереди, а затем запускать его по мере необходимости, используя директиву go:generate.
Обсуждение. Очередь — это простая структура данных, работающая по принципу FIFO (First In, First Out — «Первым пришел, первым ушел»). То есть
элемент, добавленный в очередь первым, будет удален из нее тоже первым.
Как правило, очереди предусматривают два метода: insert (или enqueue)
для добавления элемента в конец и remove (или dequeue) для извлечения
элемента из начала.
Как было показано в главе 3, реализация обобщений иногда требует написания
большого объема повторяющегося кода. В некоторых случаях этого можно
избежать с помощью генераторов.
Итак, мы хотим автоматически генерировать очереди, оптимизированные для
работы с определенными типами данных. Такие очереди должны соответствовать
шаблону, приведенному в листинге 13.18.
Листинг 13.18. Простая очередь
package main
type MyTypeQueue struct {
q []MyType
}
Простейшая очередь на основе
среза определенного типа
func NewMyTypeQueue() *MyTypeQueue {
return &MyTypeQueue{
q: []MyType{},
}
}
func (o *MyTypeQueue) Insert(v MyType) {
o.q = append(o.q, v)
}
Добавляет элемент
в конец очереди
13.3. Генерация кода средствами самого Go
393
func (o *MyTypeQueue) Remove() MyType {
Удаляет элемент
if len(o.q) == 0 {
из начала очереди
panic("Oops.")
}
В рабочем коде вместо вызова паники следовало бы
first := o.q[0]
возвращать ошибку. Здесь мы упростили реализацию
o.q = o.q[1:]
для сокращения кода генератора
return first
}
Этот код хорошо иллюстрирует, что именно мы хотим сгенерировать для
конкретного типа. Некоторые элементы (например, тип данных и имя пакета)
должны подставляться во время генерации. Следующим шагом будет преобразование этого кода в Go-шаблон. Начальная реализация нашего генератора
очередей содержится в листинге 13.19.
Листинг 13.19. Шаблон очереди
package main
import (
"fmt"
"os"
"strings"
"text/template"
)
Package — это заполнитель
для имени пакета
var tpl = `package {{.Package}}
type {{.MyType}}Queue struct {
.MyType — это
q []{{.MyType}}
заполнитель для типа
}
func New{{.MyType}}Queue() *{{.MyType}}Queue {
return &{{.MyType}}Queue{
q: []{{.MyType}}{},
}
}
func (o *{{.MyType}}Queue) Insert(v {{.MyType}}) {
o.q = append(o.q, v)
}
func (o *{{.MyType}}Queue) Remove() {{.MyType}} {
if len(o.q) == 0 {
panic("Oops.")
}
first := o.q[0]
o.q = o.q[1:]
return first
}`
Как видите, наш шаблон практически идентичен написанному ранее целевому
коду, за исключением того, что вместо имени пакета используется заполнитель
{{.Package}}, а вместо префикса MyType — переменная {{.MyType}}. Далее нам
нужно написать код, выполняющий генерацию. Это будет командная утилита,
соответствующая шаблону //go:generate КОМАНДА АРГУМЕНТ…, например:
//go:generate queue MyInt
394
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
В результате генератор реализует очередь для значений типа MyInt. Однако
было бы еще лучше, если бы мы имели возможность генерировать очереди сразу
для нескольких типов данных:
//go:generate queue MyInt MyFloat64
Этого тоже довольно легко добиться, как показано в листинге 13.20.
Листинг 13.20. Основной генератор очереди
func main() {
tt := template.Must(template.New("queue").Parse(tpl))
for i := 1; i < len(os.Args); i++ {
dest := strings.ToLower(os.Args[i]) + "_queue.go"
Компилирует шаблон
генератора
Перебирает аргументы,
создавая для каждого
из них файл с именем
file, err := os.Create(dest)
вида ТИП_queue.go
if err != nil {
fmt.Printf("Could not create %s: %s (skip)\n", dest, err)
continue
}
Присваивает .MyType значение
переданного аргумента,
vals := map[string]string{
обозначающего тип
"MyType": os.Args[i],
"Package": os.Getenv("GOPACKAGE"),
Присваивает .Package значение
}
переменной окружения $GOPACKAGE
}
}
tt.Execute(file, vals)
file.Close()
Выполняет шаблон, записывая
результат в файл
Поскольку мы хотим принимать несколько типов данных из командной строки,
начнем с перебора значений os.Args. Для каждого из них автоматически создается выходной файл с именем вида ТИП_queue.go. Чтобы соблюсти соглашения
Go по именованию файлов, имя типа следует привести к нижнему регистру.
В нашем шаблоне используются всего две переменные. Первая определяет тип
данных, с которым мы хотим работать, а вторая — имя пакета. Удобство команды
go generate заключается в том, что она автоматически подставляет несколько
переменных окружения с информацией о расположении файла-генератора.
В частности, переменная окружения $GOPACKAGE получает имя пакета, в котором
был обнаружен заголовок go:generate.
При выполнении шаблона используется ассоциативный массив vals, с помощью
которого значения подставляются в шаблон. В результате получается полноценный файл на языке Go. Чтобы запустить генератор, необходимо поместить
заголовок go:generate в соответствующий файл, как показано в листинге 13.21.
Это хороший пример типичного использования генератора Go-кода. Мы объявляем генератор, указываем нужный тип и запускаем процесс генерации —
и все это в одном файле. Очевидно, данный код не скомпилируется, пока вы
13.3. Генерация кода средствами самого Go
395
не запустите генератор. Однако, поскольку работа генератора не зависит от
успешной компиляции вашего кода, его можно (и нужно) запускать до сборки
пакета.
Листинг 13.21. Использование генератора
//go:generate ./queue MyInt
package main
import "fmt"
type MyInt int
Добавляет заголовок go:generate,
создающий очередь для типа MyInt
Определяет
тип MyInt
func main() {
var one, two, three MyInt = 1, 2, 3
q := NewMyIntQueue()
q.Insert(one)
q.Insert(two)
q.Insert(three)
}
Использует сгенерированную
очередь MyIntQueue
fmt.Printf("First value: %d\n", q.Remove())
Важно отметить, что генераторы задумывались авторами Go как инструменты разработки, а не средства, применяемые на этапе сборки или выполнения
программы. Во многих случаях имеет смысл сгенерировать код и сохранить
его в системе контроля версий. Пользователь не должен запускать генератор
самостоятельно (даже если этим пользователем является другой разработчик),
за исключением случаев, когда вы сознательно хотите предоставить доступ
к промежуточным этапам генерации.
Чтобы запустить описанный выше генератор, вам потребуется выполнить несколько шагов. Сначала скомпилируйте код из листингов 13.18 и 13.19. Для
корректной работы генератора необходимо, чтобы программа queue либо находилась в текущей папке (в этом случае задействуется вызов./queue), либо была
доступна через переменную окружения $PATH (например, находилась в $GOPATH/
bin) — в этом случае можно использовать вызов //go:generate queue.
Когда генератор queue будет скомпилирован и доступен для go generate, вы
сможете запустить утилиту go generate, а затем — основную программу, которая
использует сгенерированный код:
$ ls
myint.go
$ go generate
$ ls
myint.go
myint_queue.go
$ go run myint.go myint_queue.go
First value: 1
396
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
После того как генератор создаст дополнительный код очереди, вы сможете
запустить программу, приведенную в листинге 13.21. Все необходимое для ее
работы к этому времени уже будет сгенерировано.
Код создания этого генератора довольно прост. Вместо обработки ошибок
в данной реализации используется вызов паники. Она также не поддерживает
помещение указателей в очередь. Однако такие ограничения легко устраняются
стандартными средствами. Например, чтобы добавить поддержку указателей,
достаточно передать в шаблон дополнительный параметр для вставки символа *
в нужных местах.
Разумеется, шаблоны Go — это не единственный способ генерации кода.
С помощью пакета go/ast можно создавать код, программно взаимодействуя
с абстрактным синтаксическим деревом. Более того, ничто не мешает написать
генератор Go-кода на языке Python, C или Erlang. Мы также не ограничены генерацией только кода на языке Go. Например, SQL-выражения CREATE довольно
часто генерируются на основе аннотированных структур. Одно из достоинств
инструмента go generate — его универсальность, реализуемая через предельно
простой интерфейс.
В целом генераторы избавляют нас от необходимости писать шаблонный код,
который в противном случае пришлось бы реализовать через механизм рефлексии. Хотя рефлексия в Go весьма полезна, код на ее основе обычно довольно
громоздкий, обладает ограниченными возможностями и может ухудшать производительность приложения. Это не означает, что механизм рефлексии плох,
просто данный инструмент предназначен для решения строго определенного
круга задач.
С другой стороны, генерация кода не всегда является оптимальным решением.
Метапрограммирование может затруднить отладку и добавить лишние этапы
в процесс разработки. Как и механизм рефлексии, генераторы следует применять
тогда, когда ситуация действительно этого требует. Однако они не являются
ни панацеей, ни способом обхода ограничений, связанных со строгой типизацией.
13.4. Взаимодействие с кодом на других языках
Напоследок нам стоит обсудить тему взаимодействия с кодом, написанным на
языках программирования, отличных от Go, — в первую очередь на языке C. Например, языки вроде Rust, Zig и Kotlin получили эту возможность «из коробки».
На практике такая необходимость возникает нечасто, однако в некоторых случаях
вам может понадобиться задействовать в своем проекте C-библиотеку, не переписывая ее. Если ресурсы позволяют, имеет смысл реализовать небольшую
библиотеку с нуля, однако в процессе прототипирования или в тех случаях,
когда переписывание может оказаться слишком затратным, Go предоставляет
специальный механизм, позволяющий «импортировать» код на C и использовать
функции и переменные этого языка прямо в Go-коде.
13.4. Взаимодействие с кодом на других языках
397
В сущности, это гораздо ближе к непосредственной работе на C, чем к передаче сериализованных данных, характерной для применения, например, пакета os.exec.
Как сказал Роб Пайк, «cgo — это не Go». По его мнению, при взаимодействии
с C-приложениями вы уже не пишете код на языке Go, даже если внешне это выглядит именно так. В своем докладе Go Proverbs на конференции Gophercon он
подробно описал подводные камни и ограничения, связанные с использованием
cgo: https://rutube.ru/video/97c7b5f478d624366d8a4a86c6f89b8e/.
Чтобы разобраться в принципе такого взаимодействия, давайте передадим данные из Go-приложения в код, написанный на C, как показано в листинге 13.22.
Листинг 13.22. Из Go в C
package main
Стандартный импорт C-кода
с помощью директивы #include
// #include <stdio.h>
// void delimiter(int num, int limit) {
Функция, выводящая один
//
if (num < limit) {
из двух возможных разделителей
//
printf(", ");
в зависимости от номера итерации
//
return;
//
}
//
printf("\n");
Объявление функции
// }
fizz_buzz на языке C
// void fizz_buzz(int limit) {
//
for (int i = 0; i <= limit; i++) {
Цикл с основной
//
if (i % 3 == 0 || i % 5 == 0) {
логикой
//
printf("FizzBuzz");
//
}
//
else if (i % 3 == 0) {
//
printf("Fizz");
//
}
//
else if (i % 5 == 0) {
//
printf("Buzz");
//
} else {
//
printf("%d", i);
//
}
//
delimiter(i, limit);
//
}
// }
import "C"
Импорт C-кода в Go
func main() {
if _, err := C.fizz_buzz(15); err != nil {
panic(err)
}
}
Вызов C-функции с перехватом
возможной ошибки
В этом примере мы вызываем функцию fizz_buzz, реализованную на языке C
и встроенную в наш Go-файл. Мы также можем ссылаться на заголовочные
файлы C прямо из Go-кода. В данном случае все ссылки на cgo должны располагаться в комментариях до любых импортов, включая import "C". Разумеется,
функцию fizz_buzz можно было бы реализовать и на Go, однако наш пример
398
Глава 13. Рефлексия, генерация кода и продвинутые возможности Go
показывает, как организовать взаимодействие между двумя языками, если возникает такая необходимость.
Работа с cgo — это скорее исключение, чем правило. Однако, если у вас есть
библиотека на C, для которой не существует нативного аналога на Go и которую
сложно интегрировать иным способом, а потребность в таком взаимодействии
возникает лишь изредка, использование cgo может стать удобным способом
подключения такой библиотеки.
В более сложных сценариях у вас может возникнуть необходимость в управлении параметрами сборки мусора во время выполнения. Go позволяет делать
это довольно гибко. Например, разрешается задать лимит памяти для сборщика
мусора через переменную окружения GOMEMLIMIT или установить целевой процент
использования памяти сборщиком мусора через переменную GOGC. В последнем
случае сборку мусора можно даже полностью отключить, установив значение
GOGC=off. Однако такие меры требуются крайне редко и должны приниматься
только тогда, когда сборка мусора действительно становится узким местом
в производительности.
Резюме
Механизм рефлексии в Go позволяет исследовать данные во время выполнения, включая их типы и виды. Это дает возможность определить, реализует ли тип определенный интерфейс, допускает ли сравнение или приведение
и многое другое.
Мы можем анализировать аннотации структур и парсить теги для более
эффективного расширения функциональности программ, например для
валидации данных.
С помощью рефлексии мы можем реализовать собственные методы Marshal
и Unmarshal, чтобы кодировать данные примерно так же, как это делают
встроенные кодировщики Go.
Инструмент go generate позволяет нам избежать написания большого объема
шаблонного кода, что особенно полезно при работе с обобщениями.
Мы можем напрямую взаимодействовать с кодом на языке C внутри Goпрограмм, подключая библиотеки, которые не имеют нативной реализации
на Go.
Натан Козыра, Мэтт Батчер, Мэтт Фарина
Go на практике
2-е издание
Перевела с английского В. Корягина
Руководитель дивизиона
Ю. Сергиенко
Руководитель проекта
А. Питиримов
Ведущий редактор
Н. Гринчик
Научный редактор
Д. Бардин
Литературный редактор
А. Аверьянов
Художественный редактор
В. Мостипан
Корректоры
Т. Никифорова, Е. Павлович
Верстка
Г. Блинов
Изготовлено в России. Изготовитель: ООО «Прогресс книга».
Место нахождения и фактический адрес: 194044, Россия, г. Санкт-Петербург,
Б. Сампсониевский пр., д. 29А, пом. 52. Тел.: +78127037373.
Дата изготовления: 12.2025. Наименование: книжная продукция. Срок годности: не ограничен.
Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные
профессиональные, технические и научные.
Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01.
Подписано в печать 26.11.25. Формат 70×100/16. Бумага офсетная. Усл. п. л. 32,250. Тираж 1000. Заказ 0000.
Комьюнити рецензентов
и переводчиков ИТ-литературы
Миссия участников клуба — обеспечить
высокое качество профессиональной
переводной литературы в России.
«Книжные дебагеры» проверяют
корректность терминологии и подписей
на схемах и иллюстрациях, чтобы сделать
книги более понятными русскоязычному
читателю. Стать участником Read IT Club
может любой ИТ-специалист, готовый
поделиться опытом с сообществом.
присоединиться к нам