Привет, Хабр!

Сегодня мы попробуем реализовать управление состоянием в Go‑приложениях с помощью паттерна Redux. Да‑да, Redux не только для JS.

Redux — это предсказуемый контейнер состояния для приложений. Он помогает управлять состоянием приложения централизованно, делая его более предсказуемым и удобным для отладки. В основном Redux ассоциируется с фронтендом на JavaScript, но принципы, лежащие в его основе, иногда могут подойти и для Go‑приложений.

Основные концепции Redux:

  1. Store: Централизованное хранилище состояния.

  2. Actions: Описания того, что произошло.

  3. Reducers: Функции, которые определяют, как состояние изменяется в ответ на действия.

  4. Dispatch: Процесс отправки действий в хранилище.

Создаем Redux-подобную систему в Go

Определяем состояние

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

// state.go
package main

// Cat представляет собой котика
type Cat struct {
    ID        int
    Name      string
    Breed     string
    IsAdopted bool
}

// AppState хранит текущее состояние приложения
type AppState struct {
    Cats []Cat
}

Определяем действия

Действия описывают, что происходит в нашем приложении. Например, добавление задачи, удаление задачи или изменение статуса задачи.

// actions.go
package main

// ActionType определяет тип действия
type ActionType string

const (
    AddCat          ActionType = "ADD_CAT"
    RemoveCat       ActionType = "REMOVE_CAT"
    ToggleAdoption  ActionType = "TOGGLE_ADOPTION"
)

// Action представляет собой действие
type Action struct {
    Type    ActionType
    Payload interface{}
}

Создаем редьюсеры

Редьюсеры определяют, как состояние изменяется в ответ на действия.

// reducers.go
package main

// Reducer функция, которая принимает состояние и действие, и возвращает новое состояние
type Reducer func(state AppState, action Action) AppState

// rootReducer объединяет все редьюсеры
func rootReducer(state AppState, action Action) AppState {
    switch action.Type {
    case AddCat:
        cat, ok := action.Payload.(Cat)
        if !ok {
            return state
        }
        cat.ID = len(state.Cats) + 1
        state.Cats = append(state.Cats, cat)
    case RemoveCat:
        id, ok := action.Payload.(int)
        if !ok {
            return state
        }
        for i, cat := range state.Cats {
            if cat.ID == id {
                state.Cats = append(state.Cats[:i], state.Cats[i+1:]...)
                break
            }
        }
    case ToggleAdoption:
        id, ok := action.Payload.(int)
        if !ok {
            return state
        }
        for i, cat := range state.Cats {
            if cat.ID == id {
                state.Cats[i].IsAdopted = !state.Cats[i].IsAdopted
                break
            }
        }
    default:
        // Неизвестное действие, возвращаем состояние без изменений
    }
    return state
}

Создаем хранилище

Хранилище управляет состоянием и обрабатывает действия через редьюсеры.

// store.go
package main

import "sync"

// Store хранит состояние и позволяет подписываться на его изменения
type Store struct {
    state       AppState
    reducer     Reducer
    mutex       sync.RWMutex
    subscribers []chan AppState
}

// NewStore создает новое хранилище
func NewStore(reducer Reducer, initialState AppState) *Store {
    return &Store{
        state:       initialState,
        reducer:     reducer,
        subscribers: make([]chan AppState, 0),
    }
}

// GetState возвращает текущее состояние
func (s *Store) GetState() AppState {
    s.mutex.RLock()
    defer s.mutex.RUnlock()
    return s.state
}

// Dispatch отправляет действие и обновляет состояние
func (s *Store) Dispatch(action Action) {
    s.mutex.Lock()
    s.state = s.reducer(s.state, action)
    // Копируем подписчиков, чтобы избежать блокировок
    subscribers := append([]chan AppState{}, s.subscribers...)
    s.mutex.Unlock()

    // Уведомляем всех подписчиков
    for _, sub := range subscribers {
        // Не блокируем основной поток
        go func(ch chan AppState) {
            ch <- s.state
        }(sub)
    }
}

// Subscribe добавляет нового подписчика
func (s *Store) Subscribe() chan AppState {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    ch := make(chan AppState, 1)
    s.subscribers = append(s.subscribers, ch)
    // Отправляем текущее состояние сразу после подписки
    ch <- s.state
    return ch
}

Используем хранилище в приложении

