Эта статья является продолжением истории написания demo-блога на микросервисах (предыдущие части можно почитать здесь: Часть 1 «Общее описание архитектуры», Часть 2 «API Gateway», Часть 3 «Сервис User»). В этой статье речь пойдет о реализации микросервиса Post (статьи).
Основной особенностью микросервиса является то, что он реализует различные виды связей с другими сервисами. Например, с сервисом Comments (комментарии) реализован тип связи один ко многим (у одной статьи может быть несколько комментариев), а с сервисами User и Category реализованы связи многое к одному (т.е. у одного пользователя может быть много статей и у одной категории может быть несколько статей).
С точки зрения функциональности в сервисе Post будут реализованы следующие методы:
Традиционно создание микросервиса начнем с его описания в протофайле
Далее генерим каркас микросервиса. Для этого переходим в корневой каталог проекта и выполняем команду sh ./bin/protogen.sh.
Супер! Большую часть работы за нас сделал кодогенератор, нам осталось только написать реализацию прикладных функций. Открываем файл ./services/post/functions.go и пишем реализацию.
Рассмотрим основные фрагменты функциии Create.
1. Парсим контекст вызова и достаем из него информацию о пользователе.
2. Проверяем параметры запроса и если они содержат недопустимые значения, возвращаем соответствующую ошибку.
3. Сохраняем Post в БД (mongoDB).
4. Получаем Id созданной записи, добавляем ее к ответу и возвращаем ответ.
Ранее я упоминал, что сервис Post интересен своими связями с другими сервисами. Наглядно это демонстрирует метод Get (получить Post по заданному ID).
Для начала прочитаем из mongoDB Post:
Здесь все более-менее просто. вначале преобразуем строку в ObjectID и далее используем его в filter для поиска записи.
Теперь нам нужно полученную запись Post обогатить данными об авторе. Для этого нужно сходить в сервис User и получить запись по заданному UserId. Сделать это можно следующим образом:
Хочу обратить внимание, что я умышленно использую два разных термина User и Author, т.к. считаю, что они лежат в разных контекстах. User — это про логины/пароли аутентификацию и прочие атрибуты и функции так или иначе связанные с безопасностью и доступами. Author — это сущность про опубликованные посты, комментарии и прочее. Сущность Author рождается в контексте Post используя за основу данные из User. (надеюсь мне удалось объяснить разницу ;)
Следующим шагом вычитываем данные по связанным категориям из сервиса Category. Не уверен, что предлагаю оптимальный вариант (надеюсь сообщество поправит). Суть подхода следующая: делаем ОДИН запрос в сервис Category и вычитываем ВСЕ существующие категории, далее в сервисе Post выбираем только те категории, которые связаны с Post. Минус данного подхода — оверхэд по передаваемым данным, плюс — делаем всего один запрос. Т.к. кол-во категорий это определенно не зашкаливающая величина считаю что оверхэдом можно пренебречь.
Следующее что нам следует сделать это получить все связанные комментарии. Здесь задача похожа на задачу с категориями, за исключением, что в случае с категориями Id связанных категорий у нас хранились в Post, в случае с комментариями наооборот Id родительского Post хранится непосредственно в дочерних комментариях. На самом деле это сильно упрощает задачу, т.к. все что нам нужно, это сделать запрос в сервис Comments с указанием родительского Post и обработать результат — в цикле добавить к Post все связанные PostComment
И возвращаем собранный Post
В web интерфейсе у нас реализована навигация по категориям и по авторам. Т.е. когда пользователь кликает по категории ему отображается список всех статей, которые ссылаются на выбранную категорию. А когда кликает по автору, соответственно отображается список статей, где автором указан выбранный пользователь.
Для реализации этой функциональности в сервисе Post предусмотрены два метода:
GetPostCategory — возвращает структуру PostCategory, которая содержит ID, наименование категории и коллекцию связанных статей
GetAuthor — возвращает структуру Author котора содержит атрибуты пользователя (FirstName, LastName и т. п.) и коллекцию связанных Post.
Подробно описывать реализацию этих методов не буду дабы не повторяться. Они базируются на тех же фрагментах кода что были описаны выше.
Основной особенностью микросервиса является то, что он реализует различные виды связей с другими сервисами. Например, с сервисом Comments (комментарии) реализован тип связи один ко многим (у одной статьи может быть несколько комментариев), а с сервисами User и Category реализованы связи многое к одному (т.е. у одного пользователя может быть много статей и у одной категории может быть несколько статей).
С точки зрения функциональности в сервисе Post будут реализованы следующие методы:
- Логирование запросов к сервису и промежуточных состояния (механизм подробно описан в статье Часть 3 «Сервис User») с указанием TraceId (тот самый, который был выдан api-gw, см. Часть 2 «API Gateway»)
- Функции CRUD (создание, чтение, редактирование, удаление записи в БД — MongoDB).
- Функции поиска: поиск всех статей, поиск по категории, поиск по автору
Традиционно создание микросервиса начнем с его описания в протофайле
//post.proto
yntax = "proto3";
package protobuf;
import "google/api/annotations.proto";
// Описание сервиса Post
service PostService {
//Создание статьи
rpc Create (CreatePostRequest) returns (CreatePostResponse) {
option (google.api.http) = {
post: "/api/v1/post"
};
}
//Обновление статьи
rpc Update (UpdatePostRequest) returns (UpdatePostResponse) {
option (google.api.http) = {
post: "/api/v1/post/{Slug}"
};
}
//Удаление статьи
rpc Delete (DeletePostRequest) returns (DeletePostResponse) {
option (google.api.http) = {
delete: "/api/v1/post/{Slug}"
};
}
//Информация о категории и связанных постах
rpc GetPostCategory (GetPostCategoryRequest) returns (GetPostCategoryResponse) { //Возвращает категорию и связанные посты
option (google.api.http) = {
get: "/api/v1/post/category/{Slug}"
};
}
//Список всех постов
rpc Find (FindPostRequest) returns (FindPostResponse) {
option (google.api.http) = {
get: "/api/v1/post"
};
}
//Возвращает одну статью по ключу
rpc Get (GetPostRequest) returns (GetPostResponse) {
option (google.api.http) = {
get: "/api/v1/post/{Slug}"
};
}
//Информация о авторе
rpc GetAuthor (GetAuthorRequest) returns (GetAuthorResponse) { //Возвращает одного автора по SLUG
option (google.api.http) = {
get: "/api/v1/author/{Slug}"
};
}
//Список всех авторов
rpc FindAuthors (FindAuthorRequest) returns (FindAuthorResponse) { //Возвращает список авторов
option (google.api.http) = {
get: "/api/v1/author"
};
}
}
//---------------------------------------------------------------
// CREATE
//---------------------------------------------------------------
message CreatePostRequest {
string Title = 1;
string SubTitle = 2;
string Content = 3;
string Categories = 4;
}
message CreatePostResponse {
Post Post = 1;
}
//---------------------------------------------------------------
// UPDATE
//---------------------------------------------------------------
message UpdatePostRequest {
string Slug = 1;
string Title = 2;
string SubTitle = 3;
string Content = 4;
int32 Status = 5;
string Categories = 6;
}
message UpdatePostResponse {
int32 Status =1;
}
//---------------------------------------------------------------
// DELETE
//---------------------------------------------------------------
message DeletePostRequest {
string Slug = 1;
}
message DeletePostResponse {
int32 Status =1;
}
//---------------------------------------------------------------
// GET
//---------------------------------------------------------------
message GetPostRequest {
string Slug = 1;
}
message GetPostResponse {
Post Post = 1;
}
//---------------------------------------------------------------
// FIND POST
//---------------------------------------------------------------
message FindPostRequest {
string Slug = 1;
}
message FindPostResponse {
repeated Post Posts = 1;
}
//---------------------------------------------------------------
// GET AUTHOR
//---------------------------------------------------------------
message GetAuthorRequest {
string Slug = 1;
}
message GetAuthorResponse {
Author Author = 1;
}
//---------------------------------------------------------------
// FIND AUTHOR
//---------------------------------------------------------------
message FindAuthorRequest {
string Slug = 1;
}
message FindAuthorResponse {
repeated Author Authors = 1;
}
//---------------------------------------------------------------
// GET CATEGORY
//---------------------------------------------------------------
message GetPostCategoryRequest {
string Slug = 1;
}
message GetPostCategoryResponse {
PostCategory Category = 1;
}
//---------------------------------------------------------------
// POST
//---------------------------------------------------------------
message Post {
string Slug = 1;
string Title = 2;
string SubTitle = 3;
string Content = 4;
string UserId = 5;
int32 Status = 6;
string Src = 7;
Author Author = 8;
string Categories = 9;
repeated PostCategory PostCategories = 10;
string Comments = 11;
repeated PostComment PostComments = 12;
}
//---------------------------------------------------------------
// Author
//---------------------------------------------------------------
message Author {
string Slug = 1;
string FirstName = 2;
string LastName = 3;
string SrcAvatar = 4;
string SrcCover = 5;
repeated Post Posts = 6;
}
//---------------------------------------------------------------
// PostCategory
//---------------------------------------------------------------
message PostCategory {
string Slug = 1;
string Name = 2;
repeated Post Posts = 3;
}
//---------------------------------------------------------------
// PostComment
//---------------------------------------------------------------
message PostComment {
string Slug = 1;
string Content = 2;
Author Author = 3;
}
Далее генерим каркас микросервиса. Для этого переходим в корневой каталог проекта и выполняем команду sh ./bin/protogen.sh.
Супер! Большую часть работы за нас сделал кодогенератор, нам осталось только написать реализацию прикладных функций. Открываем файл ./services/post/functions.go и пишем реализацию.
Рассмотрим основные фрагменты функциии Create.
1. Парсим контекст вызова и достаем из него информацию о пользователе.
...
md,_:=metadata.FromIncomingContext(ctx)
var userId string
if len(md["user-id"])>0{
userId=md["user-id"][0]
}
...
2. Проверяем параметры запроса и если они содержат недопустимые значения, возвращаем соответствующую ошибку.
...
if in.Title==""{
return nil,app.ErrTitleIsEmpty
}
...
3. Сохраняем Post в БД (mongoDB).
...
collection := o.DbClient.Database("blog").Collection("posts")
post:=&Post{
Title:in.Title,
SubTitle:in.SubTitle,
Content:in.Content,
Status:app.STATUS_NEW,
UserId:userId,
Categories:in.Categories,
}
insertResult, err := collection.InsertOne(context.TODO(), post)
if err != nil {
return nil,err
}
...
4. Получаем Id созданной записи, добавляем ее к ответу и возвращаем ответ.
...
if oid, ok := insertResult.InsertedID.(primitive.ObjectID); ok {
post.Slug=fmt.Sprintf("%s",oid.Hex())
}else {
err:=app.ErrInsert
return out,err
}
out.Post=post
return out,nil
...
Ранее я упоминал, что сервис Post интересен своими связями с другими сервисами. Наглядно это демонстрирует метод Get (получить Post по заданному ID).
Для начала прочитаем из mongoDB Post:
...
collection := o.DbClient.Database("blog").Collection("posts")
post:=&Post{}
id, err := primitive.ObjectIDFromHex(in.Slug)
if err != nil {
return nil,err
}
filter:= bson.M{"_id": id}
err= collection.FindOne(context.TODO(), filter).Decode(post)
if err != nil {
return nil,err
}
...
Здесь все более-менее просто. вначале преобразуем строку в ObjectID и далее используем его в filter для поиска записи.
Теперь нам нужно полученную запись Post обогатить данными об авторе. Для этого нужно сходить в сервис User и получить запись по заданному UserId. Сделать это можно следующим образом:
...
//Запрос к сервису User
var header, trailer metadata.MD
resp, err := o.UserService.Get(
getCallContext(ctx),
&userService.GetUserRequest{Slug:post.UserId},
grpc.Header(&header), //метадата со стороны сервера в начале запоса
grpc.Trailer(&trailer), //метадата со стороны сервера в коне запоса
)
if err != nil {
return nil,err
}
author:=&Author{
Slug:resp.User.Slug,
FirstName:resp.User.FirstName,
LastName:resp.User.LastName,
SrcAvatar:SRC_AVATAR, //TODO - заглушка
SrcCover:SRC_COVER, //TODO - заглушка
}
post.Author=author
...
Хочу обратить внимание, что я умышленно использую два разных термина User и Author, т.к. считаю, что они лежат в разных контекстах. User — это про логины/пароли аутентификацию и прочие атрибуты и функции так или иначе связанные с безопасностью и доступами. Author — это сущность про опубликованные посты, комментарии и прочее. Сущность Author рождается в контексте Post используя за основу данные из User. (надеюсь мне удалось объяснить разницу ;)
Следующим шагом вычитываем данные по связанным категориям из сервиса Category. Не уверен, что предлагаю оптимальный вариант (надеюсь сообщество поправит). Суть подхода следующая: делаем ОДИН запрос в сервис Category и вычитываем ВСЕ существующие категории, далее в сервисе Post выбираем только те категории, которые связаны с Post. Минус данного подхода — оверхэд по передаваемым данным, плюс — делаем всего один запрос. Т.к. кол-во категорий это определенно не зашкаливающая величина считаю что оверхэдом можно пренебречь.
...
//Запрос к сервису Category, JOIN category
respCategory,err:=o.CategoryService.Find(
getCallContext(ctx),
&categoryService.FindCategoryRequest{},
)
if err != nil {
return out,err
}
for _, category:= range respCategory.Categories {
for _, category_slug:= range strings.Split(post.Categories,",") {
if category.Slug==category_slug{
postCategor:=&PostCategory{
Slug:category.Slug,
Name:category.Name,
}
post.PostCategories=append(post.PostCategories,postCategor)
}
}
}
...
Следующее что нам следует сделать это получить все связанные комментарии. Здесь задача похожа на задачу с категориями, за исключением, что в случае с категориями Id связанных категорий у нас хранились в Post, в случае с комментариями наооборот Id родительского Post хранится непосредственно в дочерних комментариях. На самом деле это сильно упрощает задачу, т.к. все что нам нужно, это сделать запрос в сервис Comments с указанием родительского Post и обработать результат — в цикле добавить к Post все связанные PostComment
...
//Запрос к сервису Comments, JOIN comments
respComment,err:=o.CommentService.Find(
getCallContext(ctx),
&commentService.FindCommentRequest{PostId:in.Slug},
)
if err != nil {
return out,err
}
for _, comment:= range respComment.Comments {
postComment:=&PostComment{
Slug:comment.Slug,
Content:comment.Content,
}
post.PostComments=append(post.PostComments,postComment)
}
...
И возвращаем собранный Post
...
out.Post=post
return out,nil
...
В web интерфейсе у нас реализована навигация по категориям и по авторам. Т.е. когда пользователь кликает по категории ему отображается список всех статей, которые ссылаются на выбранную категорию. А когда кликает по автору, соответственно отображается список статей, где автором указан выбранный пользователь.
Для реализации этой функциональности в сервисе Post предусмотрены два метода:
GetPostCategory — возвращает структуру PostCategory, которая содержит ID, наименование категории и коллекцию связанных статей
GetAuthor — возвращает структуру Author котора содержит атрибуты пользователя (FirstName, LastName и т. п.) и коллекцию связанных Post.
Подробно описывать реализацию этих методов не буду дабы не повторяться. Они базируются на тех же фрагментах кода что были описаны выше.