Антон ЖияновGo, SQL и разработка софтаhttps://antonz.ru/https://antonz.ru/assets/favicon/favicon.pngАнтон Жияновhttps://antonz.ru/Hugo -- gohugo.ioru-ruSat, 20 Dec 2025 16:00:00 +0000Обновленный go fixhttps://antonz.ru/accepted/modernized-go-fix/Sat, 20 Dec 2025 16:00:00 +0000https://antonz.ru/accepted/modernized-go-fix/С новыми анализаторами и движком от go vet.Начиная с версии Go 1.26 команда go fix работает на основе analysis framework — это тот же движок, что использует go vet.

Хотя теперь go fix и go vet используют одну и ту же инфраструктуру, у них разные задачи и разные наборы анализаторов:

  • Vet нужен для поиска проблем в коде. Его анализаторы находят проблемы, но не всегда предлагают исправления, и не все исправления безопасно применять.
  • Fix в основном нужен для того, чтобы обновлять код под новые возможности языка и стандартной библиотеки. Его анализаторы вносят только безопасные исправления, но не обязательно указывают на проблемы в коде.

Полный список анализаторов go fix — в разделе Анализаторы.

Зачем

Главная цель — добавить инструменты модернизации кода из Go language server (gopls) в командную строку. Если в go fix появится набор фиксов modernize, разработчики смогут быстро и безопасно обновлять одной командой весь код после выхода новой версии Go.

Переосмысление go fix также упрощает саму экосистему Go. Теперь go fix и go vet используют одну и ту же базу и расширения. Это делает инструменты более понятными, удобными для поддержки и гибкими для тех, кто хочет подключать собственные анализаторы.

Как

Новая команда go fix:

usage: go fix [build flags] [-fixtool prog] [fix flags] [packages]

Fix runs the Go fix tool (cmd/fix) on the named packages
and applies suggested fixes.

It supports these flags:

  -diff
        instead of applying each fix, print the patch as a unified diff

The -fixtool=prog flag selects a different analysis tool with
alternative or additional fixers.

По умолчанию go fix запускает полный набор анализаторов (см. список ниже). Чтобы выбрать конкретные анализаторы, используйте флаг -NAME для каждого из них, или -NAME=false, чтобы запустить все анализаторы, кроме тех, которые вы отключили.

Например, здесь включен только анализатор forvar:

go fix -forvar .

А здесь включены все анализаторы кроме omitzero:

go fix -omitzero=false .

Сейчас нет способа отключить отдельные анализаторы для конкретных файлов или участков кода.

Флаг -fixtool=prog позволяет выбрать другой инструмент анализа вместо стандартного. Например, чтобы собрать и запустить инструмент "stringintconv", который исправляет преобразования string(int), используйте такие команды:

go install golang.org/x/tools/go/analysis/passes/stringintconv/cmd/stringintconv@latest
go fix -fixtool=$(which stringintconv)

Альтернативные инструменты должны строиться на основе unitchecker, который отвечает за взаимодействие с go fix.

Анализаторы

Вот список исправлений, которые сейчас доступны в go fix, с примерами.

any • bloop • fmtappendf • forvar • hostport • inline • mapsloop • minmax • newexpr • omitzero • plusbuild • rangeint • reflecttypefor • slicescontains • slicessort • stditerators • stringsbuilder • stringscut • stringcutprefix • stringsseq • testingcontext • waitgroup

any

Заменяет interface{} на any:

// before
func main() {
    var val interface{}
    val = 42
    fmt.Println(val)
}
// after
func main() {
    var val any
    val = 42
    fmt.Println(val)
}

bloop

Заменяет в бенчмарках цикл for-range по b.N на b.Loop, и удаляет ручное управление таймером:

// before
func Benchmark(b *testing.B) {
    s := make([]int, 1000)
    for i := range s {
        s[i] = i
    }
    b.ResetTimer()

    for range b.N {
        Calc(s)
    }
}
// after
func Benchmark(b *testing.B) {
    s := make([]int, 1000)
    for i := range s {
        s[i] = i
    }

    for b.Loop() {
        Calc(s)
    }
}

fmtappendf

Заменяет []byte(fmt.Sprintf) на fmt.Appendf, чтобы избежать промежуточного выделения памяти:

// before
func format(id int, name string) []byte {
    return []byte(fmt.Sprintf("ID: %d, Name: %s", id, name))
}
// after
func format(id int, name string) []byte {
    return fmt.Appendf(nil, "ID: %d, Name: %s", id, name)
}

forvar

Убери лишнее переопределение переменных в цикле:

// before
func main() {
    for x := range 4 {
        x := x
        go func() {
            fmt.Println(x)
        }()
    }
}
// after
func main() {
    for x := range 4 {
        go func() {
            fmt.Println(x)
        }()
    }
}

hostport

Заменяет создание сетевых адресов с помощью fmt.Sprintf на net.JoinHostPort, потому что пары "хост-порт", собранные через форматирование строк типа %s:%d или %s:%s, не работают с IPv6:

// before
func main() {
    host := "::1"
    port := 8080
    addr := fmt.Sprintf("%s:%d", host, port)
    net.Dial("tcp", addr)
}
// after
func main() {
    host := "::1"
    port := 8080
    addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
    net.Dial("tcp", addr)
}

inline

Встраивает вызовы функций согласно директивам go:fix inline:

// before
//go:fix inline
func Square(x float64) float64 {
    return math.Pow(float64(x), 2)
}

func main() {
    fmt.Println(Square(5))
}
// after
//go:fix inline
func Square(x float64) float64 {
    return math.Pow(float64(x), 2)
}

func main() {
    fmt.Println(math.Pow(float64(5), 2))
}

mapsloop

Заменяет явные циклы по картам на вызовы функций пакета maps (Copy, Insert, Clone или Collect в зависимости от ситуации):

// before
func copyMap(src map[string]int) map[string]int {
    dest := make(map[string]int, len(src))
    for k, v := range src {
        dest[k] = v
    }
    return dest
}
// after
func copyMap(src map[string]int) map[string]int {
    dest := make(map[string]int, len(src))
    maps.Copy(dest, src)
    return dest
}

minmax

Заменяет if/else на вызовы встроенных функций min или max:

// before
func calc(a, b int) int {
    var m int
    if a > b {
        m = a
    } else {
        m = b
    }
    return m * (b - a)
}
// after
func calc(a, b int) int {
    var m int
    m = max(a, b)
    return m * (b - a)
}

newexpr

Заменяет собственные функции вида "указатель на T" на вызов new(expr):

// before
type Pet struct {
    Name  string
    Happy *bool
}

func ptrOf[T any](v T) *T {
    return &v
}

func main() {
    p := Pet{Name: "Fluffy", Happy: ptrOf(true)}
    fmt.Println(p)
}
// after
type Pet struct {
    Name  string
    Happy *bool
}

//go:fix inline
func ptrOf[T any](v T) *T {
    return new(v)
}

func main() {
    p := Pet{Name: "Fluffy", Happy: new(true)}
    fmt.Println(p)
}

omitzero

Убирает omitempty у полей со структурным типом, потому что этот тег на них не влияет:

// before
type Person struct {
    Name string `json:"name"`
    Pet  Pet    `json:"pet,omitempty"`
}

type Pet struct {
    Name string
}
// after
type Person struct {
    Name string `json:"name"`
    Pet  Pet    `json:"pet"`
}

type Pet struct {
    Name string
}

plusbuild

Удаляет устаревшие комментарии //+build:

//go:build linux && amd64
// +build linux,amd64

package main

func main() {
    var _ = 42
}
//go:build linux && amd64

package main

func main() {
    var _ = 42
}

rangeint

Заменяет 3-частные циклы for на for-range по числам:

// before
func main() {
    for i := 0; i < 5; i++ {
        fmt.Print(i)
    }
}
// after
func main() {
    for i := range 5 {
        fmt.Print(i)
    }
}

reflecttypefor

Заменяет reflect.TypeOf(x) на reflect.TypeFor[T]() если тип известен во время компиляции:

// before
func main() {
    n := uint64(0)
    typ := reflect.TypeOf(n)
    fmt.Println("size =", typ.Bits())
}
// after
func main() {
    typ := reflect.TypeFor[uint64]()
    fmt.Println("size =", typ.Bits())
}

slicescontains

Заменяет циклы на slices.Contains или slices.ContainsFunc:

// before
func find(s []int, x int) bool {
    for _, v := range s {
        if x == v {
            return true
        }
    }
    return false
}
// after
func find(s []int, x int) bool {
    return slices.Contains(s, x)
}

slicessort

Заменяет sort.Slice на slices.Sort для простых типов:

// before
func main() {
    s := []int{22, 11, 33, 55, 44}
    sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
    fmt.Println(s)
}
// after
func main() {
    s := []int{22, 11, 33, 55, 44}
    slices.Sort(s)
    fmt.Println(s)
}

stditerators

Использует итераторы вместо API в стиле Len/At для некоторых типов стандартной библиотеки:

// before
func main() {
    typ := reflect.TypeFor[Person]()
    for i := range typ.NumField() {
        field := typ.Field(i)
        fmt.Println(field.Name, field.Type.String())
    }
}
// after
func main() {
    typ := reflect.TypeFor[Person]()
    for field := range typ.Fields() {
        fmt.Println(field.Name, field.Type.String())
    }
}

stringsbuilder

Заменяет повторные вызовы += на strings.Builder:

// before
func abbr(s []string) string {
    res := ""
    for _, str := range s {
        if len(str) > 0 {
            res += string(str[0])
        }
    }
    return res
}
// after
func abbr(s []string) string {
    var res strings.Builder
    for _, str := range s {
        if len(str) > 0 {
            res.WriteString(string(str[0]))
        }
    }
    return res.String()
}

stringscut

Заменяет некоторые вызовы strings.Index и срезы строк на strings.Cut или strings.Contains:

