Время в Go API: RFC3339, смещение и нормализация UTC

Партнёр присылает время события строкой. Один вызов содержит 2026-01-02T10:00:00+03:00, другой — 2026-01-02T07:00:00Z. Если использовать строку как ключ или сравнивать её лексикографически, одинаковый момент выглядит разным. Если принять строку без зоны и подставить настройки машины, результат начнёт зависеть от окружения сервиса.

Договориться о моменте времени

Для поля, означающего точный момент, полезен контракт RFC3339 с обязательным смещением или Z. time.Parse с layout time.RFC3339 проверяет структуру и получает time.Time. Метод UTC меняет представление зоны, сохраняя момент. После нормализации можно одинаково сериализовать время для журнала, сообщения и записи в хранилище.

Go использует особое правило layout: формат задаётся примером эталонной даты, а не буквами YYYY-MM-DD. Готовая константа RFC3339 позволяет не собирать его вручную и уменьшает риск перепутать месяц с минутами. Если поле по контракту является датой без времени, ему нужен другой формат и другая семантика; добавлять полночь UTC автоматически не следует.

В примере парсер возвращает нормализованный момент. Два разных смещения сравниваются через Equal, затем форматируются в один вид. Строка без зоны отвергается. Проверки выражают требование к границе сервиса, а не только способность библиотеки разобрать один красивый пример.

Разобрать и сравнить моменты

package main

import (
	"fmt"
	"time"
)

func parseMoment(raw string) (time.Time, error) {
	value, err := time.Parse(time.RFC3339, raw)
	if err != nil {
		return time.Time{}, fmt.Errorf("event time: %w", err)
	}
	return value.UTC(), nil
}

func main() {
	a, err := parseMoment("2026-01-02T10:00:00+03:00")
	if err != nil {
		panic(err)
	}
	b, err := parseMoment("2026-01-02T07:00:00Z")
	if err != nil {
		panic(err)
	}
	if !a.Equal(b) || a.Format(time.RFC3339) != "2026-01-02T07:00:00Z" {
		panic("moments differ")
	}
	if _, err := parseMoment("2026-01-02T10:00:00"); err == nil {
		panic("missing zone accepted")
	}
	fmt.Println(a.Format(time.RFC3339), a.Equal(b))
}

Выполните go run main.go. Вывод содержит UTC-представление и true для равенства моментов. Для сравнения времени предпочитайте Equal: оператор == также учитывает внутреннее представление time.Time, включая связанные сведения о зоне и монотонных часах. Для ключа события лучше использовать отдельный event_id, поскольку совпадение времени само по себе не означает одно и то же событие.

Зона отчёта и точность времени

Нормализация в UTC удобна для обмена, но пользовательский календарный день определяется зоной предметной области. Для отчёта по местным суткам сначала переведите момент в нужную зону, затем извлеките дату. Хранение только местной строки без смещения потеряет информацию при повторяющемся часе перехода времени. Эти правила должны быть едины у производителя и потребителя сообщения.

Если точность дробной части важна, согласуйте её отдельно. time.Time хранит наносекунды, а разные базы и клиенты могут округлять до микросекунд или миллисекунд. В демонстрации используется секундная точность, чтобы сосредоточиться на смещении. Для контрактного теста возьмите реальное значение с дробью и проверьте весь путь сериализации и чтения, прежде чем использовать его для упорядочивания.

Не исправляйте некорректный вход молча по локальной зоне сервера. Такой fallback делает старые и новые продюсеры неразличимыми: клиент уверен в одном времени, а сервис хранит другое. Если переходный период требует старого формата, оформите его отдельной веткой с явной зоной и наблюдаемым признаком, чтобы затем убрать поддержку.

Проверьте невозможную дату, отсутствующую зону, Z и ненулевое смещение. Отдельно проверьте пограничный переход даты при нормализации: событие сразу после полуночи в +03:00 может оказаться предыдущим днём UTC. Это правильное изменение представления, которое нельзя принимать за потерю исходного события.

Продолжить практику

Следующий шаг — закрепить формат времени в сообщении между независимо обновляемыми сервисами. В курсе микросервисов на Go есть HTTP-контракты, события Kafka и совместимые изменения. Откройте программу и продолжите от парсера одного поля к проверке полного контракта производителя и потребителя.

Продолжить на практике

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

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.