Система заметок с нуля. Часть 4: разработка микросервисов NoteService, TagService и UserService
Разработка микросервисов на Golang и работа с MongoDB. Видео с примерами кода и подробным объяснением принятых решений.
2К открытий2К показов
Продолжаем разрабатывать систему заметок с нуля.
В первой части мы спроектировали микросервисную архитектуру.
Во второй части разработали RESTful API Service на Golang cо Swagger и авторизацией.
Третья часть была посвящена знакомству с графовой БД Neo4j и работе над микросервисами CategoryService и APIService.
На этот раз мы займёмся разработкой NoteService и TagService. Также посмотрим на изменения в APIService. Подробности в видео и текстовой расшифровке под ним.
Репозиторий проекта на GitHub
Разработка NoteService
Начнём с сервиса заметок. Он написан на Golang.
Для всех сервисов я написал общий код работы с ошибками. В папке internal/apperror в файле error.go структура кастомной ошибки AppError, которая состоит из полей Err, Message, DeveloperMessage и Code. Также тут есть метод создания новой ошибки, который превращает переменную message в ошибку в поле Err.
- Метод Error() — для поддержки стандартного интерфейса error.
- Метод Unwrap — чтобы вернуть корневую ошибку.
- Метод Marshal — вспомогательный, просто так удобнее.
Также здесь методы для быстрого получения ошибки со стандартным кодом — это BadRequestError и systemError. Наверху объявлены отдельные переменные готовых ошибок, которые влияют на HTTP статус-код ответа.
Теперь идём в файл middleware.go. Тут у нас middleware, обрабатывающий ошибку, которую вернул хендлер. Если ошибка кастомная и если это ошибка ErrNotFound, то надо отдать 404 код ответа. Если ошибка другая, то проблема в пользовательских данных, надо отдать код 400. Если же ошибка не кастомная, то оборачиваем ее в systemError и отдаём 418 код.
Файлы handler.go и model.go
Теперь перейдём к хендлерам. Все хендлеры обернуты в структуру и имеют интерфейс с методом Register, который регистрирует методы в роутере. В структуре хендлера есть Logger и NoteService, который и занимается бизнес-логикой. Сверху указаны две константы. Это URL для работы с множеством заметок и с конкретной заметкой.
Метод GetNote — берём из контекста параметр по имени uuid, который так указан в виде якоря в константе URL, валидируем его и вызываем метод GetOne у NoteService, после чего маршалим структуру и отдаём заметку. Здесь и далее я не обрабатываю ошибки, так как они будут обработаны в Middleware, что очень удобно.
Метод GetNotesByCategory — получение списка заметок определённой категории. Здесь мы из URL вытаскиваем параметр category_uuid. Остальное всё так же как и в предыдущем методе.
В методе CreateNote мы первый раз используем паттерн DTO. Мы декодируем body в объект CreateNoteDTO. DTO — это паттерн Data Transfer Object, который содержит в себе все пользовательские данные для выполнения запроса. В Golang DTO —это структура с полями, которые мы ожидаем в body в виде JSON. Все DTO и модель заметки у меня лежат в файле model.go. DTO создаются на все действия, где есть пользовательские данные. В нашем случае это создание и обновление заметки. Создав DTO, мы вызываем метод Create у NoteService и передаём туда созданный объект. Также по всем правилам хорошего RESTful-сервиса, мы возвращаем статус 201 и заголовок Location, в котором будет URL для получения созданной заметки.
PartiallyUpdateNote — метод обновления заметки. Здесь мы вытаскиваем параметр uuid из контекста, вычитываем body в виде массива байт, анмаршалим байты в объект UpdateNoteDTO. Тут должен возникнуть вопрос: зачем вычитывать байты и потом их превращать в объект? Ведь можно сразу воспользоваться методом decode, как я это делал в методе создания заметки.
Дело в том, что у заметки есть теги. Это массив int. Их можно обновить, послав список int в JSON. Но при создании объекта UpdateNoteDTO, если списка тегов не прислали в body или прислали его пустым, то у объекта он тоже будет пустой. Как тогда определить: его не прислали или прислали пустым, а значит, надо удалить все теги? Для этого я как раз и вычитал все байты из body заранее. Далее мы создаём переменную tagsUpdate. Если в объекте DTO длина списка тегов равна нулю, значит, мы не знаем, присылали ли теги. Тогда ещё раз анмаршалим массив байт из body в структуру map и проверяем там наличие ключа tags. Если он там был, значит, прислали пустой список тегов и надо их обновить. Далее вызываем метод Update и передаем uuid заметки, DTO и флаг с тегами. Возвращаем 204 NoContent.
Метод DeleteNote простой. Вытаскиваем uuid заметки и дёргаем сервис.
Файл service.go
Теперь давайте посмотрим на сервис. Он использует объект storage, который работает непосредственно с хранилищем заметок. Останавливаться на сервисе подробно не будем. Он крайне простой. Генерируем короткое описание заметки для отображения в списке, делаем небольшую валидацию и всё. Единственный момент, который стоит отметить, — анализ возвращаемой ошибки от вызова методов storage и проверка того, является ли она ErroNotFound. Если является, то я просто прокидываю ошибку выше, а если нет, то оборачиваю дополнительной информацией.
Файл storage.go
В файле storage.go лежит основной интерфейс со всеми методами. Все реализации находятся в папке db.
Текущая реализация для MongoDB расположена в файле mongodb.go. Здесь есть метод создания NewStorage, который формирует URL коннекта.
Я создаю объект credentials с данными для подключения, включая БД авторизации. Параметр AuthSource — это указание, в какой базе искать пользователя, под которым я пытаюсь подключиться. У меня БД называется notes_system, а пользователя testadm я создал в дефолтной базе admin. Если MongoDB не сказать, что AuthSource — это admin, то она не знает, где искать юзера. Скорее всего, по умолчанию она будет искать в той же БД, куда мы коннектимся, и не найдёт.
Далее мы коннектимся с БД и получаем объект коллекции. Затем проверяем, приконнектились ли мы к БД с помощи метода Ping. Я это выделил в отдельный метод, так как буду проверять при каждом обращении. Пингуем БД мы также с контекстом и таймаутом в 5 секунд.
Методы здесь те же, что мы используем в сервисе. Начнём с создания заметки:
- Получаем на вход DTO.
- Отдаём его без обработки в метод InsertOne.
- На выходе получаем result, в котором будет InsertedID, являющийся ObjectID.
MongoDB для каждого документа генерирует бинарный ObjectID, у которого есть чудесный Hex, похожий на UUID. Его можно использовать как Primary Key. Я так и поступил, поэтому для отдачи я кастую InsertedID в ObjectID и после вызываю метод Hex(). Отдаю эту строку как идентификатор созданной заметки.
Далее идёт поиск одной заметки. Получаем на вход тот самый Hex, который нам надо превратить в ObjectID. Что и мы и делаем, вызывая метод ObjectIDFromHex из пакета primitive. Затем создаём фильтр. Здесь мы берем структуру bson.M.
Документы в MongoDB хранятся в бинарном формате, который называется bson. В отличие от JSON, у которого числа, строки и булевые значения, у bson больше типов: int, long, date, float, decimal128. Это нужно в первую очередь для самой Mongodb для упрощения работы с данными. Всего в Golang четыре типа bson-структур:
- D — документ bson. Этот тип следует использовать в ситуациях, когда порядок имеет значение, например, команды для MongoDB.
- M — просто map, он работает и ведёт себя как D, только не гарантирует порядок.
- A — массив bson.
- E — одиночный элемент внутри D и M.
После создания фильтра создаём опции поиска FindeOneOptions. В данном случае мы используем параметр Projection, позволяющий выбрать поля, которые нам вернутся или не вернутся. В данном случае для поля short_body мы выставляем значение 0, чтобы оно нам не вернулось, так как мы ищем одну заметку, а поле short_body имеет смысл только для списочного представления.
Далее мы опять же через контекст с таймаутом в 5 секунд вызываем у объекта коллекции метод FindOne и получаем result. Ошибку получаем через метод Err у result и проверяем только на наличие ошибки ErrNoDocuments, то есть такой заметки нет. Тогда отдаём кастомную ошибку ErrNotFound. В остальных случаях пробрасываем её наверх, оборачивая дополнительной информацией. Если всё ок, то декодируем данные из result в переменную n. Переменная n нигде не инициализируется в теле метода. Здесь и далее я использовал именованные выходные параметры, и Golang сам проинициализировал их. Поэтому остаётся только вернуть n и nil.
В методе FindByCategoryUUID мы создаём фильтр и опции поиска, вызываем метод Find, но таймаут сделаем уже 10 секунд. Всё-таки список заметок получаем, мало ли что. На выходе получаем курсор, также проверяем ошибку ErrNoDocuments и вычитываем все из курсора в массив notes. Возвращаем массив notes и nil.
В методе Update мы маршалим наше DTO в байты, далее анмаршалим их в структуру bson.M и создаём объект update с ключом $set. Если надо обновить теги, то добавляем их принудительно, чтобы очистить список, если все теги удалили. После чего вызываем метод UpdateOne и получаем result. Если переменная MatchedCount равна нулю, значит, мы не нашли такую заметку и надо отдать 404. Отдаём ошибку ErrNotFound.
Метод Delete простой — удаляем по фильтру один документ.
В целом по этому сервису все. Давайте теперь его простестируем. Так как категорией может быть в целом любая строка, создадим заметку с категорий 123. Вытаскиваем идентификатор из заголовка Location. Теперь мы можем получить заметки категории 123, подставив в параметр category_uuid значение 123. Также мы можем получить заметку по uuid. Давайте обновим её: поменяем заголовок и добавим 3 тега. Посмотрим, что всё изменилось. А теперь удалим все теги. Работает! Удалим эту заметку. Теперь метод получения заметок по категории возвращает 404.
Разработка TagService
Теперь давайте посмотрим на TagService. По своей структуре и логике он такой же, как NoteService. Но я бы хотел рассмотреть метод создания тега в БД у Storage. Для тега я решил взять интовый сиквенс и нумеровать по возрастанию. Но в MongoDB такого нет из коробки, поэтому пришлось велосипедить.
Я реализовал метод Optimistic Loop.
- Создаём findOptions.
- Делаем сортировку по полю _id на убывание.
- Достаём первый элемент.
Таким образом мы должны получить максимальный индекс последнего тега. В цикле перебираем полученные теги. Он должен быть один, но может быть всё что угодно. Берём его значение поля _id, добавляем 1 и пытаемся вставить. Если ловим ошибку duplicate key error (об этом скажет метод IsDuplicateKeyError), то продолжаем перебирать. Это может продолжаться бесконечно, но я ограничился 3 попытками через счётчик tryCount.
Это дикий и странный велосипед, потому что в mongoDB нет сиквенсов и я решил изменить тип первичного ключа документа. Зачем я так сделал? Это весело, плюс хотел показать вам, что так тоже можно.
В остальном же сервис похож на NoteService. Мы можем получить кучу тегов, проставив в значение id кучу int. Если каких-то тегов нет, то мы получим только те, что есть. Если нет никаких тегов, то получим ошибку 404.
Также я реализовал UserService. Он похож на NoteService и TagService, код можно посмотреть в GitHub-репозитории.
Разработка APIService
Для APIService я реализовал все клиенты ко всем сервисам. Так как клиенты по сути одинаковые и методы похожи, мы разберём общую концепцию на примере клиента к NoteService.
Для всех клиентов я реализовал общий код работы с RESTful-сервисами. В директории rest есть файлы api.go, client.go и url.go. В файле url.go осталась структура опций фильтрации. В файле client.go лежит структура базового клиента.
У базового клиента есть метод SendRequest, который через Lock мьютекса (это нужно для асинхронной работы) выставляет заголовки Accept и Content-Type, выполняет запрос, полученный на входе, и создаёт ответ в виде APIResponse. Далее смотрим статус-код. Если тот негативный, то пытаемся декодировать body в структуру APIError. Даже если не получилось, всё равно отдаём объект APIResponse.
APIResponse — это обёртка над response. Она живёт в файле api.go. Я упростил метод вычитывания данных из body и добавил поле IsOk. На самом деле этот класс по сути не нужен. Но он отлично демонстрирует, что в Golang можно обернуть любую структуру другой структурой и создавать вспомогательных методов, которые упростят код и улучшат читаемость.
Я проверяю, всё ли ок с ответом от вызова метода SendRequest. Если да, то читаю body, получаю данные из него и отдаю. Если с ответом не всё в порядке, то получаю данные по ошибке из response и формирую кастомную APIError.
Нам осталось реализовать FileService с хранилищем MinIO, упаковать все сервисы в докер-контейнеры и раздеплоить систему при помощи GitHub Actions на сервера, после чего уже можно будет реализовывать клиента в виде веб-приложения на Vue.js.
Группа автора «ВКонтакте», Telegram, Twitter.
2К открытий2К показов