// before
func nospace(s string) string {
    idx := strings.Index(s, " ")
    if idx == -1 {
        return s
    }
    return strings.ReplaceAll(s, " ", "")
}
// after
func nospace(s string) string {
    found := strings.Contains(s, " ")
    if !found {
        return s
    }
    return strings.ReplaceAll(s, " ", "")
}

stringscutprefix

Заменяет strings.HasPrefix/TrimPrefix на strings.CutPrefix, а strings.HasSuffix/TrimSuffix на string.CutSuffix:

// before
func unindent(s string) string {
    if strings.HasPrefix(s, "> ") {
        return strings.TrimPrefix(s, "> ")
    }
    return s
}
// after
func unindent(s string) string {
    if after, ok := strings.CutPrefix(s, "> "); ok {
        return after
    }
    return s
}

stringsseq

Заменяет обход через strings.Split/Fields на strings.SplitSeq/FieldsSeq:

// before
func main() {
    s := "go is awesome"
    for _, word := range strings.Fields(s) {
        fmt.Println(len(word))
    }
}
// after
func main() {
    s := "go is awesome"
    for word := range strings.FieldsSeq(s) {
        fmt.Println(len(word))
    }
}

testingcontext

Заменяет context.WithCancel на t.Context в тестах:

// before
func Test(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    if ctx.Err() != nil {
        t.Fatal("context should be active")
    }
}
// after
func Test(t *testing.T) {
    ctx := t.Context()
    if ctx.Err() != nil {
        t.Fatal("context should be active")
    }
}

waitgroup

Заменяет wg.Add+wg.Done на wg.Go:

// before
func main() {
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("go!")
    }()

    wg.Wait()
}
// after
func main() {
    var wg sync.WaitGroup

    wg.Go(func() {
        fmt.Println("go!")
    })

    wg.Wait()
}

𝗣 71859 👥 Alan Donovan, Jonathan Amsterdam

]]>
Утечки горутин в Go 1.24+https://antonz.ru/detecting-goroutine-leaks/Fri, 19 Dec 2025 10:00:00 +0000https://antonz.ru/detecting-goroutine-leaks/С ними помогут synctest и pprof.Вы конечно и так в курсе, но на всякий случай:

Утечка происходит, если одна или несколько горутин навсегда заблокировались на канале (или другом примитиве синхронизации), но другие горутины и программа в целом при этом продолжают работать.

Утрированный пример утечки:

func work() chan int {
    ch := make(chan int)
    go func() {
        ch <- 42
        ch <- 13 // (!) утечка
    }()
    return ch
}

func main() {
    <-work()
    // ...
}

Традиционно Go не очень-то помогал в поиске утечек. Обнаружить их можно было разве что пристально разглядывая профиль или трассировку с продакшена, а в тестах приходилось использовать сторонний пакет goleak от Убера.

Сейчас это меняется.

Сначала в Go 1.24 добавили пакет synctest, который прекрасно справляется с поиском утечек при тестировании. Об этом почему-то никто не говорит — наверно, потому что не проходили мой курс по многозадачности 😁

func Test(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        <-work()
        synctest.Wait()
    })
}
panic: main bubble goroutine has exited but blocked goroutines remain

goroutine 37 [chan send (durable), synctest bubble 1]:
main.work.func1()
    leak_test.go:12 +0x3c
created by main.work in goroutine 36
    leak_test.go:10 +0x6c

Как видите, тест и ругнулся, и точно указал, где возникла утечка.

А теперь в Go 1.26 подвезут новый pprof-профиль под названием goroutineleak — он надежно (без ложных срабатываний) обнаруживает утечки в продакшене (как вы понимаете, synctest-то в продакшене не запустишь).

func main() {
    prof := pprof.Lookup("goroutineleak")

    defer func() {
        time.Sleep(50 * time.Millisecond)
        prof.WriteTo(os.Stdout, 2)
    }()

    <-work()
}
goroutine 3 [chan send (leaked)]:
main.work.func1()
    leak.go:12 +0x3c
created by main.work in goroutine 1
    leak.go:10 +0x74

Как видите, указал ровно на ту же утечку, что synctest.

В общем, рекомендую оба инструмента. За подробностями велкам в статью:

Detecting goroutine leaks in modern Go

]]>
Курс: Знакомство с Gohttps://antonz.ru/go-intro/Sat, 29 Nov 2025 16:00:00 +0000https://antonz.ru/go-intro/Для всех, кто уверенно программирует на другом языке и хочет попробовать Go.Я опубликовал бесплатный вводный курс по Go. Он идеально подходит для свитчеров — тех, кто уже уверенно программирует на другом языке и хочет попробовать Go.

В отличие от обычных курсов-знакомств, тут никто не рассказывает, что такое переменная и чем она отличается от цикла. Бестолковых задач вроде «что напечатает функция» тоже нет. Все кратко и по делу.

Фрагмент урока о горутинах
Фрагмент урока о горутинах

Освоить язык целиком по курсу не получится, а вот понять «это вообще мое или нет» — запросто. Если «ваше» — дальше можно пройти и полный курс.

Часть 1. Основы

  • 1.1 О курсе
  • 1.2 Базовые конструкции
  • 1.3 Массивы и карты
  • 1.4 Функции и указатели
  • 1.5 Структуры и методы
  • 1.6 Резюме

Часть 2. Другие темы

  • 2.1 О модуле
  • 2.2 Пакеты и модули
  • 2.3 Дженерики
  • 2.4 Горутины
  • 2.5 Текст
  • 2.6 Резюме

Лекции текстовые, видео нет. Я умею понятно объяснять сложные вещи, так что читать их приятно. Много примеров, нет сухой теории. Нет ИИ-генерированного контента — все лекции написаны лично мной.

Курс интерактивный. Большинство упражнений можно выполнять прямо в браузере. По каждой задачке есть эталонное решение с разбором.

Используется версия Go 1.25.

Записаться на курс

]]>
Выразительные тесты без testify/asserthttps://antonz.ru/do-not-testify/Wed, 16 Jul 2025 07:00:00 +0000https://antonz.ru/do-not-testify/Equal, Err и True — их вполне достаточно.Многие Go-разработчики предпочитают ассерты (asserts, проверки в тестах) без if, чтобы тесты были короче и понятнее. Вместо того чтобы писать if с t.Errorf:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    age, err := db.Str().Get("age")
    if !errors.Is(err, redka.ErrNotFound) {
        t.Errorf("want ErrNotFound, got %v", err)
    }
    if age != nil {
        t.Errorf("want nil, got %v", age)
    }
}
PASS

Они используют пакет testify/assert (или его злодейского близнеца testify/require):

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    age, err := db.Str().Get("age")
    assert.ErrorIs(t, err, redka.ErrNotFound)
    assert.Nil(t, age)
}

Но я не думаю, что для хороших тестов действительно нужен testify/assert с его 40 разными ассертами. Расскажу о другом способе.

Пакет testify также содержит моки и тест-сьюты. Мы не будем их рассматривать — поговорим только про ассерты.

Проверка на равенство

Самая распространенная проверка в тестах — проверка на равенство:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    name, _ := db.Str().Get("name") // отложим пока проверку ошибок
    if name.String() != "alice" {
        t.Errorf("want 'alice', got '%v'", name)
    }
}
PASS

Давайте напишем простую дженерик-функцию для таких проверок:

// AssertEqual проверяет, что got равно want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
    tb.Helper()

    // Проверяем, что оба равны nil.
    if isNil(got) && isNil(want) {
        return
    }

    // Иначе сравниваем через рефлексию.
    if reflect.DeepEqual(got, want) {
        return
    }

    // Нет совпадения, сообщаем о проблеме.
    tb.Errorf("want %#v, got %#v", want, got)
}

Приходится использовать вспомогательную функцию isNil, потому что компилятор не позволит сравнивать типизированное значение T с нетипизированным nil:

// isNil проверяет, что v равно nil.
func isNil(v any) bool {
    if v == nil {
        return true
    }

    // Интерфейс может быть не nil, но содержать nil,
    // поэтому проверяем внутреннее значение.
    rv := reflect.ValueOf(v)
    switch rv.Kind() {
    case reflect.Chan, reflect.Func, reflect.Interface,
        reflect.Map, reflect.Pointer, reflect.Slice,
        reflect.UnsafePointer:
        return rv.IsNil()
    default:
        return false
    }
}

Применим ассерт в нашем тесте:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    name, _ := db.Str().Get("name")
    AssertEqual(t, name.String(), "alice")
}
PASS

Порядок параметров в AssertEqual такой: (got, want), а не (want, got), как в testify. Так проще читается — ведь в жизни мы говорим «ее зовут Алиса», а не «Алиса — это ее имя».

Кроме того, в отличие от testify, наша функция не поддерживает настраиваемые сообщения о проблемах. Если тест не пройдет, все равно придется смотреть в код, так что зачем заморачиваться? Стандартное сообщение подскажет, что не так, а номер строки укажет, где искать проблему.

AssertEqual уже отлично подходит для любых проверок на равенство — а это, по моему опыту, до 70% всех проверок в тестах. Неплохо для альтернативы testify размером в 20 строк! Но мы можем сделать ее еще лучше, так что давайте не упускать этот шанс.

Во-первых, типы вроде time.Time и net.IP предоставляют специальный метод Equal. Следует его использовать, чтобы сравнение было точным:

// equaler - интерфейс для типов с методом Equal
// (вроде time.Time или net.IP).
type equaler[T any] interface {
    Equal(T) bool
}

// areEqual проверяет, что a равно b.
func areEqual[T any](a, b T) bool {
    // Проверяем, что оба равны nil.
    if isNil(a) && isNil(b) {
        return true
    }

    // Пробуем сравнить с помощью метода Equal.
    if eq, ok := any(a).(equaler[T]); ok {
        return eq.Equal(b)
    }

    // Иначе сравниваем через рефлексию.
    return reflect.DeepEqual(a, b)
}

Во-вторых, мы можем быстро сравнивать байтовые срезы через bytes.Equal:

// areEqual проверяет, что a равно b.
func areEqual[T any](a, b T) bool {
    // ...

    // Особый случай для байтовых срезов.
    if aBytes, ok := any(a).([]byte); ok {
        bBytes := any(b).([]byte)
        return bytes.Equal(aBytes, bBytes)
    }

    // ...
}

Наконец, будем вызывать функцию areEqual внутри AssertEqual:

// AssertEqual проверяет, что got равно want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
    tb.Helper()
    if areEqual(got, want) {
        return
    }
    tb.Errorf("want %#v, got %#v", want, got)
}

Посмотрим, как работает:

func Test(t *testing.T) {
    // date1 и date2 обозначают одно и то же время.
    date1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
    date2 := time.Date(2025, 1, 1, 5, 0, 0, 0, time.FixedZone("UTC+5", 5*3600))
    AssertEqual(t, date1, date2) // ok

    // b1 и b2 - одинаковые байтовые срезы.
    b1 := []byte("abc")
    b2 := []byte{97, 98, 99}
    AssertEqual(t, b1, b2) // ok

    // m1 и m2 - отличающиеся карты.
    m1 := map[string]int{"age": 25}
    m2 := map[string]int{"age": 42}
    AssertEqual(t, m1, m2) // fail
}
FAIL: Test (0.00s)
main_test.go:47: want map[string]int{"age":42}, got map[string]int{"age":25}

Работает отлично!

Проверка ошибок

Ошибки в Go используются постоянно, поэтому их проверка — важная часть тестирования:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    _, err := db.Str().Get("name") // игнорируем значение для краткости
    if err != nil {
        t.Errorf("unexpected error: %v'", err)
    }
}
PASS

Проверка ошибок, по моей оценке, составляет до 30% всех ассертов, поэтому вынесем ее в отдельную функцию.

Сначала реализуем базовые сценарии — когда ошибки не должно быть, и когда ожидается ошибка:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want error) {
    tb.Helper()

    // Ожидали nil ошибку, но получили не-nil.
    // Это критическая проблема, поэтому сразу завершаем тест.
    if want == nil && got != nil {
        tb.Fatalf("unexpected error: %v", got)
        return
    }

    // Ожидали не-nil ошибку, а получили nil.
    if want != nil && got == nil {
        tb.Errorf("want error, got <nil>")
        return
    }

    // Остальное сделаем чуть позже.
    return
}

Обычно мы не останавливаем тест, если отдельный ассерт упал. Это позволяет увидеть все проблемы разом, а не выискивать их по одной. Единственное исключение — ситуация unexpected error в коде выше (ожидали nil, получили не-nil). Здесь тест сразу завершается, потому что следующие проверки, скорее всего, уже не имеют смысла и могут вызвать панику.

Посмотрим, как работает:

func Test(t *testing.T) {
    db := getDB(t)
    db.Str().Set("name", "alice")

    _, err := db.Str().Get("name")
    AssertErr(t, err, nil)
}
PASS

Пока неплохо. Теперь реализуем остальные проверки, но без создания отдельных функций (ErrorIs, ErrorAs, ErrorContains и т.д.), как это делает testify.

Если want — ошибка (тип error), используем errors.Is, чтобы проверить, совпадает ли ошибка с ожидаемым значением:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    tb.Helper()

    if want != nil && got == nil {
        tb.Error("want error, got <nil>")
        return
    }

    switch w := want.(type) {
    case nil:
        if got != nil {
            tb.Fatalf("unexpected error: %v", got)
        }
    case error:
        if !errors.Is(got, w) {
            tb.Errorf("want %T(%v), got %T(%v)", w, w, got, got)
        }
    default:
        tb.Errorf("unsupported want type: %T", want)
    }
}

Пример:

func Test(t *testing.T) {
    err := &fs.PathError{
        Op: "open",
        Path: "file.txt",
        Err: fs.ErrNotExist,
    }
    AssertErr(t, err, fs.ErrNotExist)
}
PASS

Если want — строка, проверим, что сообщение об ошибке содержит ожидаемый текст:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    // ...
    switch w := want.(type) {
    case string:
        if !strings.Contains(got.Error(), w) {
            tb.Errorf("want %q, got %q", w, got.Error())
        }
    //...
    }
}

Пример:

func Test(t *testing.T) {
    _, err := regexp.Compile("he(?o") // invalid
    AssertErr(t, err, "invalid or unsupported")
}
PASS

Наконец, если want — тип, используем errors.As, чтобы проверить, совпадает ли тип ошибки с ожидаемым:

// AssertErr проверяет, что ошибка got соответствует want.
func AssertErr(tb testing.TB, got error, want any) {
    // ...
    switch w := want.(type) {
    case reflect.Type:
        target := reflect.New(w).Interface()
        if !errors.As(got, target) {
            tb.Errorf("want %s, got %T", w, got)
        }
    //...
    }
}

Пример:

func Test(t *testing.T) {
    got := &fs.PathError{
        Op: "open",
        Path: "file.txt",
        Err: fs.ErrNotExist,
    }

    var want *fs.PathError
    AssertErr(t, got, reflect.TypeOf(want))

    // Или так.
    AssertErr(t, got, reflect.TypeFor[*fs.PathError]())
}
PASS

И вот что еще. Хотелось бы иметь возможность проверить, что произошла просто какая-то ошибка, не проверяя ее значение или тип (так работает Error в testify). Чтобы поддержать такой сценарий, сделаем параметр want необязательным:

// AssertErr проверяет, что ошибка got соответствует любому из wants.
func AssertErr(tb testing.TB, got error, wants ...any) {
    tb.Helper()

    // Если wants не указаны, ожидаем, что got будет не-nil ошибкой.
    if len(wants) == 0 {
        if got == nil {
            tb.Error("want error, got <nil>")
        }
        return
    }

    // Здесь для простоты сравниваем got только с первым want.
    // Можно (и стоило бы) сравнивать с каждым из wants.
    want := wants[0]
    // ...
}

Пример:

func Test(t *testing.T) {
    _, err := regexp.Compile("he(?o") // invalid
    AssertErr(t, err) // хотим не-nil ошибку
}
PASS

Теперь AssertErr обрабатывает все нужные нам случаи:

  • Проверяет, есть ли ошибка.
  • Проверяет, что ошибки нет.
  • Проверяет конкретное значение ошибки.
  • Проверяет тип ошибки.
  • Проверяет, соответствует ли текст ошибки ожиданиям.

И все это в 40 строк кода. Неплохо, правда?

Другие проверки

AssertEqual и AssertErr покрывают 85-95% тестовых проверок в типичном проекте. Но все равно остаются эти злосчастные 5-15%.

Например, нам могут понадобиться такие проверки:

func Test(t *testing.T) {
    s := "go is awesome"
    if len(s) < 5 {
        t.Error("too short")
    }

    if !strings.Contains(s, "go") {
        t.Error("too weak")
    }
}
PASS

Технически, можно использовать AssertEqual. Но выглядит это так себе:

func Test(t *testing.T) {
    s := "go is awesome"
    AssertEqual(t, len(s) >= 5, true)
    AssertEqual(t, strings.Contains(s, "go"), true)
}
PASS

Поэтому давайте добавим третий и последний ассерт — AssertTrue. Эта функция самая простая:

// AssertTrue проверяет, что got истинно.
func AssertTrue(tb testing.TB, got bool) {
    tb.Helper()
    if !got {
        tb.Error("not true")
    }
}

Теперь логические проверки выглядят лучше:

func Test(t *testing.T) {
    s := "go is awesome"
    AssertTrue(t, len(s) >= 5)
    AssertTrue(t, strings.Contains(s, "go"))
}
PASS

Неплохо!

Заключение

Не думаю, что для тестов в гошном проекте нужны сорок разных видов ассертов. Вполне достаточно трех (а то и двух), если они корректно проверяют на равенство и обрабатывают разные ситуации с ошибками.

Я постоянно использую «трио ассертов» — Equal, Err и True — в своих проектах. Поэтому вынес их в отдельный мини-пакет github.com/nalgeon/be. Если вам понравился описанный в статье подход — попробуйте и вы!

]]>
Интерактивный тур по Go 1.25https://antonz.ru/go-1-25/Thu, 26 Jun 2025 17:30:00 +0000https://antonz.ru/go-1-25/Фейковые часы, новый GC, бортовой регистратор и многое другое.Go 1.25 уже вышел, так что сейчас самое время изучить, что нового. Официальные заметки к релизу сухие как прошлогодние сухари, поэтому я подготовил интерактивную версию с множеством примеров.

synctestjson/v2GOMAXPROCSНовый GCАнти-CSRFWaitGroup.GoFlightRecorderos.Rootreflect.TypeAssertT.Attrslog.GroupAttrshash.Cloner

Статья основана на официальных заметках к релизу от команды Go. Они распространяются под лицензией BSD-3-Clause. Это не полный список изменений; если нужен полный — смотрите официальную документацию.

По тексту вы увидите ссылки на спецификации (𝗣) и коммиты (𝗖𝗟) для описанных фич. Посмотрите их, если хотите узнать больше о причинах и деталях изменений.

Чтобы не усложнять код, в примерах часто отсутствует обработка ошибок. Не делайте так в продакшене ツ

Поехали!

# Синтетическое время в тестах

Предположим, у нас есть функция, которая ждет значение из канала одну минуту, а затем завершает ожидание по таймауту:

// Read читает значение из входного канала и возвращает его.
// Завершается по таймауту через 60 секунд.
func Read(in chan int) (int, error) {
    select {
    case v := <-in:
        return v, nil
    case <-time.After(60 * time.Second):
        return 0, errors.New("timeout")
    }
}

Вызываем ее так:

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    val, err := Read(ch)
    fmt.Printf("val=%v, err=%v\n", val, err)
}
val=42, err=<nil>

Как проверить ситуацию с таймаутом? Конечно, мы не хотим, чтобы тест действительно ждал 60 секунд. Мы могли бы сделать таймаут параметром функции (наверное, так и стоило бы), но допустим, что это не вариант.

Новый пакет synctest приходит на помощь! Функция synctest.Test выполняет изолированный «пузырь». Внутри пузыря функции пакета time используют искусственные часы, что позволяет тесту пройти мгновенно:

func TestReadTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ch := make(chan int)
        _, err := Read(ch)
        if err == nil {
            t.Fatal("expected timeout error, got nil")
        }
    })
}
PASS

Начальное время в пузыре — полночь 2000-01-01 UTC. Время идет вперед, когда все горутины в пузыре заблокированы. В нашем примере, когда единственная горутина заблокирована на select в Read, часы пузыря прыгают на 60 секунд вперед, что вызывает срабатывание в селекте ветки таймаута.

Имейте в виду, что t, передаваемый во внутреннюю функцию — не совсем обычный testing.T. В частности, никогда не вызывайте на нем T.Run, T.Parallel или T.Deadline:

func TestSubtest(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        t.Run("subtest", func (t *testing.T) {
            t.Log("ok")
        })
    })
}
panic: testing: t.Run called inside synctest bubble [recovered, repanicked]

Как видите, дочерние тесты внутри пузыря запустить не получится.

Другая полезная функция — synctest.Wait. Она ждет, пока все горутины в пузыре заблокируются, а затем продолжает выполнение:

func TestWait(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var innerStarted bool
        done := make(chan struct{})

        go func() {
            innerStarted = true
            time.Sleep(time.Second)
            close(done)
        }()

        // Ждем, пока внутренняя горутина заблокируется на time.Sleep.
        synctest.Wait()
        // Гарантируется, что здесь innerStarted равно true.
        fmt.Printf("inner started: %v\n", innerStarted)

        <-done
    })
}
inner started: true

Попробуйте закомментировать вызов Wait() и посмотрите, как изменится значение innerStarted.

Пакет testing/synctest впервые появился как экспериментальный в версии 1.24. Сейчас он считается стабильным и готов к использованию. Обратите внимание, что функция Run, которая была добавлена в 1.24, теперь устарела. Вместо нее следует использовать Test.

𝗣 67434, 73567 • 𝗖𝗟 629735, 629856, 671961

# JSON v2

Вторая версия пакета json — это большое обновление, в котором полно несовместимых изменений. Поэтому я написал отдельный пост с подробным разбором изменений и множеством интерактивных примеров.

Здесь покажу только одну из самых впечатляющих функций.

В json/v2 вы больше не ограничены одним маршалером для конкретного типа. Теперь можно создавать кастомные маршалеры и анмаршалеры «на лету» — с помощью универсальных функций MarshalToFunc и UnmarshalFromFunc.

Например, можно преобразовать логические значения (true/false) и «логические» строки (on/off) в значения или — не создав при этом ни одного типа!

Сначала создадим маршалер для логических значений:

// Преобразует значения true/false в ✓ или ✗.
boolMarshaler := json.MarshalToFunc(
    func(enc *jsontext.Encoder, val bool) error {
        if val {
            return enc.WriteToken(jsontext.String("✓"))
        }
        return enc.WriteToken(jsontext.String("✗"))
    },
)

Затем маршалер для «логических» строк:

// Преобразует строки вида "true"/"false" в ✓ или ✗.
strMarshaler := json.MarshalToFunc(
    func(enc *jsontext.Encoder, val string) error {
        if val == "on" || val == "true" {
            return enc.WriteToken(jsontext.String("✓"))
        }
        if val == "off" || val == "false" {
            return enc.WriteToken(jsontext.String("✗"))
        }
        // SkipFunc — специальная ошибка, которая инструктирует Go пропустить
        // текущий маршалер и перейти к следующему. В нашем случае
        // следующим будет стандартный маршалер для строк.
        return json.SkipFunc
    },
)

Наконец, объединим маршалеры с помощью JoinMarshalers и передадим их в функцию маршалинга через опцию WithMarshalers:

// Объединяем маршалеры с помощью JoinMarshalers.
marshalers := json.JoinMarshalers(boolMarshaler, strMarshaler)

// Кодируем в JSON несколько значений.
vals := []any{true, "off", "hello"}
data, err := json.Marshal(vals, json.WithMarshalers(marshalers))
fmt.Println(string(data), err)
["✓","✗","hello"] <nil>

Здорово, правда?

В json/v2 еще много полезного: поддержка I/O-читателей и писателей, инлайнинг вложенных объектов, куча разных настроек и заметный прирост производительности. Поэтому еще раз советую посмотреть пост, посвященный изменениям во второй версии.

𝗣 63397, 71497

# GOMAXPROCS для контейнеров

Параметр рантайма GOMAXPROCS определяет максимальное количество потоков операционной системы, которые планировщик Go может использовать для одновременного выполнения горутин. Начиная с Go 1.5, по умолчанию он равен значению runtime.NumCPU, то есть количеству логических CPU на машине (точнее, это либо общее число логических CPU, либо число, разрешенное маской аффинности процессора, если оно меньше).

Например, на моем ноутбуке с 8 ядрами значение GOMAXPROCS по умолчанию тоже равно 8:

maxProcs := runtime.GOMAXPROCS(0) // возвращает текущее значение
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", maxProcs)
NumCPU: 8
GOMAXPROCS: 8

Программы на Go часто запускаются в контейнерах, например, под управлением Docker или Kubernetes. В этих системах можно ограничить использование процессора для контейнера с помощью функции Linux, которая называется cgroups.

Cgroup (control group) в Linux позволяет объединять процессы в группы и управлять тем, сколько процессорного времени, памяти и сетевых ресурсов они могут использовать, устанавливая лимиты и приоритеты.

Например, вот как можно ограничить контейнер Docker четырьмя CPU:

docker run --cpus=4 golang:1.24-alpine go run /app/nproc.go

До версии 1.25 рантайм Go не учитывал ограничение по CPU (CPU-квоту) при установке значения GOMAXPROCS. Как бы вы ни ограничивали ресурсы процессора, GOMAXPROCS всегда устанавливался равным количеству CPU на хосте.

docker run --cpus=4 golang:1.24-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 8

Теперь рантайм Go runtime начал учитывать CPU-квоту:

docker run --cpus=4 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 4

Значение по умолчанию для GOMAXPROCS равно либо общему количеству CPU, либо лимиту CPU, заданному через cgroup для процесса — выбирается меньшее из этих двух значений.

Дробные значения лимита округляются в большую сторону:

docker run --cpus=2.3 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 3

На многоядерной машине минимальное умолчательное значение GOMAXPROCS равно 2, даже если лимит на CPU установлен меньше:

docker run --cpus=1 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 2

Если лимит CPU изменяется, рантайм автоматически обновляет значение GOMAXPROCS. Сейчас это происходит не чаще одного раза в секунду (реже, если приложение простаивает).

Ограничение CPU

Cgroups позволяют ограничивать использование процессора двумя способами:

  • Квота CPU — это максимальное время работы процессора, которое можно использовать за определенный период.
  • Доли CPU — это относительный приоритет использования процессора, который задается для планировщика ядра.

В Docker опции --cpus и --cpu-period/--cpu-quota задают квоту, а --cpu-shares задает доли.

В Kubernetes CPU limit задает квоту, а CPU request задает доли.

В Go GOMAXPROCS учитывает только CPU-квоту, но не доли.

Можно вручную установить GOMAXPROCS с помощью функции runtime.GOMAXPROCS. В этом случае рантайм будет использовать заданное вами значение и не будет его менять:

runtime.GOMAXPROCS(4)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
GOMAXPROCS: 4

Установленное вручную значение GOMAXPROCS можно отменить. Чтобы вернуть значение по умолчанию, используйте новую функцию runtime.SetDefaultGOMAXPROCS:

GOMAXPROCS=2 go1.25rc1 run nproc.go
// Используем переменную окружения.
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

// Используем ручное значение.
runtime.GOMAXPROCS(4)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

// Возвращаем значение по умолчанию.
runtime.SetDefaultGOMAXPROCS()
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
GOMAXPROCS: 2
GOMAXPROCS: 4
GOMAXPROCS: 8

Чтобы сохранить обратную совместимость, новое поведение GOMAXPROCS работает только если в go.mod указана версия Go 1.25 или выше. Также его можно отключить вручную с помощью настроек GODEBUG:

  • containermaxprocs=0 — игнорировать CPU-квоту.
  • updatemaxprocs=0 — не обновлять GOMAXPROCS автоматически.

𝗣 73193 • 𝗖𝗟 668638, 670497, 672277, 677037

# Новый сборщик мусора

Некоторые гоферы любят пошутить про Java и ее многочисленные сборщики мусора. Теперь будет не до смеха — в Go 1.25 появился новый сборщик мусора.

Алгоритм сборки мусора под кодовым названием Green Tea хорошо подходит для программ, которые создают много маленьких объектов и работают на современных машинах с большим количеством ядер.

Старый сборщик мусора сканирует память не по порядку, а скачет туда-сюда. Из-за этого все работает неоптимально, потому что много времени уходит на доступ к памяти. Проблема усугубляется на многоядерных системах с неоднородным доступом к памяти (так называемая NUMA-архитектура, когда у каждого процессора или группы процессоров есть своя «локальная» память).

Green Tea работает иначе. Вместо того чтобы сканировать отдельные маленькие объекты, он сканирует память большими, непрерывными блоками — спанами (spans). Каждый спан содержит много маленьких объектов одного размера. Благодаря работе с большими блоками GC может сканировать память быстрее и лучше использовать кэш процессора.

Результаты бенчмарков разнятся, но команда Go ожидает, что в реальных программах с большим количеством GC затраты на сборку мусора снизятся на 10–40%.

Я провел небольшой тест: сделал 1 000 000 операций чтения и записи в Redka (это мой клон Redis, написанный на Go). Общее время на сборку мусора было примерно одинаковым на старом и новом алгоритмах. Но Редька, вероятно, не самый удачный пример — она в основном полагается на SQLite, а действий в Go-коде там относительно немного.

Новый сборщик мусора пока экспериментальный, включается переменной окружения GOEXPERIMENT=greenteagc при сборке. Дизайн и реализация сборщика могут измениться в будущих версиях. Подробности реализации и обратная связь по работе GC — по ссылкам ниже.

𝗣 73581Feedback

# Анти-CSRF

Новый тип http.CrossOriginProtection защищает от CSRF-атак, отклоняя небезопасные кросс-доменные запросы из браузера.

Кросс-доменные запросы определяются так:

  • Проверкой по заголовку Sec-Fetch-Site.
  • Сравнением домена в заголовке Origin с доменом в заголовке Host.

Вот пример, где мы включаем CrossOriginProtection и явно разрешаем несколько дополнительных источников:

// Регистрируем пару обработчиков.
mux := http.NewServeMux()
mux.HandleFunc("GET /get", func(w http.ResponseWriter, req *http.Request) {
    io.WriteString(w, "ok\n")
})
mux.HandleFunc("POST /post", func(w http.ResponseWriter, req *http.Request) {
    io.WriteString(w, "ok\n")
})

// Настраиваем защиту от CSRF-атак.
antiCSRF := http.NewCrossOriginProtection()
antiCSRF.AddTrustedOrigin("https://example.com")
antiCSRF.AddTrustedOrigin("https://*.example.com")

// Подключаем защиту ко всем обработчикам.
srv := http.Server{
    Addr:    ":8080",
    Handler: antiCSRF.Handler(mux),
}
log.Fatal(srv.ListenAndServe())

Теперь, если браузер отправит запрос с того же домена, что и сервер, сервер его разрешит:

curl --data "ok" -H "sec-fetch-site:same-origin" localhost:8080/post
ok

Если браузер отправит кросс-доменный запрос, сервер его отклонит:

curl --data "ok" -H "sec-fetch-site:cross-site" localhost:8080/post
cross-origin request detected from Sec-Fetch-Site header

Если заголовок Origin не соответствует заголовку Host, сервер отклонит запрос:

curl --data "ok" \
  -H "origin:https://evil.com" \
  -H "host:antonz.org" \
  localhost:8080/post
cross-origin request detected, and/or browser is out of date:
Sec-Fetch-Site is missing, and Origin does not match Host

Если запрос придет из доверенного источника, сервер его разрешит:

curl --data "ok" \
  -H "origin:https://example.com" \
  -H "host:antonz.org" \
  localhost:8080/post
ok

Сервер всегда разрешает методы GET, HEAD и OPTIONS, поскольку они безопасны:

curl -H "origin:https://evil.com" localhost:8080/get
ok

Сервер всегда разрешает запросы без заголовков Sec-Fetch-Site или Origin (такой запрос не придет из браузера):

curl --data "ok" localhost:8080/post
ok

𝗣 73626 • 𝗖𝗟 674936, 680396

# Go в группе ожидания

Все знают, как дождаться выполнения горутины с помощью группы ожидания:

var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    fmt.Println("go is awesome")
}()

wg.Add(1)
go func() {
    defer wg.Done()
    fmt.Println("cats are cute")
}()

wg.Wait()
fmt.Println("done")
cats are cute
go is awesome
done

Новый метод WaitGroup.Go сам увеличивает счетчик группы, запускает функцию в горутине, и уменьшает счетчик, когда функция завершилась. Теперь пример выше можно переписать без использования wg.Add() и wg.Done():

var wg sync.WaitGroup

wg.Go(func() {
    fmt.Println("go is awesome")
})

wg.Go(func() {
    fmt.Println("cats are cute")
})

wg.Wait()
fmt.Println("done")
cats are cute
go is awesome
done

Реализация именно такая, как вы думаете:

// https://github.com/golang/go/blob/master/src/sync/waitgroup.go
func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

Забавно, что команде Go понадобилось 13 лет, чтобы добавить логичную обертку для Add+Done. Ну да ладно, лучше поздно, чем никогда!

𝗣 63796 • 𝗖𝗟 662635

# Flight recorder

Flight recording — это способ трассировки, который собирает данные о выполнении программы (например, вызовы функций и выделение памяти) со скользящим окном по времени или размеру трейса. Это помогает компактно записывать важные моменты в работе программы, даже если заранее неизвестно, когда они произойдут.

Новый тип trace.FlightRecorder реализует этот подход в Go. Он ведет трассировку в рамках ограниченного скользящего окна, там самым фиксируя только самую свежую информацию.

Вот пример использования.

Сначала настраиваем скользящее окно:

// Сохранять как минимум 5 последних секунд трассировки,
// с размером буфера не более 3 МБ.
// Это только рекомендации, а не строгие ограничения.
cfg := trace.FlightRecorderConfig{
    MinAge:   5 * time.Second,
    MaxBytes: 3 << 20, // 3MB
}

Затем создаем трассировщик и запускаем его:

// Создаем и запускаем трассировщик.
rec := trace.NewFlightRecorder(cfg)
rec.Start()
defer rec.Stop()

И пишем обычный код приложения:

// Имитируем какую-то работу.
done := make(chan struct{})
go func() {
    defer close(done)
    const n = 1 << 20
    var s []int
    for range n {
        s = append(s, rand.IntN(n))
    }
    fmt.Printf("done filling slice of %d elements\n", len(s))
}()
<-done

Наконец, сохраняем трассировку в файл, когда происходит важное событие:

// Сохраняем снепшот трассировки в файл.
file, _ := os.Create("/tmp/trace.out")
defer file.Close()
n, _ := rec.WriteTo(file)
fmt.Printf("wrote %dB to trace file\n", n)
done filling slice of 1048576 elements
wrote 8441B to trace file

Посмотреть трассировку в браузере можно командой go tool:

go1.25rc1 tool trace /tmp/trace.out

𝗣 63185 • 𝗖𝗟 673116

# Больше Root-методов

Тип os.Root ограничивает работу с файловой системой конкретным каталогом. Теперь он поддерживает несколько новых методов, аналогичных функциям пакета os.

Chmod меняет права доступа к файлу:

root, _ := os.OpenRoot("data")
root.Chmod("01.txt", 0600)

finfo, _ := root.Stat("01.txt")
fmt.Println(finfo.Mode().Perm())
-rw-------

Chown меняет идентификатор пользователя (uid) и группы (gid) файла:

root, _ := os.OpenRoot("data")
root.Chown("01.txt", 1000, 1000)

finfo, _ := root.Stat("01.txt")
stat := finfo.Sys().(*syscall.Stat_t)
fmt.Printf("uid=%d, gid=%d\n", stat.Uid, stat.Gid)
uid=1000, gid=1000

Chtimes меняет время последнего доступа и изменения файла:

root, _ := os.OpenRoot("data")
mtime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
atime := time.Now()
root.Chtimes("01.txt", atime, mtime)

finfo, _ := root.Stat("01.txt")
fmt.Println(finfo.ModTime())
2020-01-01 00:00:00 +0000 UTC

Link создает жесткую ссылку на файл:

root, _ := os.OpenRoot("data")
root.Link("01.txt", "hardlink.txt")

finfo, _ := root.Stat("hardlink.txt")
fmt.Println(finfo.Name())
hardlink.txt

MkdirAll создает новый каталог и все родительские каталоги при необходимости:

const dname = "path/to/secret"

root, _ := os.OpenRoot("data")
root.MkdirAll(dname, 0750)

finfo, _ := root.Stat(dname)
fmt.Println(dname, finfo.Mode())
path/to/secret drwxr-x---

RemoveAll удаляет файл или каталог со всем содержимым:

root, _ := os.OpenRoot("data")
root.RemoveAll("01.txt")

finfo, err := root.Stat("01.txt")
fmt.Println(finfo, err)
<nil> statat 01.txt: no such file or directory

Rename переименовывает (перемещает) файл или каталог:

const oldname = "01.txt"
const newname = "go.txt"

root, _ := os.OpenRoot("data")
root.Rename(oldname, newname)

_, err := root.Stat(oldname)
fmt.Println(err)

finfo, _ := root.Stat(newname)
fmt.Println(finfo.Name())
statat 01.txt: no such file or directory
go.txt

Symlink создает символическую ссылку на файл. Readlink возвращает путь, на который указывает эта ссылка:

const lname = "symlink.txt"

root, _ := os.OpenRoot("data")
root.Symlink("01.txt", lname)

lpath, _ := root.Readlink(lname)
fmt.Println(lname, "->", lpath)
symlink.txt -> 01.txt

WriteFile записывает данные в файл, создавая его при необходимости. ReadFile читает файл и возвращает его содержимое:

const fname = "go.txt"

root, _ := os.OpenRoot("data")
root.WriteFile(fname, []byte("go is awesome"), 0644)

content, _ := root.ReadFile(fname)
fmt.Printf("%s: %s\n", fname, content)
go.txt: go is awesome

Теперь, когда os.Root содержит все основные операции, вам вряд ли понадобятся файловые функции пакета os. Это делает работу с файлами намного безопаснее.

Кстати о файловых системах. os.DirFS() (файловая система с корнем в указанной папке) и os.Root.FS() (файловая система для дерева файлов в корне) теперь реализуют новый интерфейс fs.ReadLinkFS. В этом интерфейсе два метода — ReadLink и Lstat.

ReadLink возвращает путь, на который указывает символическая ссылка:

const lname = "symlink.txt"

root, _ := os.OpenRoot("data")
root.Symlink("01.txt", lname)

fsys := root.FS().(fs.ReadLinkFS)
lpath, _ := fsys.ReadLink(lname)
fmt.Println(lname, "->", lpath)
symlink.txt -> 01.txt

Удивляет, конечно, разница в написании os.Root.Readlink и fs.ReadLinkFS.ReadLink.

Lstat возвращает информацию о файле или символической ссылке:

fsys := os.DirFS("data").(fs.ReadLinkFS)
finfo, _ := fsys.Lstat("01.txt")

fmt.Printf("name:  %s\n", finfo.Name())
fmt.Printf("size:  %dB\n", finfo.Size())
fmt.Printf("mode:  %s\n", finfo.Mode())
fmt.Printf("mtime: %s\n", finfo.ModTime().Format(time.DateOnly))
name:  01.txt
size:  11B
mode:  -rw-r--r--
mtime: 2025-06-22

Вот и все по пакету os!

𝗣 49580, 67002, 73126 • 𝗖𝗟 645718, 648295, 649515, 649536, 658995, 659416, 659757, 660635, 661595, 674116, 674315, 676135

# Рефлексивное приведение типа

Чтобы преобразовать reflect.Value обратно в нужный тип, обычно используют метод Value.Interface() вместе с приведением типа:

alice := &Person{"Alice", 25}

// Взяли рефлексивное Value...
aliceVal := reflect.ValueOf(alice).Elem()
// ...и привели обратно к типу Person.
person, _ := aliceVal.Interface().(Person)

fmt.Printf("Name: %s, Age: %d\n", person.Name, person.Age)
Name: Alice, Age: 25

Теперь вместо этого можно использовать новую дженерик-функцию reflect.TypeAssert:

alice := &Person{"Alice", 25}

// Взяли рефлексивное Value...
aliceVal := reflect.ValueOf(alice).Elem()
// ...и привели обратно к типу Person.
person, _ := reflect.TypeAssert[Person](aliceVal)

fmt.Printf("Name: %s, Age: %d\n", person.Name, person.Age)
Name: Alice, Age: 25

Такой способ более идиоматичен и не тратит лишнюю память, потому что значение не упаковывается в интерфейс.

𝗣 62121 • 𝗖𝗟 648056

# Атрибуты и вывод тестов

Новый метод T.Attr добавляет дополнительную информацию к тесту. Например, ссылку на задачу, описание тест-кейса или что-то еще, что поможет анализировать результаты тестов:

func TestAttrs(t *testing.T) {
    t.Attr("issue", "demo-1234")
    t.Attr("description", "Testing for the impossible")

    if 21*2 != 42 {
        t.Fatal("What in the world happened to math?")
    }
}
=== RUN   TestAttrs
=== ATTR  TestAttrs issue demo-1234
=== ATTR  TestAttrs description Testing for the impossible
--- PASS: TestAttrs (0.00s)

Атрибуты особенно полезны в JSON-формате, если вы отправляете результаты тестов в CI или другую систему для автоматической обработки:

go1.25rc1 test -json -run=.
...
{
    "Time":"2025-06-25T20:34:16.831401+00:00",
    "Action":"attr",
    "Package":"sandbox",
    "Test":"TestAttrs",
    "Key":"issue",
    "Value":"demo-1234"
}
...
{
    "Time":"2025-06-25T20:34:16.831415+00:00",
    "Action":"attr",
    "Package":"sandbox",
    "Test":"TestAttrs",
    "Key":"description",
    "Value":"Testing for the impossible"
}
...

Вывод тестов отформатирован для удобства чтения.

Такой же метод Attr есть в testing.B и testing.F.

𝗣 43936 • 𝗖𝗟 662437

Новый метод T.Output предоставляет доступ к потоку вывода (io.Writer), который использует тест. Удобно, если вы хотите отправлять логи приложения прямо в лог теста — так их проще читать или автоматически анализировать:

func TestLog(t *testing.T) {
    t.Log("test message 1")
    t.Log("test message 2")
    appLog := slog.New(slog.NewTextHandler(t.Output(), nil))
    appLog.Info("app message")
}
=== RUN   TestLog
    main_test.go:12: test message 1
    main_test.go:13: test message 2
    time=2025-06-25T16:14:34.085Z level=INFO msg="app message"
--- PASS: TestLog (0.00s)

Такой же метод Output есть в testing.B и testing.F.

𝗣 59928 • 𝗖𝗟 672395, 677875

И последнее: функция testing.AllocsPerRun теперь вызывает панику, если тесты выполняются параллельно.

Сравните поведение версии 1.24:

// go 1.24
func TestAllocs(t *testing.T) {
    t.Parallel()
    allocs := testing.AllocsPerRun(100, func() {
        var s []int
        // Do some allocations.
        for i := range 1024 {
            s = append(s, i)
        }
    })
    t.Log("Allocations per run:", allocs)
}
=== RUN   TestAllocs
=== PAUSE TestAllocs
=== CONT  TestAllocs
    main_test.go:21: Allocations per run: 12
--- PASS: TestAllocs (0.00s)

И 1.25:

// go 1.25
func TestAllocs(t *testing.T) {
    t.Parallel()
    allocs := testing.AllocsPerRun(100, func() {
        var s []int
        // Do some allocations.
        for i := range 1024 {
            s = append(s, i)
        }
    })
    t.Log("Allocations per run:", allocs)
}
=== RUN   TestAllocs
=== PAUSE TestAllocs
=== CONT  TestAllocs
--- FAIL: TestAllocs (0.00s)
panic: testing: AllocsPerRun called during parallel test [recovered, repanicked]

Дело в том, что результат AllocsPerRun не гарантирован, если параллельно идут несколько тестов. Поэтому и добавили новое поведение с паникой — оно должно помочь ловить такие баги.

𝗣 70464 • 𝗖𝗟 630137

# Группировка атрибутов в логах

При структурном логировании связанные атрибуты часто объединяют под одним ключом:

logger.Info("deposit",
    slog.Bool("ok", true),
    slog.Group("amount",
        slog.Int("value", 1000),
        slog.String("currency", "USD"),
    ),
)
msg=deposit ok=true amount.value=1000 amount.currency=USD

Это работает, но только если не пытаться сначала собрать атрибуты, а потом формировать группу:

attrs := []slog.Attr{
    slog.Int("value", 1000),
    slog.String("currency", "USD"),
}
logger.Info("deposit",
	slog.Bool("ok", true),
	slog.Group("amount", attrs...),
)
cannot use attrs (variable of type []slog.Attr)
as []any value in argument to slog.Group
(exit status 1)

slog.Group ожидает срез значений типа any, поэтому он не принимает срез slog.Attr.

Новая функция slog.GroupAttrs решает эту проблему, создавая группу из переданных slog.Attr:

attrs := []slog.Attr{
    slog.Int("value", 1000),
    slog.String("currency", "USD"),
}
logger.Info("deposit",
    slog.Bool("ok", true),
    slog.GroupAttrs("amount", attrs...),
)
msg=deposit ok=true amount.value=1000 amount.currency=USD

Мелочь, но может пригодиться.

𝗣 66365 • 𝗖𝗟 672915

# Клон хеша

Новый интерфейс hash.Cloner позволяет хеш-функции возвращать копию текущего состояния:

// https://github.com/golang/go/blob/master/src/hash/hash.go
type Cloner interface {
    Hash
    Clone() (Cloner, error)
}

Теперь все стандартные реализации hash.Hash поддерживают функцию Clone: MD5, SHA-1, SHA-3, FNV-1, CRC-64 и другие.

h1 := sha3.New256()
h1.Write([]byte("hello"))

clone, _ := h1.Clone()
h2 := clone.(*sha3.SHA3)

// состояние h2 такое же, как h1, поэтому после
// записи одинаковых данных получится одинаковый хеш.
h1.Write([]byte("world"))
h2.Write([]byte("world"))

fmt.Printf("h1: %x\n", h1.Sum(nil))
fmt.Printf("h2: %x\n", h2.Sum(nil))
fmt.Printf("h1 == h2: %t\n", reflect.DeepEqual(h1, h2))
h1: 92dad9443e4dd6d70a7f11872101ebff87e21798e4fbb26fa4bf590eb440e71b
h2: 92dad9443e4dd6d70a7f11872101ebff87e21798e4fbb26fa4bf590eb440e71b
h1 == h2: true

𝗣 69521 • 𝗖𝗟 675197

# Заключение

Go 1.25 облегчает тестирование многозадачного кода, вводит новый экспериментальный пакет для работы с JSON, и улучшает работу рантайма благодаря новой реализации GOMAXPROCS и сборщика мусора. Также появился компактный трассировщик, современная защита от CSRF, долгожданный метод Go для групп ожидания и еще несколько улучшений.

В целом, отличный релиз!

]]>
Опыт с книгойhttps://antonz.ru/book-experience/Fri, 11 Apr 2025 12:00:00 +0000https://antonz.ru/book-experience/Здесь денег нет.Оказывается, осенью 2024 вышла на русском языке моя книга про оконные функции SQL (это такой мини-язык анализа данных в составе SQL). Издательство скромно об этом умолчало, так что узнал я случайно.

Поскольку та же самая книга на английском уже давно доступна на Амазоне (там я публиковал самостоятельно), теперь у меня есть возможность сравнить эти два способа публикации (издательство и самиздат).

Самиздат:

  • ➕ Если материал уже готов, процесс займет неделю максимум.
  • ➕ Сверстал и оформил все так, как мне нравится.
  • ➕ Продается по всему миру (ну почти).
  • ➖ Никто не покупает 😐

Издательство:

  • ➕ Много отзывов!
  • ➖ Процесс занял полтора года.
  • ➖ Ужасная верстка.
  • ➖ Денег не платят 💸

Как можно заметить, объединяет оба варианта отсутствие денег 😁

Впрочем, и я не рассчитывал.

]]>
Метод-значение в Gohttps://antonz.ru/method-value/Sun, 23 Feb 2025 12:00:00 +0000https://antonz.ru/method-value/Функция с приколоченным к ней получателeм.Допустим, у нас есть сервер, который можно тыкать палочкой:

type Server struct {
    nPings int
}

func (s *Server) Ping() {
    s.nPings++
}

Давайте попингуем его вот так:

s := Server{}
ping := s.Ping // хм

ping()
ping()
ping()

Как думаете, что произойдет? Я задал этот вопрос на канале и получил такие ответы:

Что будет при таком вызове ping?

18% Ругань компилятора
    ■■■■

11% Паника в рантайме
    ■■

15% Паника у меня
    ■■■

56% Увеличится s.nPings
    ■■■■■■■■■■■

Действительно, метод у значения структуры (или указателя на значение) — это просто функция с конкретным получателем (тем самым значением структуры). Такой метод-значение (method value) можно использовать как обычную функцию — вызывать напрямую или передавать в качестве параметра, например.

Предположим, у нас есть тип-монитор, который умеет периодически проверять доступность сервиса, ведет историю проверок, считает процент доступности и выполняет другие полезные действия.

Монитору совершенно не нужно знать о конкретной реализации сервиса. Достаточно получить в конструкторе функцию-пинг, и дальше вызывать ее:

type Monitor struct {
    ping func() error
    // ...
}

func NewMonitor(ping func() error) *Monitor {
    return &Monitor{ping}
}

И если у нашего сервера есть подходящий по сигнатуре метод:

func (s *Server) Ping() error {
    // ...
}

То можно прямо его и передать без всяких оберток:

s := Server{}
m := NewMonitor(s.Ping)

Такие вот элементы функционального программирования в глубоко процедурном языке :)

]]>
Скукоживание карт в Gohttps://antonz.ru/map-shrink/Tue, 04 Feb 2025 12:00:00 +0000https://antonz.ru/map-shrink/Карта ни за что не отдаст вам память.Не все могут поверить в коварство гошной карты, которая делает кусь не отдает память.

Характерный комментарий:

Если есть сервер на го с сессиями которые реализованы в виде мапы, то даже после отключения клиентов и удаления ключей из нее память не будет освобождаться? 🤌

Штош. Давайте разбираться.

Вот наш клиент с идентификатором и телом в 40 байт:

type Client struct {
    id   uint64
    body [40]byte
}

Создаем карту, добавляем 10К клиентов:

printAlloc("initial")

m := make(map[int]Client)
for i := range 10000 {
    m[i] = Client{id: uint64(i)}
}

runtime.GC()
printAlloc("after create")
initial: heap size = 109 KB
after create: heap size = 1110 KB

Размер кучи вырос до 1100 KB. Удаляем все записи из карты:

for i := range 10000 {
    delete(m, i)
}

runtime.GC()
printAlloc("after delete")
after delete: heap size = 1110 KB

Ни байтика не отдала, зараза!

Попробуем хранить указатели вместо значений:

m := make(map[int]*Client)
for i := range 10000 {
    m[i] = &Client{id: uint64(i)}
}

for i := range 10000 {
    delete(m, i)
}
after create: heap size = 898 KB
after delete: heap size = 429 KB

Почему часть памяти освободилась?

Здесь в памяти хранятся только указатели на клиентов, а сами значения (48B каждое) хранятся вне карты. Поэтому клиентов GC спокойно спокойно освобождает (ссылок-то на них больше нет), а вот внутренние структуры карты по-прежнему занимают память.

Напоследок предположим, что вместо легкого клиента у нас толстенький боди-позитивный с телом на 1024B:

type Client struct {
    id   uint64
    body [1024]byte
}

m := make(map[int]Client)
for i := range 10000 {
    m[i] = Client{id: uint64(i)}
}

for i := range 10000 {
    delete(m, i)
}
after create: heap size = 11683 KB
after delete: heap size = 434 KB

Что за ерунда? Мы же используем значения, а не указатели, почему память освободилась?

Если значения в карте достаточно большие (больше 128B) Go автоматически хранит в карте не сами значения, а указатели на них. Поэтому после GC занятая клиентами память освободилась, и осталась только занятая самой картой память размером 400KB.

Такие дела.

песочница

]]>
Тур по Go 1.24https://antonz.ru/go-1-24/Mon, 13 Jan 2025 12:00:00 +0000https://antonz.ru/go-1-24/Слабые указатели, швейцарские таблицы, быстрые карты, удобные бенчмарки и многое другое.Go 1.24 уже вышел, так что сейчас самое время изучить, что нового. Официальные заметки к релизу сухие как прошлогодние сухари, поэтому я подготовил более живую версию с множеством примеров.

Псевдонимы generic-типов

Сначала напоминалка: псевдоним типа (type alias) в Go создает синоним для типа, не создавая новый тип.

Когда тип определен на основе другого типа, типы отличаются:

type ID int

var n int = 10
var id ID = 10

id = ID(n)
fmt.Printf("id is %T\n", id)
id is main.ID

Когда тип объявлен как псевдоним другого типа, типы остаются одинаковыми:

type ID = int

var n int = 10
var id ID = 10

id = n // works fine
fmt.Printf("id is %T\n", id)
id is int

Go 1.24 поддерживает generic-псевдонимы типов: псевдоним типа может быть параметризован, как и определенный тип.

Например, можно определить Set как generic-псевдоним для map с логическими значениями (не то чтобы это было сильно полезно, но):

type Set[T comparable] = map[T]bool
set := Set[string]{"one": true, "two": true}

fmt.Println("'one' in set:", set["one"])
fmt.Println("'six' in set:", set["six"])
fmt.Printf("set is %T\n", set)
'one' in set: true
'six' in set: false
Set is map[string]bool

Вполне возможно, что вы никогда не использовали обычные псевдонимы, и не будете использовать generic-псевдонимы. Но нужно же было пополнить вашу копилку бесполезных знаний :)

Слабые указатели

Если вы сильный программист, то не читайте дальше

Слабый указатель (пакет weak в Go 1.24+) ссылается на объект, как обычный указатель. Но в отличие от обычного указателя, слабый указатель не способен удержать объект в памяти. Если на объект ссылаются только слабые указатели, сборщик мусора может освободить занимаемую им память.

Предположим, у нас есть тип blob (реализация скрыта для краткости):

// Blob is a large byte slice.
type Blob []byte

И указатель на блоб размером в 1000 КБ:

b := newBlob(1000)

Мы можем создать слабый указатель (weak.Pointer) из обычного с помощью weak.Make. А получить доступ к оригинальному указателю поможет Pointer.Value:

wb := weak.Make(newBlob(1000))
fmt.Println(wb.Value())
// Blob(1000 KB)

Обычный указатель не позволит сборщику мусора освободить занятую объектом память:

b := newBlob(1000)
fmt.Println("before GC =", b)
runtime.GC()
fmt.Println("after GC =", b)
before GC = Blob(1000 KB)
after GC = Blob(1000 KB)

Слабый указатель же разрешает сборщику мусора освободить память:

wb := weak.Make(newBlob(1000))
fmt.Println("before GC =", wb.Value())
runtime.GC()
fmt.Println("after GC =", wb.Value())
before GC = Blob(1000 KB)
after GC = <nil>

Как видите, Pointer.Value возвращает nil, если сборщик мусора уже освободил значение по указателю.

Пр этом нет гарантии, что nil вернется сразу после того, как объект перестал использоваться (или в любое другое время позже). Рантайм сам решает, когда освобождать память, и освобождать ли вообще.

Слабые указатели могут пригодиться для реализации кэша больших объектов. Они гарантируют, что объект не будет оставаться в памяти только потому, что он находится в кэше.

Мы еще поговорим об этом в следующей секции.

runtime.AddCleanup

Помните наш блоб?

b := newBlob(1000)
fmt.Printf("b=%v, type=%T\n", b, b)
b=Blob(1000 KB), type=*main.Blob

Что если мы хотим запустить некоторую функцию очистки (cleanup function), когда объект будет собран сборщиком мусора?

Раньше для этого мы бы вызывали runtime.SetFinalizer, который сложно использовать. Теперь есть его улучшенная версия — runtime.AddCleanup:

func main() {
    b := newBlob(1000)
    now := time.Now()
    // Регистрируем функцию, которую рантайм
    // вызовет после сборки памяти объекта b.
    runtime.AddCleanup(b, cleanup, now)

    time.Sleep(10 * time.Millisecond)
    b = nil
    runtime.GC()
    time.Sleep(10 * time.Millisecond)
}

func cleanup(created time.Time) {
    fmt.Printf(
        "object is cleaned up! lifetime = %dms\n",
        time.Since(created)/time.Millisecond,
    )
}
object is cleaned up! lifetime = 10ms

AddCleanup прикрепляет функцию очистки к объекту. Она выполняется после того как объект становится недоступен (на него больше никто не ссылается).

Функция очистки выполняется в отдельной горутине — она последовательно обрабатывает все вызовы очистки в рамках программы. К одному и тому же указателю можно прикрепить несколько функций очистки.

Обратите внимание, что функция очистки не обязательно выполняется сразу после того, как объект стал недоступен; она может выполниться в любое время в будущем.

Более полный пример, который демонстрирует совместную работу слабых указателей и AddCleanupв песочнице.

Швейцарские таблицы

Спустя много лет команда Go решила изменить реализацию map!

Начиная с Go 1.24, она основана на SwissTable и предлагает несколько оптимизаций (со слов разработчиков, лично не проверял):

  • Чтение и запись в больших картах (>1024 записей) быстрее на ~30%.
  • Запись в аллоцированных картах (с установленной емкостью) быстрее на ~35%.
  • Итерация в целом быстрее на ~10%, для карт с низкой наполненностью (большая емкость, мало записей) на ~60%.

Вернуться к старой реализации можно через переменную окружения GOEXPERIMENT=noswissmap при сборке (надо сказать, что не все остались довольны новыми картами).

Если интересно, вот исходники.

Конкурентно-безопасная карта

Лирическое отступление. Обычная карта в Go никогда не скукоживается, только растет пока не поглотит вселенную. GC не освобождает память, занятую самой картой, даже если удалять из нее элементы. Не изменилось это и в новой «швейцарской» карте в Go 1.24.

Но есть в Go еще одна карта, конкурентно-безопасная (sync.Map). И по странному стечению обстоятельств, в Go 1.24 у нее тоже новая реализация! Теперь она основана на concurrent hash-trie (помесь хэш-таблицы и префиксного дерева) и работает быстрее, особенно при модификациях карты.

