Урок первый показал код Connect-сервера как плакат — целиком, без разбора, и пообещал: третий урок соберёт первый Connect-сервис целиком и разберёт каждый элемент. Обещание выполняю, но с одним поворотом: вместо сухого hello world с методом Ping мы построим hello world настоящий — ElizaService, болтуна-«психотерапевта», которого официальная документация Connect использует как первого гостя в каждом гайде. Почему именно он: метод Say принимает предложение и возвращает предложение — минимальная форма unary-вызова «запрос — ответ», ноль побочных эффектов, ноль состояния, и при этом есть живой референс на demo.connectrpc.com, к которому мы уже прикладывали curl в первом уроке. План урока такой: дописываем схему eliza.v1 в проект из прошлого урока, прогоняем buf generate, реализуем handler с ответами болтуна, монтируем его в тот же mux рядом с NoteService, проверяем curl-ом — а затем возвращаемся к notes-серверу и разбираем его по косточкам вместе с клиентом: интерфейс handler-а, встроенная Unimplemented-заглушка, конверты запросов и коды ошибок на стороне клиента.
ElizaService: hello world официальной документации#
Немного истории, потому что она объясняет, почему RPC-фреймворк шестидесятых лет не существовал, а болтун — да. ELIZA написала Джозеф Вейценбаум в MIT в 1966 году: программа переформулировала фразы пациента в вопросы и отвечала на них — примитивный имитатор психотерапевта, названный в честь Элизы Дулиттл из «Пигмалиона». Пользователи засыпали её признаниями, и этот феномен даже получил собственное имя — «эффект Элизы». Демо-сервер connectrpc.com держит образцовую реализацию connectrpc.eliza.v1.ElizaService с тремя методами: Say (unary — реплика на реплику), Introduce (тоже unary) и Converse (двунаправленный стрим). Для первого сервиса нужен только Say: в документации Connect эту пару «Say плюс curl» так и используют — это connectrpc hello world проекта, и мы воспроизведём его локально, своими руками. Обратите внимание на дисциплину схемы: service ElizaService в пакете connectrpc.eliza.v1, один rpc Say, message-типы SayRequest и SayResponse с единственным строковым полем sentence. Никаких connect-специфичных опций в схеме нет и не будет: каким протоколом клиент поедет к сервису — решается на стороне вызова, схеме всё равно.
Схема eliza/v1/eliza.proto
GOsyntax = "proto3";
package connectrpc.eliza.v1;
option go_package = "example.com/notes/gen/eliza/v1;elizav1";
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}
service ElizaService {
rpc Say (SayRequest) returns (SayResponse);
}Файл кладём в proto/eliza/v1/eliza.proto — правило PACKAGE_DIRECTORY_MATCH из прошлого урока требует, чтобы путь повторял package. go_package указывает пакет elizav1 по пути example.com/notes/gen/eliza/v1: то же соглашение, что у notes.v1, только версия пакета живёт в отдельном дереве каталогов, чтобы сервисы не мешали друг другу эволюционировать. Поле sentence под номером 1 и в запросе, и в ответе — нумерация полей в protobuf обязательна, имена должны совпадать по смыслу, но живут независимо.
buf generate: те же плагины, новый пакет#
Никакой новой настройки не требуется — buf.gen.yaml из урока об установке уже описывает оба плагина и каталог gen, buf.yaml объявляет модуль path: proto, значит, новая схема в proto/eliza/v1/ попадает в модуль автоматически. buf generate соберёт четыре файла: gen/eliza/v1/eliza.pb.go с message-типами SayRequest и SayResponse от protoc-gen-go, и gen/eliza/v1/elizav1connect/eliza.connect.go от protoc-gen-connect-go — с интерфейсом ElizaServiceHandler, встроенной заглушкой UnimplementedElizaServiceHandler, конструктором NewElizaServiceHandler и типизированным клиентом NewElizaServiceClient. Обратите внимание на имя пакета: elizav1connect — суффикс connect добавляется к имени protobuf-пакета elizav1, точно так же, как notesv1connect строился от notesv1. buf lint, кстати, тоже подхватит новую схему без правок конфига — версии пакета и каталог уже сходятся. Это одна из тихих радостей buf-а: добавление сервиса в проект — это копирование файла схемы и одна команда генерации, вся конфигурация остаётся на месте.
Что именно появилось в gen/eliza/v1/elizav1connect/eliza.connect.go?
Реализация: elizaServer
GOpackage main
import (
"context"
elizav1 "example.com/notes/gen/eliza/v1"
elizav1connect "example.com/notes/gen/eliza/v1/elizav1connect"
)
// Реализация ElizaService — болтун уровня 1966 года.
type elizaServer struct {
// Заглушка: новые rpc в схеме не сломают компиляцию.
elizav1connect.UnimplementedElizaServiceHandler
}
// Классические рефлексы ELIZA: переспросить и переформулировать.
var replies = []string{
"How long have you felt this way?",
"Why do you say that?",
"What would it mean if that were true?",
}
func (s *elizaServer) Say(ctx context.Context, req *elizav1.SayRequest) (*elizav1.SayResponse, error) {
// «Анализ» детерминированный: фразу выбирает длина ввода.
reply := replies[len(req.Sentence)%len(replies)]
return &elizav1.SayResponse{Sentence: reply}, nil
}Это ВСЁ, что нужно реализовать: один метод с сигнатурой, которую продиктовала схема — контекст, типизированный запрос, типизированный ответ и ошибка. Указатель на структуру, метод на указателе, встроенная заглушка — та же форма, что у gRPC-сервера, только типы из пакета elizav1. Ответ болтуна детерминированный: длина входной фразы делится по модулю на три реплики, поэтому «I feel happy.» (13 символов) всегда получает replies[1] — «Why do you say that?». Детерминизм здесь не дурной тон, а удобство тестов: curl-проверка ниже повторяема, и вы можете писать httptest-тест на точный ответ уже сегодня.
main: два сервиса на одном mux
GOfunc main() {
mux := http.NewServeMux()
// Первый сервис: ElizaService на /connectrpc.eliza.v1.ElizaService/
path, handler := elizav1connect.NewElizaServiceHandler(&elizaServer{})
mux.Handle(path, handler)
// Второй на том же порту: NoteService из уроков 01-02.
notesPath, notesHandler := notesv1connect.NewNoteServiceHandler(¬esServer{})
mux.Handle(notesPath, notesHandler)
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}Форма та же, что в первом уроке, но теперь в mux живут два сервиса: каждый конструктор вернул свой путь и свой handler, каждый смонтирован одной строкой. Пути не пересекаются, потому что они выведены из package и имени сервиса: /connectrpc.eliza.v1.ElizaService/ и /notes.v1.NoteService/. Никакого реестра сервисов, никаких портов на сервис: столько mux.Handle, сколько схем. ListenAndServe — обычный net/http со всеми его последствиями: поверх этого же mux работают middleware, /healthz, статика и любые другие HTTP-маршруты вашего приложения. Структуру notesServer мы разберём ниже в полном разборе — это та же реализация из кода первого урока.
Тот же вызов, но метод набрали строчными: /say вместо /Say. Что вернёт сервер?
Прочитайте код и выберите, что он выведет:
$ curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8080/connectrpc.eliza.v1.ElizaService/saycurl-проверка: RPC без SDK
GO$ curl -i \
--header "Content-Type: application/json" \
--data '{"sentence": "I feel happy."}' \
http://localhost:8080/connectrpc.eliza.v1.ElizaService/Say
HTTP/1.1 200 OK
Content-Type: application/json
{"sentence":"Why do you say that?"}Вот он, первый живой вызов: POST по пути из схемы, JSON-тело без обёрток и фреймов, HTTP 200 со смыслом «вызов удался». Content-Type application/json включил собственный протокол Connect — тот самый, что дружит с curl и devtools; заголовок Content-Type: application/grpc превратил бы тот же вызов в классический gRPC, но руками его не собрать, и об этом — следующий урок. Ответ повторил ввод детерминированно: 13 символов в фразе дали replies[1]. Сравните с демо из первого урока: там тот же метод Say отвечал на demo.connectrpc.com, теперь у вас такой же сервис на localhost:8080.
Анатомия вызова Say#
| Элемент вызова | Что видно на проводе | Откуда берётся |
|---|---|---|
| Путь | /connectrpc.eliza.v1.ElizaService/Say | package + имя сервиса + имя rpc из схемы, регистрозависим |
| HTTP-метод | POST | unary-вызовы Connect шлют POST по умолчанию |
| Content-Type | application/json | выбор протокола и формата: здесь собственный протокол Connect, JSON |
| Тело запроса | {"sentence": "I feel happy."} | SayRequest, сериализованный выбранным кодеком, без фреймов |
| HTTP-статус ответа | 200 OK | итог unary-вызова; ошибка была бы 400/404/... по таблице кодов урока 01 |
| Тело ответа | {"sentence": "Why do you say that?"} | SayResponse в том же формате, что и запрос |
Полный разбор notes-сервера из первого урока#
Обещание первого урока — пора выполнять: разбираем тот файл построчно, теперь когда под рукой есть второй пример для сравнения. Начнём с интерфейса NoteServiceHandler из notes.connect.go: у него по методу на каждый rpc схемы — GetNote и CreateNote — с сигнатурой (context.Context, notesv1.GetNoteRequest) (notesv1.Note, error); типы запроса и ответа — сгенерированные message-типы, никаких обёрток. Дальше заглушка UnimplementedNoteServiceHandler: она встраивается в вашу структуру не для галочки, а ради обратной совместимости. Схема вырастет — добавится rpc DeleteNote, плагин перегенерирует интерфейс с третьим методом, и код с заглушкой продолжит компилироваться: незанятые методы ответят ошибкой unimplemented (HTTP 501 из таблицы первого урока) вместо красной сборки. Уберёте заглушку — получите ошибку компиляции при каждом расширении схемы, что честно, но болезненно. Структура notesServer — обычный Go-тип с указателем-приёмником: метод GetNote возвращает ¬esv1.Note{Id: req.Id, Title: "Первая заметка"}, и здесь же место для вашей логики — хранилище в полях структуры, мьютексы, кеш, что угодно; состояние сервиса живёт в вашей структуре, а не в сгенерированном коде. Наконец main: конструктор NewNoteServiceHandler вернул строку пути и handler, mux смонтировал, ListenAndServe слушает — всё это обычный net/http, который вы писали до всякого RPC.
Зачем встраивать UnimplementedNoteServiceHandler в свою реализацию?
Клиент: NewNoteServiceClient
GOfunc callNotes() {
client := notesv1connect.NewNoteServiceClient(http.DefaultClient, "http://localhost:8080")
res, err := client.GetNote(context.Background(),
connect.NewRequest(¬esv1.GetNoteRequest{Id: "1"}))
if err != nil {
// Код ошибки RPC достаётся из err без парсинга статусов.
if connect.IsError(err, connect.CodeNotFound) {
log.Print("нет такой заметки")
return
}
log.Fatal(err)
}
log.Print(res.Msg.Title) // Первая заметка
}Клиент симметричен серверу: конструктор NewNoteServiceClient принимает обычный http.Client и адрес сервера, а методы повторяют rpc схемы с типизированными аргументами. connect.NewRequest — конверт, который несёт ваше сообщение плюс место под заголовки; ответ приходит конвертом connect.Response, само сообщение — в res.Msg. Ошибки: при неудаче err != nil и res == nil, а код RPC достаётся функцией connect.IsError или connect.CodeOf — никакой возни с HTTP-статусами руками. Проверьте связку: сервер из первого урока возвращает заглушку с Title «Первая заметка», и клиент печатает ровно её. Для сравнения: первый gRPC-клиент в одноимённом курсе строился вокруг grpc.Dial и долгоживущего соединения — здесь же клиенту достаточно http.Client, потому что транспорт обычный HTTP.
Разбор клиента по аргументам#
Каждый аргумент клиента — точка настройки, пройдём по ним. Первый аргумент — http.Client: можно оставить http.DefaultClient, а можно принести свой с таймаутами, транспортным слоем и джар-файлами; всё, что вы знаете про HTTP-клиент из продвинутого курса Go, применяется без адаптеров, потому что Connect ходит обычным HTTP. Адрес сервера — второй аргумент: корень без пути сервиса, конструктор сам приклеит /notes.v1.NoteService/GetNote; для локальной отладки это http://localhost:8080, в продакшене — адрес балансировщика. Третий элемент — connect.NewRequest: конверт запроса, куда кроме сообщения можно положить заголовки; дедлайн вызова задаётся не клиентом, а контекстом — context.WithTimeout вокруг вызова, ровно как в уроке про контекст продвинутого курса. Ответ: connect.Response с полем Msg — типизированный Note; при ошибке Msg не существует, есть err. И последний нюанс: клиент thread-safe, один экземпляр можно держать на всё приложение и делить между горутинами — создавать его на каждый вызов не нужно.
GetNote вернула такой ответ. Что увидит клиент после вызова client.GetNote?
Прочитайте код и выберите, что он выведет:
// Сервер, метод GetNote:
return nil, connect.NewError(connect.CodeNotFound,
errors.New("заметка не найдена"))Соберите вызов GetNote из клиентского кода: конструктор, метод, конверт и поле ответа.
Соберите путь от схемы eliza.proto до JSON-ответа в терминале — по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Интерактивы работают прямо на странице и дают XP, а curl-проверка и сервер — только локально: песочница сайта сетевой код не исполняет, поэтому исполняемых блоков в курсе нет. Скопируйте код в cmd/server/main.go из прошлого урока, добавьте файлы из этого — и повторите всю цепочку от buf generate до curl. Одна проверка занимает минуты, а понимание проводов приходит только руками.
Где должна жить логика Say и GetNote — и что можно править в gen/?
Карточки: первый сервис
Что дальше по курсу#
Первый сервис работает, и он уже умеет больше, чем кажется: один и тот же handler обслуживает curl с JSON, gRPC-клиентов и браузер — но пока вы верите в это на слово. Следующий урок — «Протоколы: gRPC, gRPC-Web, Connect» (04-protokoly) — спустит нас на уровень проводов: разберём, как handler выбирает протокол по Content-Type, из чего состоят фреймы gRPC, куда gRPC-Web прячет трейлеры и почему у Connect для unary-вызовов есть даже GET-кеш. Дальше по курсу: пятый урок — клиенты и их настройки, шестой — ошибки и connect.NewError в полную силу, седьмой — interceptors, девятый — браузерные клиенты на TypeScript. Домашняя работа урока небольшая, но не пропускайте её: соединить три файла руками — лучший способ запомнить, где чья ответственность.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Первый сервис — ElizaService, официальный hello world Connect: схема с rpc Say, сгенерированный handler, реализация болтуна, curl-проверка.
- Кодогенерация не потребовала новых настроек: схема в proto/eliza/v1/, пакеты elizav1 и elizav1connect собираются теми же buf.yaml и buf.gen.yaml.
- Реализация — структура с встроенной Unimplemented-заглушкой и методом Say; сигнатуру диктует схема, состояние живёт в ваших полях.
- main: каждый конструктор возвращает путь и handler; два сервиса монтируются в один ServeMux и живут на localhost:8080.
- Путь /package.Service/Method регистрозависим: /Say отвечает 200 JSON-ом, /say упадёт в 404.
- Клиент NewNoteServiceClient(httpClient, baseURL) типизирован message-типами; запрос — connect.NewRequest, ответ — res.Msg, коды ошибок — connect.IsError.
- В gen/ ничего не правим: логика — в вашей реализации, схема — первоисточник контракта, генерат перегенерируется.
Домашнее задание
Соберите оба сервиса в одном main: добавьте proto/eliza/v1/eliza.proto из урока, прогоните buf generate и buf lint (должны молчать), реализуйте elizaServer и смонтируйте ElizaService рядом с NoteService на localhost:8080. Проверьте curl-ом три сценария: успешный Say с фразой «I feel happy.» (ожидаем Why do you say that?), путь /say строчными (ожидаем 404) и GetNote с несуществующим id, добавив в свою реализацию возврат connect.NewError(CodeNotFound, ...) — убедитесь, что connect.IsError у клиента ловит именно этот код. Запишите в заметки, какой заголовок и какой путь использовал каждый запрос — это входной билет на следующий урок про протоколы.
Личные заметкиПусто
Вопросы и ответы по уроку
Как написать первый Connect-сервис на Go?
Добавить .proto-схему сервиса в proto/, прогнать buf generate, реализовать сгенерированный интерфейс (структура с Unimplemented-заглушкой), смонтировать NewElizaServiceHandler или аналогичный конструктор в http.ServeMux и запустить http.ListenAndServe. Проверка — curl с JSON по пути /package.Service/Method.
Что такое ElizaService в документации Connect?
Демонстрационный сервис connectrpc.eliza.v1 — болтун-«психотерапевт» отсылкой к ELIZA 1966 года; метод Say принимает предложение и возвращает ответ. Это официальный hello world Connect-а: живой пример на demo.connectrpc.com и первый сервис в гайдах.
Чем Connect handler отличается от gRPC-сервера?
Connect handler — обычный http.Handler: монтируется в ServeMux, работает под net/http, не требует grpc.NewServer и своего транспорта. Сигнатуры методов и message-типы те же, что у gRPC, но транспорт, регистрация и клиенты — стандартные HTTP.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding