Содержание урока · 12 разделов
- Сгенерированный клиент: что даёт плагин
- Протокол выбирается опцией, а не новым клиентом
- Протокол и требования к http.Client
- HTTP/2 на практике: TLS и cleartext
- Метаданные: заголовки запроса
- Контекст: таймауты и отмена
- Клиент — это интерфейс: моки и тесты
- Сжатие и лимиты клиента
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В прошлом уроке мы разобрали три протокола на проводе и выяснили главное: один и тот же сгенерированный handler обслуживает и Connect-протокол, и классический gRPC, и gRPC-Web, различая их по Content-Type. Но сервер — это только половина истории: рано или поздно вам понадобится собственный Go-клиент. Сервису заметок понадобится вызывать сервис профилей, воркер — дёргать сервис нотификаций, а CLI-утилита — общаться с вашим API из терминала. Хорошая новость: connect client на стороне Go устроен так же просто, как и сервер, — с ним вы знакомы после урока про первый Connect-сервис. Плагин protoc-gen-connect-go генерирует не только handler-конструктор, но и типизированный клиент с той же сигнатурой методов, что у интерфейса обработчика. Ваша задача — выбрать http.Client, адрес сервера и протокол; всё остальное — кодирование сообщений, маршрутизация по пути, коды ошибок, сжатие — библиотека берёт на себя. Этот урок — connect клиент go в одном месте: сначала базовый вызов, затем выбор протокола и тонкости HTTP/2, потом метаданные, таймауты и отмена, а в финале — универсальный generic-клиент для случаев, когда сгенерированного кода под рукой нет.
Сгенерированный клиент: что даёт плагин#
Для каждого сервиса из .proto-схемы плагин создаёт три вещи. Первая — интерфейс NoteServiceClient с методами, зеркально повторяющими rpc-объявления: GetNote и CreateNote из схемы превращаются в методы с сигнатурой (запрос) — (ответ, error). Вторая — конструктор NewNoteServiceClient(httpClient, baseURL, options...), который собирает реализацию этого интерфейса. Обратите внимание на первый аргумент: это не строка и не специальный канал связи, а самый обыкновенный *http.Client из стандартной библиотеки — тот самый, которым вы ходите в REST-эндпоинты. Третья — интерфейсная проверка на этапе компиляции: если серверная реализация и клиентский интерфейс расходятся со схемой, сборка падает раньше, чем код доедет до прода. Это тот же proto-first контракт, который вы знаете по gRPC: типы генерируются из схемы, а не описываются руками. Конструктор также принимает переменный список опций ClientOption — именно через них чуть позже мы будем выбирать протокол, включать сжатие и подключать перехватчики. Если вы проходили курс по gRPC, сопоставьте это с grpc.NewClient и NewNoteServiceClient из пакета pb: связь с сервером, балансировка и переиспользование соединений там прячутся внутри ClientConn, здесь же роль транспорта играет честный http.Client со всеми его таймаутами, cookie jar-ом и прокси-настройками.
Базовый вызов: четыре строки
GOpackage main
import (
"context"
"log"
"net/http"
notesv1 "example.com/notes/gen/notes/v1"
notesv1connect "example.com/notes/gen/notes/v1/notesv1connect"
)
func main() {
// Первый аргумент — обычный *http.Client, второй — адрес сервера.
client := notesv1connect.NewNoteServiceClient(
http.DefaultClient,
"http://localhost:8080",
)
// Вызов выглядит как обычная функция: запрос, ответ, ошибка.
note, err := client.GetNote(context.Background(), ¬esv1.GetNoteRequest{Id: "n1"})
if err != nil {
log.Fatal(err)
}
log.Print(note.Title)
}Смотрите, сколько здесь нет: нет ручного конструирования URL, нет marshalling в JSON или protobuf, нет разбора кодов ошибок. Конструктор получает http.Client и базовый адрес, метод GetNote — контекст и message-тип запроса, а в ответ приходит message-тип ответа и error. Путь процедуры (/notes.v1.NoteService/GetNote) библиотека выводит из схемы сама — тот же путь, по которому curl стучался в демо-сервер ещё в первом уроке. http.DefaultClient здесь осознанный выбор: для Connect-протокола хватает HTTP/1.1, о чём ниже.
В курсе мы используем классические сигнатуры: метод клиента принимает message и возвращает message. Начиная с свежих релизов connect-go (v1.16, лето 2024), сгенерированный код оборачивает их в connect.Request[T] и connect.Response[T]: вызов выглядит как client.GetNote(ctx, connect.NewRequest(...)), а ответ читается через res.Msg. Идеи урока не меняются — поменялась только упаковка; если ваш go get подтянул новую версию, сверяйте сигнатуры со своим notes.connect.go и актуальной документацией.
Протокол выбирается опцией, а не новым клиентом#
По умолчанию сгенерированный клиент разговаривает на собственном протоколе Connect — том самом, который едет по HTTP/1.1 или HTTP/2 без трейлеров и с осмысленными HTTP-статусами. Если сервер собран на Connect (или на чём-то, что понимает все три протокола, как наш handler из урока про протоколы), ничего настраивать не нужно. А вот чтобы заставить клиента говорить по-другому, есть две опции. connect.WithGRPC() переключает клиента на классический gRPC — полный паритет со спецификацией, включая трейлеры и требование HTTP/2. connect.WithGRPCWeb() включает gRPC-Web — протокол с закодированными в тело трейлерами, который исторически придумали для браузеров, но иногда используют и в Go. Зачем это нужно на практике? Первая причина — окружение: перед вашим сервисом стоит балансировщик или ingress, который понимает только gRPC, и клиент должен вписаться в эту цепочку. Вторая — совместимость: вы вызываете чужой сервис, собранный на чистом grpc-go, и он принимает только application/grpc. В обоих случаях сгенерированный клиент не меняется — меняется лишь опция в конструкторе. Схема, код вызова, обработка ошибок остаются прежними: это и есть обещание «один клиент — три протокола», зеркальное к серверному «один handler — три протокола».
Протокол и требования к http.Client#
| Протокол | Опция | Транспорт | Когда выбирать |
|---|---|---|---|
| Connect (по умолчанию) | не нужна | HTTP/1.1 или HTTP/2, http.DefaultClient подходит | свой Connect-сервер, curl-отладка, максимум простоты |
| gRPC | connect.WithGRPC() | только HTTP/2: TLS-адрес или http2.Transport | чистые gRPC-серверы, строгий ingress, паритет со спецификацией |
| gRPC-Web | connect.WithGRPCWeb() | HTTP/1.1 или HTTP/2 | прокси-цепочки и клиенты, заточенные под gRPC-Web |
Нужно, чтобы сгенерированный клиент общался с чистым gRPC-сервером по протоколу gRPC. Что потребуется?
HTTP/2 на практике: TLS и cleartext#
Пункт про HTTP/2 стоит разобрать по-инженерному, потому что именно здесь новички спотыкаются чаще всего. Ситуация первая: сервер за TLS, адрес начинается с https. Стандартный http.Client умеет переговоры ALPN и поднимет HTTP/2 сам — ничего делать не нужно. Ситуация вторая: локальная разработка, сервер слушает открытый TCP без сертификатов, а клиенту нужен именно gRPC-протокол. Обычный http.Client по http:// пойдёт на HTTP/1.1, и gRPC-вызов развалится. Лечится это транспортом h2c — HTTP/2 без шифрования — из пакета golang.org/x/net/http2: создаёте http2.Transport с AllowHTTP: true и DialTLSContext, который вместо рукопожатия TLS просто открывает TCP-соединение, и оборачиваете его в http.Client. Код ниже — канонический рецепт из документации Connect. Третья ситуация: продакшен за ingress. Здесь правило простое — HTTP/2 должен дожить от клиента до сервера целиком; если посередине стоит терминирующий TLS-балансировщик, проверьте, что и следующий хоп тоже HTTP/2 или что он честно переводит запросы, иначе gRPC-протокол обломается посередине пути. Для Connect-протокола и gRPC-Web этих требований нет: они осознанно спроектированы жить и на HTTP/1.1 — ещё одна причина, почему дефолтный протокол такой удобный.
gRPC-клиент на cleartext HTTP/2
GO// Транспорт HTTP/2 поверх открытого TCP (h2c) для локальной разработки.
transport := &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
}
client := notesv1connect.NewNoteServiceClient(
&http.Client{Transport: transport},
"http://localhost:8080",
// Единственная connect-специфика: говорим по-настоящему gRPC-протоколом.
connect.WithGRPC(),
)DialTLSContext звучит странно, но смысл честный: http2.Transport всегда открывает соединение через функцию дозвона, а AllowHTTP разрешает ему не делать TLS-рукопожатие. Дальше обычный http.Client с этим транспортом и опция connect.WithGRPC(). Если сервер за TLS — вся эта конструкция не нужна: DefaultClient с https-адресом поднимет HTTP/2 через ALPN автоматически.
Клиент просит протокол gRPC, но транспорт остался DefaultClient. Чем закончится вызов?
Прочитайте код и выберите, что он выведет:
// Сервер — наш Connect-handler: понимает Connect, gRPC и gRPC-Web.
client := notesv1connect.NewNoteServiceClient(
http.DefaultClient, // без http2.Transport
"http://localhost:8080",
connect.WithGRPC(),
)
note, err := client.GetNote(ctx, ¬esv1.GetNoteRequest{Id: "n1"})Метаданные: заголовки запроса#
Редкий вызов живёт без метаданных: трассировочные заголовки, идентификатор запроса, токен доступа — всё это должно доехать от клиента до handler-а. В gRPC для этого существовали metadata.NewOutgoingContext и md.Pairs — мы подробно разбирали эту механику в уроке про метаданные и контекст курса gRPC. В Connect философия проще: метаданные — это обычные HTTP-заголовки, без промежуточного типа. Для сгенерированного клиента Unary-вызов принимает message напрямую, поэтому вкладывать заголовки в каждый вызов он не умеет — их добавляет перехватчик, о котором поговорим в седьмом уроке: клиентский interceptor навешивает Authorization на каждый исходящий запрос в одном месте. А вот у универсального generic-клиента, к которому мы сейчас переходим, запрос — это полноценный объект connect.NewRequest с методом Header(), и заголовки ставятся прямо на нём. Заметьте симметрию с сервером: handler читает их через req.Header().Get — один и тот же map, одна и та же семантика, никакого нового словаря. Именно поэтому между gRPC и Connect так просто переносятся трассировка и авторизация: стандартные заголовки остаются стандартными заголовками.
Generic-клиент: connect.NewClient и CallUnary
GOpackage main
import (
"context"
"log"
"net/http"
"connectrpc.com/connect"
notesv1 "example.com/notes/gen/notes/v1"
)
func main() {
// Generic-клиент: типы запроса и ответа — параметрами, путь — строкой.
client := connect.NewClient[notesv1.GetNoteRequest, notesv1.Note](
http.DefaultClient,
"http://localhost:8080/notes.v1.NoteService/GetNote",
)
// Запрос — объект: у него есть Header() для метаданных.
req := connect.NewRequest(¬esv1.GetNoteRequest{Id: "n1"})
req.Header().Set("X-Request-Source", "cli")
res, err := client.CallUnary(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Print(res.Msg.Title)
}connect.NewClient — универсальный клиент из самого пакета connectrpc.com/connect: вместо сгенерированного интерфейса он параметризуется типами запроса и ответа, а полный путь процедуры задаётся строкой. Метод CallUnary принимает connect.NewRequest и возвращает *connect.Response[Note], где ответ живёт под полем Msg. Зачем он нужен? Во-первых, для сервисов без protobuf-схем или при сырых вызовах. Во-вторых — в тестах и инструментах, где хочется дёргать произвольные процедуры. В-третьих, как здесь: когда нужны заголовки на конкретном вызове.
Когда сгенерированного клиента недостаточно и уместен generic connect.NewClient?
Контекст: таймауты и отмена#
Контекст в Connect — не церемониальный параметр, а полноценный канал управления вызовом, точно так же, как и в gRPC и в обычном net/http. context.WithTimeout ограничивает время жизни вызова: если сервер не успел ответить до истечения дедлайна, клиент возвращает ошибку с кодом deadline_exceeded — и это не локальная имитация, а честный обрыв обеих сторон. Важно понимать, что дедлайн едет по проводу: gRPC передаёт его заголовком grpc-timeout, а у Connect-протокола для unary-вызовов есть собственный заголовок connect-timeout-ms — сервер видит дедлайн и старается уложиться, а если не успевает, возвращает соответствующий код. Отмена работает так же: context.WithCancel плюс cancel() по крестику в интерфейсе — и обе стороны получают уведомление, что продолжать смысла нет; серверный хендлер, который уважает ctx.Done(), перестанет гореть напрасно. Практические привычки из мира net/http здесь полностью переносимы: дедлайн ставьте на каждый вызов, а не только на «долгие»; значения таймаута наследуйте от SLA вышестоящего запроса, а не от потолка сервера; никогда не игнорируйте возвращённый cancel — иначе утечёте горутиной. Если вы привыкли к правилам курса net/http — здесь они без изменений, и это большой подарок по сравнению с собственным тайм-менеджментом gRPC-клиента.
Клиент — это интерфейс: моки и тесты#
Ещё одно следствие proto-first подхода, которое вы оцените ближе к проекту: сгенерированный клиент — это интерфейс, а не конкретный тип. Там, где ваш сервис зависит от NoteServiceClient, вы принимаете интерфейс и спокойно подставляете фейк в тестах — ровно та же дисциплина, которой учит любой зрелый Go-проект. Возьмите за правило: бизнес-логика не собирает http.Client и не знает адресов — конструктор клиента вызывается в main, а вниз по слоям передаётся уже интерфейс. Если хочется границу провести ещё жёстче, определите свой маленький интерфейс потребителя с одним-двумя методами — GetNote, CreateNote — и адаптируйте сгенерированный клиент к нему в композиционном корне; на этапе тестирования подмените его ручной заглушкой, которая возвращает фиксированную заметку. Полноценную механику мок-клиентов, httptest-серверы и приёмы интеграционных тестов мы отдельно разберём в шестнадцатом уроке про тестирование Connect-сервисов — там пригодится и generic-клиент из этого урока, потому что он позволяет строить лёгкие in-memory заглушки без сети. Пока запомните принцип: зависимость от клиента объявляет потребитель, реализация приходит снаружи. Это тот же шов, вокруг которого строились тесты в курсах Gin и Echo, только типы теперь генерируются из схемы.
Сервер вернул not_found на вызов GetNote. Что именно получит клиент в Go?
Соберите базовый вызов клиента
Восстановите конструктор сгенерированного клиента, имя метода и проверку ошибки.
Путь unary-вызова от main до ответа
Расставьте по порядку, что происходит между строкой client.GetNote и получением ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Сжатие и лимиты клиента#
Две настройки клиента, которые стоит знать до того, как заметки вырастут до больших сообщений. Первая — сжатие: по умолчанию клиент декларирует, что умеет читать gzip, а включить его на отправку можно опцией connect.WithGZIP() (в свежих версиях библиотеки она разделена на WithSendGzip и WithAcceptGzip). Библиотека сравнивает допустимое сжатие с тем, что объявил сервер, и выбирает рабочий вариант автоматически — в этом преимущество протокола, который договаривается по-HTTP-ному, через Accept-Encoding. Вторая — лимиты размера: опции WithSendMaxBytes и WithReadMaxBytes ограничивают, сколько клиент готов отправить и принять, и защищают от сюрпризов вида «один отчёт на двести мегабайт положил и клиент, и сервер». Симметричные настройки есть и у handler-а — там мы их подробно разберём в уроке про сжатие и производительность, вместе с HTTP/2-нюансами больших стримов. Здесь зафиксируйте принцип: лимиты ставят на обоих концах, клиент — чтобы не отправить лишнего и не принять внезапный поток, сервер — чтобы не принять его. Ошибки превышения приезжают клиенту тем же *connect.Error с кодом resource_exhausted — первое пересечение этой пары уроков, но далеко не последнее.
Код уроков — настоящий Go с protobuf-кодогенерацией: сетевые вызовы и кодогенерация браузерная песочница сайта исполнить не может, поэтому runner-блоков в курсе нет — для практики нужен локальный Go и проект, собранный во втором уроке. Интерактивы (тесты, «предскажи исход», сборка по строкам, карточки) работают прямо на странице и дают XP, а код с построчными разборами можно копировать в свой проект.
Карточки: клиент Connect
Что дальше по курсу#
Мы научились вызывать Connect-сервисы из Go: конструктор клиента, три протокола, транспорт, метаданные, контекст. Но любой реальный вызов рано или поздно заканчивается не ответом, а ошибкой — и здесь у Connect есть свой словарь: шестнадцать кодов gRPC, их отображение в HTTP-статусы, *connect.Error с методами Code и Message и правила выбора кода, о которые спотыкаются даже опытные команды. Следующий урок — «Обработка ошибок» — целиком посвящён этой стороне жизни: от connect.NewError в хендлере до корректной обработки на клиенте. После ошибок возьмёмся за перехватчики — единое место для логирования, метрик и авторизации на обоих концах провода; затем стриминг, браузерные клиенты и buf-тулчейн. Программа всех двадцати пяти уроков живёт на странице курса. А если хочется сравнить клиентский опыт с классикой — загляните в урок про gRPC-клиент из курса по gRPC: как увидите, переход почти незаметен, и это лучший показатель качества API Connect.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Плагин генерирует и сервер, и клиент: NewNoteServiceClient(httpClient, baseURL, options...) возвращает типизированный интерфейс по rpc-объявлениям схемы.
- По умолчанию клиент говорит протоколом Connect; опции connect.WithGRPC() и connect.WithGRPCWeb() переключают протокол без смены схемы и кода вызова.
- gRPC-протокол требует HTTP/2: https-адрес поднимает его сам, для cleartext нужен http2.Transport с AllowHTTP и подменённым DialTLSContext.
- Метаданные — обычные HTTP-заголовки: generic-клиент ставит их через connect.NewRequest().Header(), сгенерированный — через клиентский interceptor.
- Дедлайны и отмена живут в контексте и едут по проводу: connect-timeout-ms у Connect-протокола, grpc-timeout у gRPC; обе стороны останавливаются.
- Любая ошибка вызова — это *connect.Error: код, сообщение и цепочка оригинала; разбор кодов — тема следующего урока.
- Generic connect.NewClient[Req, Res] с CallUnary закрывает вызовы без сгенерированного кода: тесты, инструменты, произвольные пути.
Домашнее задание
Соберите рабочий сценарий «клиент + сервер» на базе notes.v1 из третьего урока: сервер запускается на localhost:8080, клиентская программа в отдельном пакете cmd/client вызывает GetNote через http.DefaultClient и печатает заголовок заметки. Затем проделайте три эксперимента: включите connect.WithGRPC() без http2.Transport и зафиксируйте ошибку транспорта; добавьте http2.Transport с AllowHTTP и убедитесь, что вызов по gRPC-протоколу проходит; поставьте context.WithTimeout на 1 наносекунду и понаблюдайте код deadline_exceeded. Напишите в комментариях к коду, какой протокол выбрали бы для внутреннего CLI-инструмента и почему — эта формулировка пригодится в итоговом проекте курса.
Личные заметкиПусто
Вопросы и ответы по уроку
Какой http.Client использовать для Connect-клиента?
Для Connect-протокола и gRPC-Web достаточно http.DefaultClient — они живут на HTTP/1.1 и HTTP/2. Для протокола gRPC нужен HTTP/2: на https сработает DefaultClient, для локального cleartext соберите http.Client с http2.Transport и AllowHTTP.
Как передать заголовки (токен, request id) в unary-вызов?
У generic-клиента — через connect.NewRequest и req.Header().Set. У сгенерированного клиента unary-метод принимает только message, поэтому заголовки ставит клиентский interceptor через WithInterceptors — разберём в седьмом уроке.
Можно ли одним клиентом общаться и с gRPC-сервером, и с Connect-сервером?
Да, но по разным правилам: с Connect-сервером — дефолтным протоколом, с чистым gRPC-сервером — опцией WithGRPC и HTTP/2-транспортом. Если сервер собран на Connect, он сам понимает все три протокола, и клиент настраивать не нужно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding