Avatar ·

Как сгенерировать случайную строку фиксированной длины в Go?

Всем привет! Я только начал изучать Go и столкнулся с типовой задачей: нужно генерировать случайные строки заданной длины, например, для создания уникальных идентификаторов, токенов или паролей. Я написал такой код: ```go package main import ( "fmt" "math/rand" ) func main() { fmt.Println(RandString(10)) } func RandString(n int) string { var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") b := make([]rune, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } ``` Код вроде работает, но я заметил странное поведение: при каждом запуске программы генерируется одна и та же строка. Я погуглил и узнал, что нужно использовать `rand.Seed()`, но даже после добавления `rand.Seed(time.Now().UnixNano())` иногда строки повторяются, если программа запускается несколько раз подряд очень быстро. Кроме того, меня интересуют лучшие практики: как сделать генерацию криптостойкой? Стоит ли использовать `crypto/rand` вместо `math/rand`? И как быть с производительностью, если нужно генерировать миллионы строк? Буду благодарен за подробное объяснение с примерами кода. <<>>

Проблема: детерминированность и безопасность

Ваш код страдает от двух основных проблем:

  1. Детерминированность: math/rand без инициализации seed всегда даёт одинаковую последовательность. Даже с time.Now().UnixNano() при быстрых запусках seed может совпасть.
  2. Отсутствие криптостойкости: math/rand предсказуем и не подходит для токенов, паролей или ключей.

Решение 1: Простая генерация (некриптостойкая)

Для некритичных задач (например, случайные имена в тестах) используйте math/rand с правильным seed:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func init() {
    rand.Seed(time.Now().UnixNano())
}

func RandString(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Intn(len(letterBytes))]
    }
    return string(b)
}

func main() {
    fmt.Println(RandString(10))
}

Важно: init() гарантирует, что seed будет установлен до вызова main(). Однако это всё ещё не подходит для безопасности.

Решение 2: Криптостойкая генерация (рекомендуется)

Для паролей, токенов, API-ключей используйте crypto/rand:

package main

import (
    "crypto/rand"
    "fmt"
    "math/big"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func CryptoRandString(n int) (string, error) {
    b := make([]byte, n)
    for i := range b {
        idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterBytes))))
        if err != nil {
            return "", err
        }
        b[i] = letterBytes[idx.Int64()]
    }
    return string(b), nil
}

func main() {
    str, err := CryptoRandString(10)
    if err != nil {
        panic(err)
    }
    fmt.Println(str)
}

Почему это лучше:

  • crypto/rand использует системный энтропийный пул (например, /dev/urandom в Linux).
  • Невозможно предсказать результат даже при знании предыдущих значений.
  • Не требует seed — безопасность гарантируется ОС.

Решение 3: Оптимизация производительности

Если нужно генерировать миллионы строк, цикл с rand.Int() для каждого байта медленный. Используйте маску и остаток для ускорения:

package main

import (
    "crypto/rand"
    "fmt"
    "math/big"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
    letterIdxBits = 6                    // 6 бит для 64 символов (2^6 = 64)
    letterIdxMask = 1 << letterIdxBits - 1 // Все 6 бит установлены
    letterIdxMax  = 63 / letterIdxBits   // Сколько индексов можно получить из одного 63-битного числа
)

func CryptoRandStringFast(n int) (string, error) {
    b := make([]byte, n)
    cache, remain := big.NewInt(0), big.NewInt(0)
    for i := 0; i < n; {
        idx, err := rand.Int(rand.Reader, big.NewInt(1<<63))
        if err != nil {
            return "", err
        }
        for j := 0; j < letterIdxMax && i < n; j++ {
            cache.Set(idx)
            cache.Rsh(cache, uint(letterIdxBits*j))
            cache.And(cache, big.NewInt(letterIdxMask))
            if int(cache.Int64()) < len(letterBytes) {
                b[i] = letterBytes[cache.Int64()]
                i++
            }
        }
    }
    return string(b), nil
}

func main() {
    str, err := CryptoRandStringFast(10)
    if err != nil {
        panic(err)
    }
    fmt.Println(str)
}

Как это работает: мы получаем одно большое случайное число и "выкусываем" из него по 6 бит, пока не наберём нужное количество символов. Это в ~5 раз быстрее, чем вызывать rand.Int для каждого байта.

Вывод

  • Для игрушек/тестов — math/rand с seed.
  • Для продакшена — всегда crypto/rand.
  • Для высокой производительности — используйте битовую маску.

Не забывайте обрабатывать ошибки от crypto/rand — в редких случаях системная энтропия может быть недоступна.

<<>>

Альтернативный подход: использование пакета math/rand/v2 (Go 1.22+)

В Go 1.22 появился новый пакет math/rand/v2, который решает проблемы с seed и производительностью без crypto/rand:

package main

import (
    "fmt"
    "math/rand/v2"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func RandStringV2(n int) string {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.IntN(len(letterBytes))]
    }
    return string(b)
}

func main() {
    fmt.Println(RandStringV2(10))
}

Преимущества:

  • Автоматическая инициализация seed — не нужно вызывать rand.Seed().
  • Лучшая производительность за счёт внутренних оптимизаций.
  • Более безопасный генератор (ChaCha8), чем старый math/rand.

Недостаток: всё ещё не криптостойкий. Используйте crypto/rand для токенов, паролей и любых данных, где предсказуемость — это уязвимость. math/rand/v2 подходит для симуляций, игр, тестовых данных.

Если вы обновились до Go 1.22, это самый простой и быстрый вариант для некритичных задач.