Введение

Согласно официальному сайту, chi — это легковесный, идиоматический и композируемый маршрутизатор для создания HTTP-сервисов на Go. Он на 100% совместим с net/http и довольно легок в обращении, однако его документация предназначена скорее для опытных разработчиков, чем для новичков, поэтому я решил написать серию статей, в ходе которых мы будем постепенно развивать и перерабатывать простейший CRUD, написанный на chi.

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

Подготовка

Наш CRUD будет обслуживать хранение и обработку следующей структуры:

type CrudItem struct {
    Id          int
    Name        string
    Description string
    internal    string
}

За хранение записей будут отвечать следующие две переменные:

currentId := 1
storage := make(map[int]CrudItem)

Сущности мы будем сохранять в карте/словаре (вам как больше нравится?). При необходимости добавить значение в хранилище, оно добавляется по ключу currentId. Я хочу подчеркнуть, что это решение с запахом и не предназначено для использования в реальных проектах. В следующих частях мы отрефакторим механизм хранения, вынесем его за интерфейс и сделаем его потокобезопасным (но не сегодня).

CRUD

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

package main
import (
    "net/http"
    "github.com/go-chi/chi/v5"
)  

func main() {
    r := chi.NewRouter()
    http.ListenAndServe(":3000", r)
}

Она ничего не делает, кроме создания структуры маршрутизатора и запуска его обслуживания на трехтысячном порту.
Создание простейшего обработчика и навешивание его на паттерн пути в chi выглядит следующим образом:

  1. Выбрать метод марштрутизатора, соответствующий необходимому HTTP-методу

  2. Передать в него паттерн пути и обработчик http.HandlerFunc (функция с сигнатурой  func(w http.ResponseWriter, r *http.Request)). Из коробки нам доступны следующие HTTP-методы:

Connect(pattern string, h http.HandlerFunc)
Delete(pattern string, h http.HandlerFunc)
Get(pattern string, h http.HandlerFunc)
Head(pattern string, h http.HandlerFunc)
Options(pattern string, h http.HandlerFunc)
Patch(pattern string, h http.HandlerFunc)
Post(pattern string, h http.HandlerFunc)
Put(pattern string, h http.HandlerFunc)
Trace(pattern string, h http.HandlerFunc)

Этого достаточно для написания стандартного CRUD-а, но если вам необходимо написать обработчик собственного кастомного HTTP-метода, то вам сначала необходимо зарегистрировать его с помощью chi.RegisterMethod("JELLO"), а затем навесить на паттерн пути в маршрутизаторе обработчик с помощью r.Method("JELLO", "/path", myJelloMethodHandler).

Create

Код регистрации обработчика для добавления нового CrudItem в наше импровизированное хранилище выглядит следующим образом:

r.Post("/crud-items/", func(w http.ResponseWriter, r *http.Request) {
        var item CrudItem
        err := json.NewDecoder(r.Body).Decode(&item)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte(err.Error()))
            return
        }
        item.Id = currentId
        storage[currentId] = item
        jsonItem, err := json.Marshal(item)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte(err.Error()))
            return
        }
        w.Write(jsonItem)
        currentId += 1
    })

Что из себя представляет наша реализация обработчика:

  1. Пытаемся прочитать из тела запроса json и десериализовать его в структуру CrudItem. Валидный JSON выглядит так:

{
	"name": "New name",
	"description": "New description"
}
  1. Если по какой-то причине нам не удалось это сделать, мы говорим пользователю о том, что с его запросом что-то не так и заканчиваем работу.

  2. Присваиваем сущности Id и сохраняем в наше хранилище. Ходят легенды, что в хороших CRUD-ах принято возвращать добавленный объект с присвоенными ему идентификаторами, и мы поступаем так же:

  3. Сериализуем структуру CrudItem в json;

  4. В случае провала говорим пользователю, что что-то пошло не так по нашей вине;

  5. В случае успеха отправляем пользователю json и инкрементим текущий Id.

Read

Чтение мы сделаем двумя обработчиками:

  • Прочитать все записи;

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

r.Get("/crud-items/", func(w http.ResponseWriter, r *http.Request) {
        result := make([]CrudItem, 0, len(storage))
        for _, item := range storage {
            result = append(result, item)
        }
        resultJson, err := json.Marshal(result)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte(err.Error()))
            return
        }
        w.Write(resultJson)
    })

Гораздо интереснее выглядит обработчик получения записи по Id:

