Урок курса

Один ключ для всех попыток создания

Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker

LabelOperation как единственная идентичность запроса: сборка и отправка

В уроке 6.1 клиент сохранял LabelOperation с полями RequestID и ParcelID до отправки любого запроса. Здесь тот же объект становится источником всех данных при каждой попытке — и тела, и заголовка Idempotency-Key. Это осознанный контракт: у намерения одна идентичность в JSON и в lookup-ключе одновременно.

RequestID — это идентификатор операции, не посылки. ParcelID обозначает объект, над которым совершается действие. Два поля независимы: разные RequestID при одинаковом ParcelID означают разные операции, а не повтор одной. Сервер не налагает уникальности этикетки на уровне посылки — это отдельное бизнес-правило, которое здесь не реализуется.

В этой задаче предоставленный тип имеет JSON-теги, задающие точные имена полей тела. Прежний тип без тегов сериализовался бы с именами RequestID и ParcelID; здесь это дополнение вводится явно:


type LabelOperation struct {
    RequestID string `json:"request_id"`
    ParcelID string `json:"parcel_id"`
}

Клиент собирает запрос из сохранённой op так. Фрагмент иллюстративный, компиляция здесь не проверялась:


import (
    "bytes"
    "context"
    "encoding/json"
    "net/http"
)

func buildRequest(ctx context.Context, op LabelOperation) (*http.Request, error) {
    body, err := json.Marshal(op)
    if err != nil {
        return nil, err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.com/labels", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", op.RequestID)
    return req, nil
}

При каждой повторной попытке вызов buildRequest с той же op даёт тот же заголовок Idempotency-Key и то же тело — потому что op.RequestID не меняется между попытками. Именно это и является контрактом: ключ привязан к намерению, а не к факту доставки.

Здесь нет цикла retry, backoff и бюджета попыток — это тема следующих уроков. Фрагмент выше показывает только сборку одного запроса из сохранённой операции.

Важно понять ограничение: сам по себе заголовок Idempotency-Key ничего не гарантирует. Если реплики сервера хранят состояние независимо друг от друга, один и тот же ключ может прийти к разным экземплярам и каждый создаст этикетку. Повторяемость обеспечивает только общий устойчивый реестр с атомарным занятием ключа. Заголовок — лишь носитель ключа, а не механизм дедупликации.

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

Валидация полей и вычисление fingerprint через SHA-256

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

Порядок проверок фиксирован: путь → метод → тип содержимого → размер тела до декодирования → JSON, поля и ключ → Claim. GET /other получает 404, даже если другие части запроса неверны.

Проверка Ответ при нарушении
Путь ровно /labels 404, {"error":"not_found"}
Метод POST 405, Allow: POST, {"error":"method_not_allowed"}
Content-Type application/json, параметры допустимы 415, {"error":"unsupported_media_type"}
Тело не больше 4096 байт 413, {"error":"body_too_large"}
JSON, поля и ключ 400, {"error":"invalid_request"}

Каждый ответ имеет Content-Type: application/json и точные указанные байты тела без завершающей новой строки. Все ветки отказа до Claim обходятся без store и callbacks. mime.ParseMediaType позволяет принять application/json; charset=utf-8. Размер можно проверить чтением максимум 4097 байт до декодирования: дополнительный байт обнаруживает превышение; слишком большое невалидное тело также получает 413. Фрагменты здесь иллюстративные и не компилировались.

Допустимый request_id и parcel_id — непустые строки из символов [A-Za-z0-9_-]. Отсутствующее поле, null, число, булево значение, пустая строка или символ вне алфавита — всё это 400 {"error":"invalid_request"}. Объект должен содержать ровно два поля с точными именами; лишнее поле или второй JSON-объект после первого — тоже невалидный запрос. Объекты с повторяющимися именами полей или с именами, отличающимися от request_id/parcel_id только регистром, исключены из входов задачи: их обработка не входит в условия и оценивание задачи.

