С тестами! И много яблок.

В последнее время я чесал в затылке, пытаясь придумать что-нибудь, связанное с го. Это совершенно простой, легкий в использовании язык с множеством отмеченных флажков, но я ничего не мог придумать. Наконец, меня осенило: поточно-ориентированный кеш с тестами. Теперь, поскольку Go является языком относительно высокого уровня, фактическая реализация этого (чрезвычайно простого) кеша - это только функции-оболочки вокруг структуры данных map. Тем не менее, это хороший проект для знакомства с написанием тестов и некоторыми хорошими гоизмами.

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

mkdir gocache
cd gocache
go mod init

Сцена «управления» зависимостями Go действительно ужасна, если честно [¹], поэтому я стараюсь избегать ее, насколько могу. Но если кто-то хочет использовать мой кеш, важно определить наш модуль кеширования формально, используя приведенную выше команду. А теперь, когда мы разобрались с документами, создайте файл с именем cache.go.

Мы начнем с определения нашего пакета и основных типов данных. Я не собираюсь называть это «разработкой на основе данных» или чем-то еще, но я всегда считал полезным официально определить, с чем мы будем работать.

// cache.go
package cache
import “sync”
// We’ll use strings as our cache keys
type Key string
// And you can store any type of value in our cache
type Value interface{}
type Cache struct {
    data map[Key]Value
    lock sync.RWMutex
}

Я решил ограничить наши ключи строками, поскольку именно так большинство людей будут использовать кеши (например, ключи сеанса, поиск DNS, файлы cookie и т. Д.). Для значений в кэше я решил использовать версию Go любого типа. Хотя мы определенно теряем безопасность типов из-за отсутствия обобщений [²], мы получаем много удобства. Если мы захотим позже определить что-то более сложное, например метод для итерации по всем элементам нашего кеша, то нам нужно будет либо использовать интерфейсы, либо конкретный тип. Что касается самого нашего типа кэша, то пока это простая структура только с полем data и мьютексом чтения-записи для обеспечения синхронизации.

Чтобы упростить тестирование в дальнейшем, вы также захотите определить константу для включения или выключения потоковой безопасности. Просто поместите его прямо под существующий код. Представьте себе, если бы только настоящее многопоточное программирование было таким простым!

const Threaded = true

Не знаю, как вы, но когда я думаю об операциях с кешем (что, конечно, я часто делаю), я думаю о «получить» и «установить». Без них трудно представить себе много пользы от кеша (разве что для того, чтобы похвастаться тем, как вы его используете). Итак, давайте на самом деле реализуем их поточно-ориентированным способом.

func (c *Cache) Get(k Key) (Value, bool) {
    if Threaded {
        // We use the Read Lock/Unlock methods here
        // since we are not mutating the cache, only
        // reading values from it
        c.lock.RLock()
        defer c.lock.RUnlock()
    }
    value, exists := c.data[k]
    if !exists {
        return nil, false
    }
    return value, true
}
func (c *Cache) Set(k Key, v Value) {
    if Threaded {
        // We use the regular Lock/Unlock methods here
        // since we are mutating the cache
        c.lock.Lock()
        defer c.lock.Unlock()
    }
    c.data[k] = v
}

Я зеркалирую собственный map API Go внутри get, что должно помочь сохранить кеш в разумных пределах. Большой! Но подождите, как на самом деле кто-то использует наш кеш? Нужно ли им физически печатать амперсанд-заглавную букву C-a-c-h-e в одиночестве? Варварский! Давайте дадим нашим милым пользователям красивую New функцию в типичной манере модуля Go.

func New() *Cache {
    cache := &Cache{
        data: make(map[Key]Value),
    }
    return cache
}

Обратите внимание, что нам не нужно инициализировать поле lock, поскольку Go автоматически инициализируется нулевым значением RWMutex, что нам и нужно. Пока все складывается неплохо, но я уже устал реализовывать! Приступим к тестированию. Создайте новый файл с именем cache_test.go (по соглашению файлы тестирования называются <module>_test.go) и определите его как тот же пакет, используя модуль «тестирование».

