Об'єктне сховище та кешування¶
Дві інфраструктурні залежності з однією спільною ідеєю: поставити інтерфейс перед ними, тож локальна розробка не потребує жодної з них, і тести теж.
Модулі:
github.com/aws/aws-sdk-go-v2(зconfigтаservice/s3) таgithub.com/valkey-io/valkey-go.
type ObjectStore interface {
Put(ctx context.Context, key string, r io.Reader) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
}
Визначте інтерфейс, не клієнт¶
Обробники не мають знати, чи файл у S3, чи на диску. Інтерфейс вище — весь контракт, і він навмисно крихітний — та сама аргументація зі статті про патерн репозиторію.
Зауважте, він виражений через io.Reader та io.ReadCloser, тож
викликач може стрімити завантаження на 2 ГБ без того, щоб воно
проходило через пам'ять.
Дайте йому доменну помилку:
Реалізація на файловій системі¶
Локальна реалізація коротка, і саме вона змушує розробку й тести працювати взагалі без хмарних облікових даних:
type localStore struct{ root string }
func (s localStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(s.path(key))
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%s: %w", key, ErrObjectNotFound)
}
return f, err
}
st.Put(ctx, "a/b/file.txt", strings.NewReader("payload"))
rc, err := st.Get(ctx, "a/b/file.txt") // "payload"
_, err = st.Get(ctx, "missing")
errors.Is(err, ErrObjectNotFound) // true
st.Delete(ctx, "missing") // nil — deleting what is absent is fine
Дві поведінки, які мають збігатися між реалізаціями: відсутній об'єкт повертає ваш сентинел, а видалення відсутнього об'єкта — не помилка. Якщо це розходиться, заміна реалізації ламає викликачів.
Застосуйте filepath.FromSlash до ключа й відхиляйте .., або ключ
від користувача втече за межі вашого кореневого каталогу.
Реалізація на S3¶
LoadDefaultConfig проходить стандартний ланцюжок облікових даних —
середовище, спільний файл конфігурації, роль інстансу — тож продакшн
не потребує змін коду. Для локального ендпоінта MinIO чи Garage
перевизначте базовий URL в опціях.
Перекладіть типізовані помилки SDK у власні:
out, err := c.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &c.bucket, Key: &key})
if err != nil {
var nsk *types.NoSuchKey
var nf *types.NotFound
if errors.As(err, &nsk) || errors.As(err, &nf) {
return nil, fmt.Errorf("%s: %w", key, ErrObjectNotFound)
}
return nil, fmt.Errorf("getting %s: %w", key, err)
}
return out.Body, nil
Обидва типи важливі — який ви отримаєте, залежить від операції, а
перевірка лише NoSuchKey пропускає HeadObject.
Ще дві речі варто знати. manager.NewUploader обробляє multipart-
завантаження для великих об'єктів, чого не робить простий PutObject.
А presigned URL дозволяє клієнту завантажувати чи скачувати
напряму, тож великий файл узагалі не проходить через ваш сервіс:
ps := s3.NewPresignClient(client)
req, err := ps.PresignGetObject(ctx, in, s3.WithPresignExpires(15*time.Minute))
Вибір реалізації при старті¶
func NewObjectStore(cfg Config) ObjectStore {
if cfg.S3Bucket == "" {
return localStore{root: cfg.LocalStoragePath}
}
return s3Store{client: s3.NewFromConfig(awsCfg), bucket: cfg.S3Bucket}
}
Одне рішення, в одному місці. Усе нижче за течією тримає інтерфейс.
Кешування з valkey¶
Valkey — форк Redis; valkey-go використовує білдер команд замість
стрінгово-типізованих аргументів:
c, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{"127.0.0.1:6379"},
})
defer c.Close()
err = c.Do(ctx, c.B().Set().Key("k").Value("v").Ex(30*time.Second).Build()).Error()
got, err := c.Do(ctx, c.B().Get().Key("k").Build()).ToString()
// "v"
c.B() будує команду з аргументами, перевіреними на етапі компіляції,
тож одруківка в SET — помилка збірки, а не помилка часу виконання.
Клієнт безпечний для конкурентного використання й автоматично
конвеєризує запити.
Промах — це значення помилки¶
_, err := c.Do(ctx, c.B().Get().Key("absent").Build()).ToString()
fmt.Println(valkey.IsValkeyNil(err)) // output: true
// valkey nil message
Промах кешу повертається як помилка, і це не провал. Перевіряйте це явно:
val, err := c.Do(ctx, c.B().Get().Key(k).Build()).ToString()
switch {
case valkey.IsValkeyNil(err):
return compute() // miss
case err != nil:
return compute() // cache broken — still serve the request
default:
return val, nil
}
Завжди встановлюйте TTL. Ex(30*time.Second) при записі; кеш без
терміну придатності — це витік пам'яті з зайвими кроками.
Pub/sub¶
sub, cancel := c.Dedicate()
defer cancel()
go sub.Receive(ctx, sub.B().Subscribe().Channel("events").Build(),
func(m valkey.PubSubMessage) {
// handle m.Message
})
c.Do(ctx, c.B().Publish().Channel("events").Message("hello").Build())
// received: hello
Підписка потребує виділеного з'єднання — Dedicate() — бо
підписане з'єднання не може обслуговувати інші команди.
Саме так події, надіслані сервером працюють між репліками: клієнт підключений до одного інстансу, тож подія, викликана на іншому, дістанеться його лише якщо інстанси поділяють шину. Публікуйте в канал, підписник кожної репліки отримує це, і кожна пересилає своїм власним підключеним клієнтам.
Зауважте, pub/sub — це надіслав і забув. Репліка, що вимкнена, повністю пропускає повідомлення. Для всього, що не можна втратити, використовуйте справжню чергу.
Провалюйтесь м'яко¶
Жодна з цих двох залежностей не має класти ваш сервіс:
cache, err := valkey.NewClient(opt)
if err != nil {
slog.Warn("cache unavailable, continuing without it", "error", err)
cache = nil // callers check, or use a no-op implementation
}
Недосяжність кешу має означати повільніші відповіді, а не помилки. No-op реалізація інтерфейсу чистіша за перевірки на nil всюди — кожен промах, кожен запис — успіх, а решта коду про це навіть не знає.
Об'єктне сховище зазвичай навпаки: якщо завантаження — це продукт, провал голосно при старті — правильний вибір. Вирішуйте свідомо, до якого з двох типів належить кожна залежність.
З досвіду Python: клієнт S3 — це boto3 з явними типами помилок замість
ClientErrorплюс рядковий код, аvalkey-go— цеredis-pyз API білдера. Звичка інтерфейс-плюс-локальна-реалізація — та сама, яку ви б отримали від moto чи fakeredis, тільки це ваш власний код, а не шар мокання.
Швидка довідка¶
| Задача | Форма |
|---|---|
| абстракція | невеликий інтерфейс ObjectStore над io.Reader |
| локальна розробка | реалізація на файловій системі, без облікових даних |
| клієнт S3 | config.LoadDefaultConfig + s3.NewFromConfig |
| відсутній об'єкт | errors.As для *types.NoSuchKey і *types.NotFound |
| великі завантаження | manager.NewUploader |
| пряма передача клієнту | s3.NewPresignClient |
| клієнт кешу | valkey.NewClient, команди через c.B() |
| промах | valkey.IsValkeyNil(err) — не провал |
| термін придатності | завжди .Ex(d) |
| підписка | c.Dedicate() — підписане з'єднання виключне |
| події між репліками | публікуйте в канал; кожна репліка розсилає далі |
| коли недоступно | деградуйте для кешу, провалюйтесь голосно для сховища |