Заголовок Idempotency-Key обязан присутствовать и быть равен request_id из тела. Несовпадение — 400, потому что ключ и тело описывают разные намерения, а это невалидный запрос на уровне протокола.

После успешной валидации вычисляется fingerprint:


import (
    "crypto/sha256"
    "encoding/hex"
)

func fingerprint(requestID, parcelID string) string {
    h := sha256.Sum256([]byte(requestID + "\n" + parcelID))
    return hex.EncodeToString(h[:])
}

sha256.Sum256 возвращает [32]byte; h[:] превращает массив в срез для hex.EncodeToString. Результат — 64 символа нижнего регистра. Для request_id="label-req-7" и parcel_id="parcel-42" функция даст один и тот же хеш независимо от того, было ли тело {"request_id":"label-req-7","parcel_id":"parcel-42"} или {"parcel_id":"parcel-42", "request_id" : "label-req-7"} — потому что fingerprint считается от нормализованных значений полей, а не от сырых байт JSON. Порядок полей и пробелы в теле не влияют на хеш.

Разделитель "\n" однозначен: валидированные поля не содержат символа новой строки, поэтому конкатенация "ab\ncd" и "a\nb\ncd" невозможна при проверенных входах.

Fingerprint — средство сравнения, не криптографическая подпись. Он позволяет сказать «это то же намерение» или «намерение изменилось», но не доказывает подлинность клиента. Если при существующем ключе fingerprint не совпадает — store возвращает ClaimConflict ещё до проверки статуса записи (pending или completed). Это значит: попытка переиспользовать ключ для другой посылки блокируется немедленно, без шанса «перезаписать» операцию.

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

Атомарное занятие ключа: четыре ветки Claim и воспроизведение сохранённого ответа

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

Логика проверки внутри store устроена так: сначала сравнивается fingerprint, и только при совпадении — статус записи. Несовпадение fingerprint при любом статусе (pending или completed) возвращает ClaimConflict. Совпадающий fingerprint при pending-записи — ClaimPending. Совпадающий fingerprint при completed-записи — ClaimReplay с сохранённым ответом. Отсутствие ключа — ClaimNew с созданием pending-резервации.

В обработчике ctx берётся из r.Context() и передаётся в Claim, Admit, Create, Complete и Release без создания context.Background(). Ниже иллюстративный фрагмент после валидации; компиляция здесь не проверялась:

Повтор с тем же ключом возвращает прежний результат. Та же нормализованная нагрузка. Другой payload с тем же ключом даёт предметный конфликт; replay сохраняет существенные headers.
Та же нормализованная нагрузка. Другой payload с тем же ключом даёт предметный конфликт; replay сохраняет существенные headers.

Статичная схема (PNG)


ctx := r.Context()
cr := store.Claim(ctx, op.RequestID, fingerprint(op.RequestID, op.ParcelID))
switch cr.Kind {
case ClaimNew:
    // admission + create + complete + write
case ClaimPending:
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(409)
    w.Write([]byte(`{"error":"request_pending"}`))
case ClaimReplay:
    saved := cr.Response
    for k, vals := range saved.Header {
        for _, v := range vals {
            w.Header().Add(k, v)
        }
    }
    w.WriteHeader(saved.Status)
    w.Write(saved.Body)
case ClaimConflict:
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(409)
    w.Write([]byte(`{"error":"idempotency_conflict"}`))
}

ClaimPending и ClaimConflict возвращают 409, но с разными телами. request_pending — принятая незавершённая операция, не отказ действия; повтор не получает права выполнить Create. Pending означает резервацию или незавершённую операцию, а не доказательство текущей работы: ключ занимается до Admit и может остаться занятым после падения. idempotency_conflict — попытка переиспользовать ключ для другого намерения; store ничего не меняет.

Replay принципиально отличается от обоих: это не ошибка, а повторная доставка уже сохранённого результата. Обработчик не вызывает Admit, Create, Complete или Release — только записывает в ResponseWriter то, что store уже хранит.