// cache_test.go
package cache
import (
    “testing”
    “time”
)

Мы хотим сначала протестировать базовую функциональность т.е. настройку и получение (в указанном порядке!). Тестирование Go работает с помощью команды go test, и любая функция, начинающаяся с Test (конечно, с заглавной буквы T), будет запущена и передана в структуру тестирования T. Если вам нужна небольшая подсказка относительно того, для чего мы можем протестировать наш кеш, я привел несколько примеров выше: кеш DNS!

func TestBasic(t *testing.T) {
    dns := New()
    dns.Set("apple.com", "17.253.144.10")
    ip, exists := dns.Get("apple.com")
    if !exists {
        t.Error("apple.com was not found")
    }
    if ip == nil {
        t.Error("dns[apple.com] is nil")
    }
    if ip != "17.253.144.10" {
        t.Error("dns[apple.com] != 17.253.144.10")
    }
}

Ух ты! Это почти похоже на оболочку встроенной map функциональности! Кто-то сказал ICANN go get, потому что мы почти готовы к работе с этим плохим парнем на 400 миллионов запросов в секунду. Но как убедиться, что это действительно работает? В конце концов, не все будут доверять вашим навыкам программирования с самого начала (каламбур очень задуман). К счастью, у всех этих странных условностей есть свои плоды. Все, что нам нужно сделать, это бежать

go test

И он должен распечатать результаты: ПРОЙДЕН! Но, в конце концов, не все мы: некоторые люди делают ошибки, и мы не хотим оставлять их в затруднительном положении, если они введут неправильное значение. Конечно, они могут просто вызвать «установить» с новым значением, но что, если они захотят полностью стереть все свидетельства своей ошибки? Нам нужна функция удаления! Go уже предоставляет delete функцию именно для этой цели, поэтому наша работа состоит в том, чтобы просто заключить ее в блокировку.

func (c *Cache) Remove(k Key) {
    if Threaded {
        c.lock.Lock()
        defer c.lock.Unlock()
    }
    delete(c.data, k)
}

Превосходно! Теперь наш кеш допускает даже ошибки, вперед! (Каламбур также предназначен). Еще раз проверим. Я полагаю, что мы вставляем значение, затем удаляем его и проверяем, что получаем false от Get().

// cache_test.go
func TestRemove(t *testing.T) {
    fruits := New()
    fruits.Set(“Apple”, 1.39)
    applePrice, exists := fruits.Get(“Apple”)
    if !exists {
        t.Error(“Apple price was not set”)
    }
    if applePrice == nil {
        t.Error(“Apple price is nil”)
    }
    if applePrice != 1.39 {
        t.Error(“Apple price expected to be 1.39”)
    }
    fruits.Remove(“Apple”)
    applePrice, exists = fruits.Get(“Apple”)
    if exists {
        t.Error(“Apple price was not removed”)
    }
    if applePrice != nil {
        t.Error(“Apple price is not nil after removal”)
    }
}

Мы еще раз отдаем дань уважения Apple / apples и следим за тем, чтобы наш код соответствовал номиналу. На случай, если вам интересно, delete функция Go - это просто бездействие (без операции), если ключ еще не существует. Если вы хотите предоставить больше информации нашему вызывающему, вы можете проверить, присутствует ли ключ вообще, и вернуть false, если нет, возвращая только true, если ключ действительно был удален. А пока мы просто будем придерживаться этого. Если у ICANN возникнут какие-либо проблемы с этим, всегда будет открыто мое окно с проблемами на GitHub.