r.Get("/crud-items/{id}", func(w http.ResponseWriter, r *http.Request) {
        idStr := chi.URLParam(r, "id")
        id, err := strconv.Atoi(idStr)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte(err.Error()))
            return
        }
        if _, ok := storage[id]; !ok {
            w.WriteHeader(http.StatusNotFound)
            return
        }
        resultJson, err := json.Marshal(storage[id])
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte(err.Error()))
            return
        }
        w.Write(resultJson)
    })

Здесь мы воспользовались получением id записи из URL. Для этого мы:

  1. Задали в паттерне пути именной параметр id с помощью {id};

  2. С помощью chi.URLParam(r, "id") получили строковое значение параметра id;

  3. Попробовали привести параметр id к целому числу и в случае провала сообщили пользователю, что с его запросом что-то не так.

Update

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

r.Put("/crud-items/{id}", func(w http.ResponseWriter, r *http.Request) {
        idStr := chi.URLParam(r, "id")
        id, err := strconv.Atoi(idStr)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte(err.Error()))
            return
        }
        if _, ok := storage[id]; !ok {
            w.WriteHeader(http.StatusNotFound)
            return
        }
        var item CrudItem
        err = json.NewDecoder(r.Body).Decode(&item)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte(err.Error()))
            return
        }
        item.Id = id
        storage[id] = item
        jsonItem, err := json.Marshal(item)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            w.Write([]byte(err.Error()))
            return
        }
        w.Write(jsonItem)
    })

Delete

Удаление записи из нашего хранилища выглядит следующим образом:

r.Delete("/crud-items/{id}", func(w http.ResponseWriter, r *http.Request) {
        idStr := chi.URLParam(r, "id")
        id, err := strconv.Atoi(idStr)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte(err.Error()))
            return
        }
        
        if _, ok := storage[id]; !ok {
            w.WriteHeader(http.StatusNotFound)
            return
        }
        delete(storage, id)
    })

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

Что дальше?

На этом создание базового приложения заканчивается. Сегодня мы реализовали CRUD с 5-ю обработчиками, используя маршрутизатор chi, научились читать json из тела запроса, отправлять его в ответ и получать значение из паттерна пути.
Чему будут посвящены следующие статьи:

  • Рефакторинг хранилища и вынос его за интерфейс;

  • Пагинация для обработчика получения всех записей с использованием middleware;

  • Использование интерфейса Renderer и создание нормальных DTO;

  • Добавление логирования;

  • Авторизация;

  • Работа с prometeus (создание обработчика и написание middleware для сбора статистики по обработчикам).

Свои идеи, предложения и вопросы пишите в комментарии или мне в телеграм.

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


  1. SUNsung
    17.03.2024 10:20
    +3

    И в чем преймущество chi перед другими (тот же gin)?

    По коду не увидел особой разницы - если обернуть все руками (стандартный net плюс любой самописный парсер адреса) то особо количество и качество кода не выростет


    1. Brom95 Автор
      17.03.2024 10:20

      Честно говоря, с gin особо не работал, но как мне видится, chi немного проще,например, в приеме входных моделей с валидацией. В chi это делается через имплементацию интерфейса Binder у самой модели, в то время, как у gin это выглядит несколько сложнее: https://gin-gonic.com/docs/examples/custom-validators/

      По коду не увидел особой разницы

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

      2. Думаю, все net/httpсовместимые роутеры выглядят похоже


      1. SUNsung
        17.03.2024 10:20

        В gin валидации нет как таковой. И как по мне это правильно - не смешивать мух и котлеты


    1. floordiv
      17.03.2024 10:20
      +1

      По коду не увидел особой разницы - если обернуть все руками (стандартный net плюс любой самописный парсер адреса) то особо количество и качество кода не выростет

      зачем это делать? В чем смысл писать свой роутер каждый раз? Еще и парсер адреса (что бы это ни значило). Вам не лень потом сидеть и покрывать это каждый раз тестами?


      1. SUNsung
        17.03.2024 10:20
        +1

        Я так понимаю вы из тех, кто считает что модули растут на гитхабе как трава в полях?)

        Написать единый метод который в ответ тебе вернет адрес и вложеные в него параметры

        В зависимости от задачи и архитектуры можно и парой строк обойтись

        .

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

        Если програмист не знает как переиспользовать код и как создавать унитарные методы для разных задач, то ему нечего делать в програмировании (максимум фронт-енд, и то базовый только)


    1. Mi7teR
      17.03.2024 10:20

      у chi перед gin есть преимущество в совместимости с net/http


  1. manyakRus
    17.03.2024 10:20

    https://github.com/ManyakRus/crud_generator
    а вот мой CRUD + GRPC генератор кода,
    который весь этот код сам напишет за вас :-)