Запись заголовков через Header.Add в цикле по значениям критична: предоставленный учебный ответ содержит несколько значений ETag. Каждый Header.Set заменяет существующий список, поэтому цикл оставит только последнее значение. Это условие учебного примера, а не общее правило о многозначном ETag в реальном HTTP. Сохранённый Header принадлежит store — обработчик не должен его модифицировать. При вызове Complete store делает глубокую копию Response, включая []byte тела и http.Header, чтобы последующая запись в ResponseWriter не испортила сохранённое состояние.

Пример того, как должен выглядеть сохранённый Response от Create:


Status:  201
Header:  Content-Type: application/json
         Location: /labels/label-901
         ETag: "label-901-v1"
Body:    {"request_id":"label-req-7","state":"created","label_id":"label-901"}

При replay клиент получает точно те же байты тела, тот же статус и все значения заголовков — без нового вызова Create и без нового label_id. Это и есть смысл idempotency: сервер отвечает «я уже сделал это» вместо «делаю ещё раз».

Реальный реестр должен быть общим для всех реплик сервиса и устойчивым к рестарту. Локальная map с mutex не даёт ни того, ни другого: после рестарта pending-записи исчезают, а параллельные реплики не видят друг друга. Реализация атомарности и хранения — за пределами задачи; store предоставляется готовым.

В задаче один владелец и один endpoint. В production пространство ключей должно включать владельца или tenant и вид операции, иначе чужой ключ может раскрыть сохранённый ответ. Атомарное владение даёт право на Create только новой владеющей попытке; повторы этого права не получают.

Pre-admission отказ, Release и разрыв между Create и Complete

При ClaimNew обработчик вызывает Admit(ctx). Это единственное место, где admission имеет смысл: pending-резервация уже создана, ключ занят, и нужно решить — начинать ли реальную работу. Admit по условиям возвращает только 0, 429 или 503; Create возвращает готовый корректный Response со статусом 201, а методы store не могут завершиться ошибкой. Это гарантии задачи; production требует отдельной обработки ошибок зависимостей. Контекст всех вызовов — ctx := r.Context() из предыдущего раздела. Фрагменты иллюстративные и здесь не компилировались.

429 и 503 здесь означают конкретное: admission-сервис явно отказал до начала бизнес-операции. В этом случае Create не вызывался, бизнес-изменений нет, и pending-резервацию нужно освободить через Release — иначе ключ навсегда останется в состоянии pending и следующий запрос получит request_pending вместо нового шанса. После Release обработчик возвращает тот же статус, что вернул Admit, с заголовком Retry-After: 2 и телом {"error":"not_admitted"}:


case ClaimNew:
    code := admit(ctx)
    if code == 429 || code == 503 {
        store.Release(ctx, op.RequestID)
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Retry-After", "2")
        w.WriteHeader(code)
        w.Write([]byte(`{"error":"not_admitted"}`))
        return
    }
    resp := create(ctx, op)
    store.Complete(ctx, op.RequestID, resp)
    for k, vals := range resp.Header {
        for _, v := range vals {
            w.Header().Add(k, v)
        }
    }
    w.WriteHeader(resp.Status)
    w.Write(resp.Body)

После Release запись исчезает. Следующий запрос с тем же ключом попадёт в ClaimNew снова и сделает единственный Create при успешном admission. Это можно проверить последовательными запросами через httptest. Фрагмент использует предоставленные fake-store и журнал. newRig собирает обработчик; request создаёт запрос и recorder и вызывает ServeHTTP. Последний аргумент задаёт ожидаемый журнал: helper завершает проверку ошибкой при несовпадении. exact проверяет статус, точное тело и все значения перечисленных заголовков; require завершает проверку ошибкой при ложном условии. Это иллюстрация, не результат запуска.