А что насчет всех этих проблем, на которые мы пошли, чтобы убедиться, что наш код потокобезопасен? Все было напрасно? Нет! На самом деле это ошибка времени выполнения в Go, если вы пытаетесь одновременно читать и записывать на карту. Пока наша константа Threaded истинна, этого никогда не происходит, потому что, честно говоря, наш код просто однопоточный, поэтому нет никакого риска, но в более общем плане потому, что до сих пор Threaded был истинным. Мы хотим проверить, что все наши усилия по обеспечению многопоточности нашего кода действительно окупаются, но как? Как заставить одновременное чтение и запись?

‹ТЕМАТИЧЕСКАЯ МУЗЫКА ДЖЕОПАРДИ›

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

func TestThreading(t *testing.T) {
    bloom := New()
    bloom.Set("Granniwinkle", 4.8)

    go func() {
        for {
            _, _ = bloom.Get("Granniwinkle")
        }
    }()
    time.Sleep(1 * time.Second)
    go func() {
    for {
        bloom.Set("Maude", 4.6)
    }
    }()
time.Sleep(5 * time.Second)

Пятисекундный тайм-аут в конце предназначен только для того, чтобы дать разумное окно для возникновения такой конкуренции, что приведет к сбою. Но подождите - при запуске go test сбоев не происходит! Это потому, что Threaded по-прежнему имеет значение true! Однако, чтобы убедиться, что наша защита от потоков действительно работает, нам нужно убедиться, что она не работает. Знаю, звучит парадоксально, но это все равно, что ехать на велосипеде с включенными тренировочными колесами. Пока вы их не снимете, вы никогда не узнаете, действительно ли они помогают или нет. Не волнуйтесь, это не так больно или сложно! Просто откройте cache.go и установите

Threaded = false

Теперь ни один из наших кодов «обучающего колеса» больше не будет работать, и мы наконец-то можем потерпеть фиаско! Давайте запустим go test и…

goroutine 5:
gocache.(*Cache).Set(…)
    cache.go
gocache.TestThreading.func2
    cache_test.go
created by gocache.TestThreading
    cache_test.go
exit status 2
FAIL     gocache 1.015s

Да! Я никогда не был так счастлив видеть FAIL заглавными буквами. Мы успешно инициировали одновременную ошибку чтения-записи, потому что наш кеш больше не синхронизировался за его мьютексом чтения-записи. А теперь измените Threaded обратно на true, пока Интернет не взорвался.

Вот и все! Мы готовы стать основой Интернета. Все, что нам нужно, это несколько покупателей. А пока вы можете похвастаться своим потокобезопасным кешем в Go и, возможно, добавить еще несколько функций, чтобы быть конкурентоспособными на рынке кеш-памяти.

Сноски:

[¹]: Сопровождающие пакетов должны поддерживать отдельный путь к файлу для каждого основного выпуска, поэтому, помимо бремени разработки, ничто не мешает кому-либо случайно или иным образом удалить старые версии. Пользователи пакета должны вручную обновлять каждый импорт пакета до новой основной версии. Идти в общем на компромиссы между централизацией и децентрализацией, никого не угодив. По крайней мере, с NPM все централизовано и, таким образом, организовано. Теоретически они могли бы предотвратить левую клавиатуру, если бы захотели. Go предположительно децентрализован, но затем требует, чтобы каждая новая система контроля версий добавлялась вручную (при условии утверждения сопровождающим и процесса PR) в go get и pkg.go.dev. Итак, одна из отличительных черт системы зависимостей Go заключается в том, что вы можете размещать свои модули где угодно; это не обязательно должно быть на их серверах. Тем не менее, когда вы действительно хотите, скажем, разместить на Gogs, вы не можете этого сделать, потому что специалисты по сопровождению Google не обновили свой белый список. Мне больше кажется, что децентрализованное требование больше касается освобождения места на сервере, в то же время позволяя Google контролировать, какие сайты пакетов поддерживаются.

[²]: сообщество @ Go, пожалуйста, не теряйте мою пищеварительную систему из-за упоминания дженериков. Обещаю, больше не буду.