Теперь можно использовать хранилище, редьюсеры и действия, приложении:

// main.go
package main

import (
    "fmt"
    "time"
)

func main() {
    initialState := AppState{
        Cats: []Cat{},
    }

    store := NewStore(rootReducer, initialState)

    // Подписываемся на изменения состояния
    subscriber := store.Subscribe()

    // Запускаем горутину для обработки изменений состояния
    go func() {
        for state := range subscriber {
            fmt.Println("Текущее состояние котиков:")
            for _, cat := range state.Cats {
                status := "Не усыновлен"
                if cat.IsAdopted {
                    status = "Усыновлен"
                }
                fmt.Printf("ID: %d, Имя: %s, Порода: %s, Статус: %s\n", cat.ID, cat.Name, cat.Breed, status)
            }
            fmt.Println("-----")
        }
    }()

    // Диспатчим действия
    store.Dispatch(Action{
        Type: AddCat,
        Payload: Cat{
            Name:  "Мурзик",
            Breed: "Сиамская",
        },
    })

    store.Dispatch(Action{
        Type: AddCat,
        Payload: Cat{
            Name:  "Барсик",
            Breed: "Британская",
        },
    })

    time.Sleep(500 * time.Millisecond) // Ждем, чтобы горутина успела обработать

    store.Dispatch(Action{
        Type:    ToggleAdoption,
        Payload: 1,
    })

    time.Sleep(500 * time.Millisecond) // Ждем обновлений

    store.Dispatch(Action{
        Type:    RemoveCat,
        Payload: 2,
    })

    time.Sleep(500 * time.Millisecond) // Ждем финальных обновлений
}

Запускаем

После запуска получим следующий вывод:

Текущее состояние котиков:
ID: 1, Имя: Мурзик, Порода: Сиамская, Статус: Не усыновлен
-----
Текущее состояние котиков:
ID: 1, Имя: Мурзик, Порода: Сиамская, Статус: Не усыновлен
ID: 2, Имя: Барсик, Порода: Британская, Статус: Не усыновлен
-----
Текущее состояние котиков:
ID: 1, Имя: Мурзик, Порода: Сиамская, Статус: Усыновлен
ID: 2, Имя: Барсик, Порода: Британская, Статус: Не усыновлен
-----
Текущее состояние котиков:
ID: 1, Имя: Мурзик, Порода: Сиамская, Статус: Усыновлен
-----

Итак, этот паттерн хорошо впишется, если нужно централизованно управлять состоянием приложения и следить за его изменениями. Если проект растет и управление состоянием становится сложным, Redux-подход в Go может в чем-то упростить жизнь.

Но есть и пару моментов, о которых стоит помнить:

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

  • Будь осторожен с подписками — без механизма отписки могут возникнуть утечки горутин.

  • Не перегружай хранилище: хранение слишком большого состояния может замедлить приложение.

  • Избегай гонок данных — всегда используй мьютексы или другие механизмы синхронизации при работе с состоянием.

Используй Redux-подход разумно. Если есть вопросы или идеи, пишите в комментариях.

В рамках курса "Software Architect" в ноябре пройдут открытые уроки:

  • 7 ноября: «Стратегии тестирования в архитектуре микросервисов». Узнать подробнее

  • 19 ноября: «Паттерны отказоустойчивости и масштабируемости микросервисной архитектуры». Узнать подробнее

Комментарии (4)


  1. RodionGork
    01.11.2024 07:05

    Это наверное полезно и интересно, но сам паттерн у вас описан как-то очень куце - из-за чего не зная о чем речь довольно трудно понять что вы собрались сделать и неужели без паттерна этот функционал будет выглядеть сильно иначе. Может быть стоит расширить этот раздел.

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

    state.Cats = append(state.Cats[:i], state.Cats[i+1:]...)

    ну всё же удаление элемента можно экономичнее сделать - просто последний перемещаете на место удалённого и уменьшаете длину...

    но главное - если вам их надо мэпить по айдишникам, чего ж мэпу-то не использовать?


  1. varanio
    01.11.2024 07:05

    господи свят. Какую проблему мы решаем?


  1. evgeniy_kudinov
    01.11.2024 07:05

    s/Нехватает EventLoop на go/s


  1. Dreddsa
    01.11.2024 07:05

    Хотел написать как надо, а потом подумал - без толку