x := newRig()
op := LabelOperation{RequestID: "label-req-7", ParcelID: "parcel-42"}
body := `{"request_id":"label-req-7","parcel_id":"parcel-42"}`
x.admission = 429
r := x.request("POST", "/labels", op.RequestID, "application/json", body,
    []string{"claim", "admit", "release", "write"})
exact(r, 429, `{"error":"not_admitted"}`, http.Header{
    "Content-Type": {"application/json"}, "Retry-After": {"2"},
})
require(x.creates == 0 && len(x.store.records) == 0,
    "refusal must release without Create")
x.admission = 0
r = x.request("POST", "/labels", op.RequestID, "application/json", body,
    []string{"claim", "admit", "create", "complete", "write"})
exact(r, 201, string(x.completesResponse.Body), x.completesResponse.Header)
require(x.creates == 1 && len(x.store.records) == 1 &&
    x.store.records[op.RequestID].done, "one completed creation")

Две трассы требуют двух Claim и двух Admit, одного Release, одного Create и одного Complete. write отмечает первую запись статуса или тела, поэтому журналы проверяют Release и Complete до ответа. Для 503 нужен аналогичный отдельный сценарий с точным статусом 503 на первой попытке.
Поэтому Release разрешён только при подтверждённом pre-admission отказе — то есть когда Admit вернул 429 или 503. Произвольный сетевой 503, клиентский таймаут или ошибка внутри Create не дают права освобождать ключ. Принятая незавершённая операция остаётся pending, и клиент продолжает получать request_pending при повторах — это предотвращает повторный Create, но не доказывает, что работа продолжает выполняться.

Между Create и Complete существует разрыв: после создания этикетки процесс может упасть до сохранения ответа и оставить pending. Recovery и expiry выходят за рамки задачи. Exactly-once не обещается: не более одного Create обеспечивается атомарным владением ключом, пока резервация или сохранённая запись удерживается. Произвольное истечение срока pending с освобождением ключа может разрешить второй Create после уже выполненного первого. Production требует атомарного сохранения бизнес-изменения вместе с результатом либо восстанавливаемого протокола.

Клиент продолжает lookup из 6.1 по тому же request_id. GET может обнаружить записанный результат, но после падения между Create и Complete тоже может показывать processing. Чтение само по себе не разрешает зависшую резервацию; GET здесь не реализуется.

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


r = x.request("POST", "/labels", op.RequestID, "application/json", body,
    []string{"claim", "write"})
exact(r, 201, string(x.completesResponse.Body), x.completesResponse.Header)
require(reflect.DeepEqual(r.Result().Header.Values("ETag"),
    []string{`"label-901-v1"`, `"backup"`}), "both ETag values")
require(x.creates == 1 && len(x.store.records) == 1 &&
    x.store.records[op.RequestID].done, "replay preserves completed state")

Журнал replay допускает только Claim и запись: callbacks и Complete/Release не вызываются. Общий счётчик создания остаётся равным единице; JSON для ответа заново не собирается.
Complete вызывается до записи ответа клиенту. Это единственный корректный порядок: если запись в store произошла, клиент получит сохранённый результат при следующем повторе, даже если соединение оборвалось сразу после WriteHeader. Обратный порядок — сначала ответ клиенту, потом Complete — создаёт окно, в котором повтор попадает в ClaimPending вместо ClaimReplay.

Задача 1

Клиент отправил POST /labels с Idempotency-Key: label-req-7 и телом {"request_id":"label-req-7","parcel_id":"parcel-42"}. Сервер принял запрос: вызвал Admit (вернул 0), затем Create, затем Complete — и сохранил ответ 201 с заголовками Content-Type, Location и ETag. Ответ не дошёл до клиента (соединение оборвалось). Клиент повторяет точно тот же запрос с тем же ключом и тем же телом. Что должен вернуть сервер при повторе?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