Кроме того, новая sync.Map лучше освобождает память, чем предыдущая. Та тоже умела это делать, но там использовалась «поколенческая» модель, и память собиралась с запаздыванем. В новой никаких поколений нет, и память освобождается по мере удаления элементов.

Исходно новую расчудесную карту сделали для пакета unique в Go 1.23 — там как раз нужен был конкурентно-безопасный кэш. А теперь заметили, что и для пакета sync новая реализация отлично подходит. В результате, sync.Map теперь по сути фасад к HashTrieMap.

Если вы страшный ретроград, вернуться к старой sync.Map можно через переменную GOEXPERIMENT=nosynchashtriemap при сборке.

os.Root

Тип os.Root (Go 1.24+) ограничивает операции с файловой системой определенной директорией.

Функция OpenRoot открывает директорию и возвращает Root:

dir, err := os.OpenRoot("data")
fmt.Println(dir.Name(), err)
// data <nil>

Методы Root работают внутри директории и не позволяют использовать пути за ее пределами:

file, err := dir.Open("01.txt")
fmt.Println(file.Name(), err)
// data/01.txt <nil>

file, err = dir.Open("../main.txt")
fmt.Println(err)
// openat ../main.txt: path escapes from parent

Методы Root поддерживают большинство операций с файловой системой, доступных в пакете os:

file, err := dir.Create("new.txt")
stat, err := dir.Stat("02.txt")
err = dir.Remove("03.txt")

Поработав с Root, не забудьте положить на место его закрыть:

dir, err := os.OpenRoot(path)
defer dir.Close()
// do stuff

На большинстве платформ создание Root открывает файловый дескриптор. Если директорию переместить пока Root открыт, методы будут корректно использовать новый каталог.

B.Loop

Вы наверняка знакомы с циклом в бенчмарках (for range b.N):

var sink int

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)
    b.ResetTimer()

    // Run the benchmark.
    for range b.N {
        sink = slices.Max(s)
    }
}

Go сам управляет бенчмарком, определяет разумное значение b.N, и пишет результаты. Это удобно.

Но есть и нюансы:

  • Функция бенчмарка выполняется несколько раз, поэтому сетап тоже выполняется несколько раз (и ничего с этим не поделаешь).
  • Чтобы сетап не повлиял на результат, приходится вызывать b.ResetTimer().
  • Чтобы компилятор не заоптимизировал тестируемый код, приходится использовать sink.

Go 1.24 предлагает кое-что получше — testing.B.Loop:

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)

    // Run the benchmark.
    for b.Loop() {
        slices.Max(s)
    }
}

b.Loop решает все проблемы b.N:

  • Функция бенчмарка выполняется один раз, поэтому и сетап тоже выполняется только однажды.
  • Все, что находится вне b.Loop, не влияет на результат, поэтому b.ResetTimer() не нужен.
  • Компилятор никогда не оптимизирует вызовы функций внутри b.Loop.

В общем, с выходом Go 1.24 больше нет причин использовать for range b.N. Переходим на b.Loop!

Контекст для тестов

Допустим, мы хотим протестировать этот жутко полезный сервер:

// Дает ответы на все вопросы.
type Server struct{}

// Возвращает ответ сервера.
func (s *Server) Get(query string) int {
    return 42
}

// Запускает сервер. Остановка через отмену контекста.
func startServer(ctx context.Context) *Server {
    go func() {
        select {
        case <-ctx.Done():
            // Освобождаем ресурсы.
        }
    }()
    return &Server{}
}

Вот чудесный тест, который я написал:

func Test(t *testing.T) {
    srv := startServer(context.Background())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Тест проходит, но есть проблемка: я использовал пустой контекст, так что на самом деле сервер не остановился. Такие утечки ресурсов могут стать проблемой — особенно если тестов много.

Исправить это несложно — достаточно использовать контекст с отменой:

func Test(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    srv := startServer(ctx)
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Но практика показывает, что люди часто забывают это делать 🤷‍♀️

Поэтому в Go 1.24 добавили метод T.Context. Он возвращает контекст, который автоматически отменяется перед тем, как тест завершится:

func Test(t *testing.T) {
    srv := startServer(t.Context())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Удобно!

Больше итераторов

Как известно, в 1.23 авторы Go воспылали необъяснимой стратью к итератором, и этот пожар с тех пор только разгорается жарче.

Вот притащили еще горстку в пакете strings.

Lines итерирует по строкам, разделенным \n:

s := "one\ntwo\nsix"
for line := range strings.Lines(s) {
    fmt.Print(line)
}
one
two
six

SplitSeq итерирует по частям, разделенным произвольным разделителем:

s := "one-two-six"
for part := range strings.SplitSeq(s, "-") {
    fmt.Println(part)
}
one
two
six

SplitAfterSeq как SplitSeq, но делит после разделителя:

s := "one-two-six"
for part := range strings.SplitAfterSeq(s, "-") {
    fmt.Println(part)
}
one-
two-
six

FieldsSeq итерирует по частям, разделенным пробельными символами (unicode.IsSpace) и их последовательностями:

s := "one two\nsix"
for part := range strings.FieldsSeq(s) {
    fmt.Println(part)
}
one
two
six

FieldsFuncSeq как FieldsSeq, но логику «пробельных» символов определяете вы сами:

f := func(c rune) bool {
    return !unicode.IsLetter(c) && !unicode.IsNumber(c)
}

s := "one,two;six..."
for part := range strings.FieldsFuncSeq(s, f) {
    fmt.Println(part)
}
one
two
six

Ровно такие же итераторы добавили в пакет bytes.

SHA-3 и его друзья

Пакет crypto/sha3 в Go 1.24+ реализует хеш-функцию SHA-3 и другую криптографическую хурму, про которую вам вряд ли интересно знать (смотрите FIPS 202 если вдруг интересно):

s := []byte("go is awesome")
fmt.Printf("Source: %s\n", s)
fmt.Printf("SHA3-224: %x\n", sha3.Sum224(s))
fmt.Printf("SHA3-256: %x\n", sha3.Sum256(s))
fmt.Printf("SHA3-384: %x\n", sha3.Sum384(s))
fmt.Printf("SHA3-512: %x\n", sha3.Sum512(s))
Source: go is awesome
SHA3-224: 6df...94a
SHA3-256: ece...5c4
SHA3-384: c7b...47c
SHA3-512: 9d4...c5e

И еще несколько crypto-пакетиков:

Пропуск нулевых значений в JSON

Опция omitzero в Go 1.24+ инструктирует JSON-маршалер пропускать нулевые значения.

Вообще у нас уже был для этого omitempty, но omitzero вроде как поудобнее будет. Например, он пропускает нулевые значения time.Time, чего omitempty делать не умеет.

Вот omitempty:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitempty"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b))
{"name":"Alice","birth_date":"0001-01-01T00:00:00Z"}

А вот omitzero:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitzero"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b))
{"name":"Alice"}

Если у типа есть метод IsZero() bool — именно он используется при маршалинге, чтобы определить, нулевое значение или нет.

Если метода нет, используется стандартное понятие нулевого значения (0 для целого, "" для строки, и так далее).

Наверно, стоило бы сразу задизайнить так omitempty, но кто же знал :)

Заглушить логи

С появлением пакета log/slog в Go 1.21 наконец-то стало возможно нормально вести журналы без использования внешних библиотек.

Если еще не пробовали, то рекомендую простенький туториал и доку, она понятная и с примерами.

log := slog.New(slog.NewTextHandler(os.Stdout, nil))
log.Info("operation", "count", 3, "took", 50*time.Millisecond)
time=2024-12-09T10:20:47.660+00:00 level=INFO msg=operation count=3 took=50ms

Но вообще сказать я хотел не о самом slog, а о том, как в нем заглушить логи (например, для тестов или бенчмарков).

Сделать это несложно — просто используйте io.Discard в качестве приемника для slog.TextHandler:

log := slog.New(slog.NewTextHandler(io.Discard, nil))
log.Info("Prints nothing")

А в Go 1.24 появился еще более простой способ — через slog.DiscardHandler:

log := slog.New(slog.DiscardHandler)
log.Info("Prints nothing")

Вроде и так не особо сложно было, ну да ладно :)

rand.Text

Небольшое, но весьма приятное дополнение стандартной библиотеки в Go 1.24. Появилась функция crypto/rand.Text, которая возвращает криптографически случайную строку:

text := rand.Text()
fmt.Println(text)
// 4PJOOV7PVL3HTPQCD5Z3IYS5TC

Результат содержит как минимум 128 случайных бит. Этого достаточно для защиты от атак перебором, а вероятность коллизий крайне мала. Так что функция отлично подходит для генерации секретных ключей, паролей, и тому подобного добра.

Использует алфавит Base32.

Заключение

Go 1.24 принес много новых фич: слабые указатели, новая очистка, доступ к файловой системе в рамках одного каталога. Разработчики сильно ускорили работу с картами, что очень приятно. Команда Go явно заботится о программистах: теперь проще и безопаснее писать бенчмарки, тестировать многозадачный код и использовать допольнительные инструменты. Появились и улучшения в криптографии, например, поддержка SHA-3 и генерация случайного текста.

В целом, отличный релиз!

]]>
Пруфридинг и перевод в VS Codehttps://antonz.ru/vscode-proofread/Thu, 02 Jan 2025 12:00:00 +0000https://antonz.ru/vscode-proofread/Вместо DeepL и Grammarly.Мне всегда не хватало нормального переводчика и пруфридера в VS Code (вроде DeepL/Grammarly). В итоге сделал собственное расширение.

Расширение Proofread в VS Code

Работает через Copilot (только пруфридинг), OpenAI или Ollama (пруфридинг и перевод).

Из России, полагаю, будет работать только через VPN (+ нужна подписка на Copilot или ненулевой баланс в OpenAI).

Установить можно через VS Code Marketplace

]]>