26 December 2019

Пишем блог на микросервисах – часть 4 сервис «Post»

X5 Retail Group corporate blogGoMicroservices
Эта статья является продолжением истории написания demo-блога на микросервисах (предыдущие части можно почитать здесь: Часть 1 «Общее описание архитектуры», Часть 2 «API Gateway», Часть 3 «Сервис User»). В этой статье речь пойдет о реализации микросервиса 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.

Подробно описывать реализацию этих методов не буду дабы не повторяться. Они базируются на тех же фрагментах кода что были описаны выше.
Tags:X5RetailGroupgolangproto3restgrpcgrpc-gateway
Hubs: X5 Retail Group corporate blog Go Microservices
+3
2k 41
Comments 4
Ads