В реестре уже есть completed-запись для ключа label-req-7, созданная запросом с parcel_id="parcel-42". Приходят два новых запроса с тем же ключом. Запрос A: тело {"parcel_id":"parcel-42", "request_id" : "label-req-7"} (другой порядок полей и пробелы). Запрос B: тело {"request_id":"label-req-7","parcel_id":"parcel-99"} (другой parcel_id). Какой результат возвращает сервер для каждого запроса?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Клиент отправляет POST /labels с ключом label-req-7 и валидным телом. Claim возвращает ClaimNew и создаёт pending-резервацию. Admit возвращает 429. Что обязан сделать обработчик, и что произойдёт при следующем запросе с тем же ключом, если Admit вернёт 0?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

В уроке 6.1 клиент сохранял операцию до первого запроса, чтобы знать, что отправить при повторе. Теперь сервер берёт на себя ответственность: получив повтор с тем же Idempotency-Key, он возвращает прежний ответ без нового вызова Create. Один ключ охватывает все попытки — это и есть смысл idempotency на стороне сервера.

Реализуйте единственную функцию:

func NewLabelHandler(
    store LabelStore,
    admit func(context.Context) int,
    create func(context.Context, LabelOperation) Response,
) http.Handler

Сигнатура зафиксирована; типы, fake-store и вспомогательные функции предоставлены в файле задачи. Больше ничего менять не нужно.

Контракт запроса

Единственный маршрут: POST /labels. Проверки выполняются строго в этом порядке; первое нарушение прекращает обработку:

  • Путь не /labels → 404, Content-Type: application/json, тело {"error":"not_found"}
  • Метод не POST → 405, Allow: POST, Content-Type: application/json, тело {"error":"method_not_allowed"}
  • Content-Type не application/json (параметры допустимы) → 415, тело {"error":"unsupported_media_type"}
  • Тело больше 4096 байт → 413, тело {"error":"body_too_large"}
  • Невалидный JSON, лишнее или отсутствующее поле, null, нестроковое значение, пустая строка, символ вне [A-Za-z0-9_-], второе JSON-значение → 400, тело {"error":"invalid_request"}
  • Заголовок Idempotency-Key отсутствует или не равен request_id из тела → 400, тело {"error":"invalid_request"}

Объект содержит ровно два поля: request_id и parcel_id. Повторяющиеся имена полей и варианты с отличием только в регистре исключены из входов и оценивания. Все ответы ошибок имеют Content-Type: application/json и точные байты тела без завершающей новой строки. Ошибки проверки пути, метода и входного запроса не обращаются к store или callbacks.

Fingerprint

После валидации вычислите fingerprint:

sha256([]byte(request_id + "\n" + parcel_id))
→ lowercase hex, 64 символа

Это нормализованное представление намерения: порядок полей и пробелы в JSON не влияют на результат. Fingerprint — средство сравнения, не подпись.

Четыре ветки Claim

Передайте r.Context() во все вызовы store и callbacks — никаких новых контекстов. Вызовите store.Claim(ctx, op.RequestID, fp).

  • ClaimConflict — fingerprint не совпал с существующей записью: 409, тело {"error":"idempotency_conflict"}. Никаких Admit, Create, Complete или Release; запись не меняется.
  • ClaimPending — тот же fingerprint, операция не завершена: 409, тело {"error":"request_pending"}. Никаких Admit, Create, Complete или Release; pending сохраняется.
  • ClaimReplay — тот же fingerprint, операция завершена: записать сохранённый статус, точные байты тела и все значения всех предоставленных заголовков. Никаких callbacks и изменений записи. Используйте Header.Add в цикле по значениям — Header.Set потеряет дополнительные значения.
  • ClaimNew — ключ свободен, pending-резервация создана: перейти к Admit.

Ветка ClaimNew: Admit, Create, Complete

Вызовите admit(ctx). Он возвращает только 0, 429 или 503.

При 429 или 503:

  • Вызовите store.Release(ctx, op.RequestID) — освободить pending-резервацию.
  • Ответить тем же статусом (429 или 503), заголовок Retry-After: 2, тело {"error":"not_admitted"}.
  • Не вызывать Create и Complete.

