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

    Эта статья является продолжением истории написания 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.

    Подробно описывать реализацию этих методов не буду дабы не повторяться. Они базируются на тех же фрагментах кода что были описаны выше.
    X5 Retail Group
    Все о цифровой трансформации ритейла

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

      0

      Пе примите пожалуйста этот коммент на свой счет. У меня вопрос скорее ко всем кто может на него ответить нежели к автору статьи. Насколько буквально нужно воспринимать словосочетание "одна функция" в определении что является и что не является микросервисом?
      Я например предпочитаю воспринимать буквально одна функция это function(){}
      В противном случае можно facebook.com назвать микросервисом т.к. он выполняет одну функцию — реализровать ПО соцсети для клиентов и ФСБ (хотя это уже две функции)


      Не является разделение монолита на несколько монолитов разделением на несколько мнонолитов а не на несколько микросервисов?

        0
        Желательно сильно не буквально. На мой взгляд цель создания «микросервисной архитектуры» в том, чтобы в будущем делать изменения и масштабирование системы удобно и с минимальным влиянием на остальные части.

        Нам же не нужен микросервис ради микросервиса: переход должен нести какую-то ценность, а не просто удовлетворять каким-то там требованиям по определению.

        Например: поиск, изменение и создание клиентской записи. Вполне логично, что их можно разделить на три сервиса, но с другой стороны, чем больше сервисов, тем больше нужно заботиться о модели хранения данных и нужно больше контроля за ними. А если заранее известно, что запросов на создание и изменение будет мало, то проще все три сделать в одном модуле и просто поднять приоритет операциям «создание/изменение» (если это вообще требуется).

        Итого: всё зависит от задачи.
          0

          А получим ли мы выгоды от микросервиса если не будет все доведено до передельного состояния то есть одна function === один микросервис?


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


          Вторая часть необходимой инфораструктуры это некая жутко масштабируемая база данных типа PostgreSQL в реализации DigitalOcean — к которой мы сможем обращаться с любого микросервиса как к источнику истины.


          Если мы начинаем "разумно" делить на несколько глыб наш монолит — то не поучаем ли мы все негативы от микросервисов (заключающиеся в необходимости специальной инфраструктуры) вместе с тем теряя преимущества монолита? То есть не берем ли мы при этом все недостатки микросервисов и монолитов, и не выбрасываем ли мы все их положительные стороны?

            0
            Да, получим. Я не готов обсуждать «в общем случае», но в частном — совершенно точно и даже примеры есть (можно прямо мой выше).

            Нет, микросервисы не требуют кубернетеса. И даже не факт, что там будет удобнее. Мониторинг и авто[ДО]запуск можно без проблем реализовать и без кубернетеса.

            Ну а «разумность» деления, как и всё прочее, по-прежнему опирается на разумность(опыт?) тех, кто это решает. В целом и как в монолите ранее.

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

      Самое читаемое