После Release запись исчезает. Следующий запрос с тем же ключом получит ClaimNew снова и при успешном Admit сделает единственный Create.

При 0:

  • Вызовите create(ctx, op) — получить готовый Response со статусом 201.
  • Вызовите store.Complete(ctx, op.RequestID, resp) — сохранить ответ до записи клиенту.
  • Записать все заголовки из resp.Header через Add, затем WriteHeader(resp.Status), затем Write(resp.Body).

Complete вызывается до записи ответа: если соединение оборвётся после WriteHeader, следующий повтор получит ClaimReplay с сохранённым результатом. Обратный порядок оставит окно, в котором повтор попадёт в ClaimPending.

Пример трассы: первый успех и replay

POST /labels
Content-Type: application/json
Idempotency-Key: label-req-7

{"request_id":"label-req-7","parcel_id":"parcel-42"}

→ ClaimNew → Admit=0 → Create label-901 → Complete → 201
  Body: {"request_id":"label-req-7","state":"created","label_id":"label-901"}
  Location: /labels/label-901
  ETag: "label-901-v1"

Повтор того же запроса:
→ ClaimReplay → 201, те же байты, те же заголовки; Create count=1

Повтор с parcel_id=parcel-99, тот же ключ:
→ ClaimConflict → 409 {"error":"idempotency_conflict"}; запись не меняется

Пример трассы: pre-admission отказ

POST /labels  Idempotency-Key: label-req-7
→ ClaimNew → Admit=429 → Release → 429
  Retry-After: 2
  Body: {"error":"not_admitted"}
  (записей в store нет)

Повтор того же запроса, Admit=0:
→ ClaimNew → Admit=0 → Create → Complete → 201
  (ровно одна запись, ровно один Create)

Проверяемое поведение

Предоставленный store атомарно сравнивает fingerprint до проверки pending/completed и делает глубокие копии bytes и headers при сохранении и replay. Для обработчика проверяется:

  • Статус, точные байты тела и все значения каждого заголовка.
  • Количество вызовов Create и Complete.
  • Порядок вызовов: Claim → Admit → Create → Complete → write.
  • Отсутствие callbacks при replay, conflict и pending.
  • Отсутствие записи в store после pre-admission отказа.
  • Два разных ключа для одного parcel_id дают два Create.
  • Эквивалентный JSON с другим порядком полей даёт replay, а не conflict.
  • Context из r.Context() доходит до каждого зависимого вызова.

Ограничения задачи

Реализуйте только NewLabelHandler. Store, типы и вспомогательные функции предоставлены и не меняются. Mutex, SQL и deep copy — ответственность store. GET из предыдущего урока здесь реализовывать не требуется. Retry, backoff и бюджет попыток относятся к следующим урокам. Принятая незавершённая операция не освобождается по таймауту клиента. Произвольный сетевой 503 не означает pre-admission отказ. Один заголовок Idempotency-Key сам по себе не предотвращает дублирование без общего реестра. Отдельные вызовы Create и Complete не гарантируют атомарность при падении между ними.

Оба поля обязательны и являются непустыми строками, целиком соответствующими [A-Za-z0-9_-]+. Проверка размера тела предшествует декодированию JSON. Для 429/503 сначала выполните Release, затем записывайте ответ клиенту; освобождается только резервация, полученная через ClaimNew. Для принятого ответа сохраняются статус 201, исходные bytes и все значения Content-Type, Location, ETag и других предоставленных headers. Date и hop-by-hop headers не входят в гарантируемую сохранённую часть.

Вызовы store не могут завершиться ошибкой; Create гарантированно возвращает готовый корректный Response со статусом 201. Обработка ошибок зависимостей исключена из задачи. В учебной задаче один пользователь и один endpoint; production namespace ключа, общий устойчивый реестр, восстановление зависшего pending и expiry не реализуются. Функция реализует только зафиксированный контракт; отдельные Create и Complete не доказывают exactly-once.

РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку