В этой части мы узнаем, как создать сервисный слой, который будет использовать репозитории, подключенные к MongoDB. Файлы размещены здесь для справки: https://github.com/hariesef/myschool.

Ссылка на предыдущую главу: https://hariesef.medium.com/go-e2e-tutorial-part-5-unit-test-with-sql-mock-and-gomock-c53f4bf2d72f

В учебных целях мы создадим службу под названием Учетная запись. Если мы вернемся к нашей первой чистой диаграмме:

Мы здесь сейчас. Посмотрите на обведенный.

Наша воображаемая служба учетной записи будет обслуживать три службы: создание пользователя, вход (аутентификация) и выход из системы.

Это будет интерфейс для нашего сервиса:

package account

import "context"

type AccountServiceIface interface {
 Create(context.Context, string, string) error
 Login(context.Context, string, string) (*TokenInfo, error)
 Logout(context.Context, string) error
}

type TokenInfo struct {
 Token  string
 Expiry int
}

Расположение файла: https://github.com/hariesef/myschool/blob/master/pkg/services/account/account_iface.go

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

Теперь, чтобы создать пользователя, а затем войти в систему и выйти из системы, нам потребуются как минимум две модели/репозитория:

  • Пользователь для хранения учетных данных.
  • Токен для хранения токена после успешного входа пользователя в систему.

Это будет наша реализация, пожалуйста, обратитесь к репозиторию github за полным содержимым файла:

Репозиторий пользователя:

  • pkg/модель/user_iface.go
  • внутренний/хранилище/mongodb/пользователь/user_repo_impl.go
  • внутреннее/хранилище/mongodb/пользователь/user_repo_impl_test.go

Репозиторий токенов:

  • pkg/модель/token_iface.go
  • внутренний/хранилище/mongodb/токен/token_repo_impl.go
  • внутренний/хранилище/mongodb/токен/token_repo_impl_test.go

Обратите внимание, что задача репо состоит только в том, чтобы выполнить CRUD предоставленных данных. Ни меньше, ни больше. Если вас попросят создать («haries», «abcdefg»), репозиторий должен сохранить это в БД как есть с паролем «abcdefg». Бизнес-логика, включая хеширование паролей, обработку даты истечения срока действия токена и т. д., должна выполняться на уровне службы, а не в репозитории.

Реализация MongoDB

Мы выберем одну реализацию репо: user_repo_impl.go, чтобы узнать, как делать CRUD с Mongo.

Расположение файла: https://github.com/hariesef/myschool/blob/master/internal/storage/mongodb/user/user_repo_impl.go

Для монго для реализации модели пользователя нужны определенные теги:

// implmentation of UserModel
type User struct {
 ID                string `json:"id,omitempty" bson:"_id,omitempty"`
 Email             string `json:"email,omitempty" bson:"email,omitempty"`
 EncryptedPassword string `json:"encryptedPassword,omitempty" bson:"encryptedPassword,omitempty"`
 Active            bool   `json:"active,omitempty" bson:"active,omitempty"`
}
  • Mongo работает с BSON вместо JSON. Нам нужен тег bson, чтобы указать фактическое имя поля в коллекции БД.
  • _id — это «уникальный первичный ключ» коллекции по умолчанию, который будет создан автоматически.
  • omitempty означает, что если мы создаем или обновляем документ (строку) внутри коллекции (таблицы), если поле отсутствует или равно нулю, оно будет пропущено, вместо того, чтобы продолжать устанавливать поле как нулевое.
// unexportable
type repoPrivate struct {
 db mongo.Database
}

// Safe checker to know if this file already implements the interface correctly or not
var _ (model.UserRepo) = (*repoPrivate)(nil)

func NewRepo(db *mongo.Database) model.UserRepo {
 return &repoPrivate{db: *db}
}

func (repo *repoPrivate) Create(ctx context.Context, args model.UserCreationParam) (model.UserModel, error) {

 newUser := &User{
  Email:             args.Email,
  EncryptedPassword: args.EncryptedPassword,
  Active:            true,
 }
 res, err := repo.db.Collection(UsersCollectionName).InsertOne(ctx, newUser)
 if err != nil {
  return nil, err
 }
 newUser.ID = res.InsertedID.(primitive.ObjectID).Hex()
 return newUser, nil
}

Создать новый документ в Mongo очень просто, просто используйте InsertOne(). В качестве альтернативы существует также пакетная вставка с помощью InsertMany(). Подробнее об этом можно прочитать на https://www.mongodb.com/docs/drivers/go/current/fundamentals/crud/write-operations/insert/

Создание нового документа всегда будет возвращать его первичный ключ/идентификатор.

Обратите внимание, что типом первичного ключа в Mongo является ObjectID, а не строка. Поэтому, чтобы прочитать его, нам понадобится конвертация.

Чтобы прочитать документ на основе электронной почты в качестве ключа поиска, мы используем следующий код:

func (repo *repoPrivate) Read(ctx context.Context, email string) (model.UserModel, error) {
 
 result := repo.db.Collection(UsersCollectionName).FindOne(context.Background(), bson.M{"email": email, "active": true})
 var user User
 result.Decode(&user)
 return &user, result.Err()
}

Следующая функция Deactivate() — это то место, где мы пытаемся найти документ на основе первичного ключа "_id". Эта функция установит для активного поля пользователя значение false.

func (repo *repoPrivate) Deactivate(ctx context.Context, id string) (model.UserModel, error) {
 objectId, err := primitive.ObjectIDFromHex(id)
 if err != nil {
  return nil, err
 }

 filter := bson.M{"_id": objectId}
 update := bson.D{{Key: "$set", Value: bson.M{"active": false}}}
 opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
 result := repo.db.Collection(UsersCollectionName).FindOneAndUpdate(context.Background(), filter, update, opts)
 var user User
 result.Decode(&user)
 return &user, result.Err()
}

BSON имеет несколько примитивных типов, таких как bson.M и bson.D.

  • bson.M имеет формат карты
  • в то время как bson.D на самом деле является массивом ключ-значение. В Go буквальное использование Key: и Value: требуется для удаления определенного предупреждения о линтинге.
  • Подробнее о примитивных типах можно увидеть здесь https://www.mongodb.com/docs/drivers/go/current/fundamentals/bson/

Последней функцией этого руководства будет поиск пользователей, которые все еще активны:

func (repo *repoPrivate) FindActive(ctx context.Context) ([]model.UserModel, error) {
 var users []*User

 filter := User{Active: true}
 opts := options.Find().SetSort(bson.D{{Key: "email", Value: 1}})

 cur, err := repo.db.Collection(UsersCollectionName).Find(ctx, filter, opts)
 if err != nil {
  return nil, err
 }
 if err := cur.All(ctx, &users); err != nil {
  return nil, err
 }

 models := make([]model.UserModel, len(users))
 for i, v := range users {
  models[i] = model.UserModel(v)
 }
 return models, err
}

Модульное тестирование с онлайн-базой Mongo DB

Настройка пакета будет выглядеть так:

 BeforeSuite(func() {

  var err error
  opt := options.Client().ApplyURI("mongodb://localhost:27017")
  localMongoClient, err = mongo.Connect(context.Background(), opt)
  Expect(err).To((BeNil()))

  err = localMongoClient.Ping(context.Background(), readpref.Primary())
  Expect(err).To((BeNil()))

  db := localMongoClient.Database("unit_test_db")

  //initialize DB
  db.Drop(context.Background())

  //make email as unique index
  indexModel := mongo.IndexModel{
   Keys:    bson.D{{Key: "email", Value: 1}},
   Options: options.Index().SetUnique(true)}
  _, err = db.Collection(user.UsersCollectionName).Indexes().CreateOne(context.TODO(), indexModel)
  Expect(err).To((BeNil()))

  userRepoImpl = user.NewRepo(db)
  ctrl = gomock.NewController(t)
 })

Полное содержимое файла можно увидеть здесь: https://github.com/hariesef/myschool/blob/master/internal/storage/mongodb/user/user_repo_impl_test.go

Объяснение:

  • Нужен монго или докер, работающий на локальном хосте с портом по умолчанию 27017.
  • после подключения клиента выполните быстрый ping-тест, чтобы убедиться, что он работает
  • затем установите имя базы данных для выполнения теста
  • сбросьте БД, чтобы очистить весь контент
  • настроить индексацию на основе поля электронной почты.
  • Инициализировать объект UserRepo ← наша цель тестирования

Тестирование онлайн-БД также не вызывает затруднений:

  Context("User Repo", func() {

   firstUserId := ""
   randomPassword := helper.RandomStringBytes(5)
   It("tests creating first user", func() {

    user, err := userRepoImpl.Create(context.TODO(),
     model.UserCreationParam{Email: "[email protected]", EncryptedPassword: randomPassword})
    firstUserId = user.GetID()
    fmt.Println("first user id: ", firstUserId)
    Expect(err).To((BeNil()))
    Expect(len(firstUserId)).Should(BeNumerically("==", 24))
    Expect(user.IsActive()).To(Equal(true))
   })

Остальные тестовые случаи:

2) не удалось создать другого пользователя с таким же адресом электронной почты, поскольку адрес электронной почты должен быть уникальным

3) протестируйте чтение нашего первого пользователя, сопоставив случайный пароль и идентификатор пользователя, которые у нас были раньше

4) протестируйте деактивацию нашего первого пользователя

5) проверка чтения нашего первого пользователя ← не удалась, документ не найден

6) протестируйте последнюю функцию, чтобы найти только активных пользователей

Структура обслуживания учетной записи

Теперь, когда мы увидели, как было построено и протестировано User Repo, давайте переместим сам сервис.

PS: Token Repo подлежит тестированию с использованием другого метода в следующей части руководства.

Следуя тому, что мы сделали в этом руководстве до сих пор, создание службы учетной записи имеет очень похожие шаги: определение интерфейса, реализация службы, модульное тестирование, определение прото-файла (потому что мы хотим его обслуживать) и реализация методов twirp, чтобы проксировать вызов к фактической службе.

Вот фрагмент того, как создается пользователь в службе Account:

func (acct *AccountService) Create(ctx context.Context, email string, password string) error {
 token := ctx.Value("token")
 log.Debugf("value of token: %s", token)
 hashedPassword := argonFromPassword(password)

 _, err := acct.Repo.UserRepo.Create(context.TODO(),
  model.UserCreationParam{Email: email, EncryptedPassword: hashedPassword})

 return err
}
  • token — это пример того, как мы получаем значение заголовка запроса. Ранее мы обсуждали, что заголовок хранится в дополнительном обработчике Twirp. Эта строка служит только в качестве примера и ничего не имеет общего с реальной бизнес-логикой.
  • мы используем пользовательскую функцию argonFromPassword() для хеширования, которая является частью файла реализации.

Полное содержимое файла можно увидеть здесь: https://github.com/hariesef/myschool/blob/master/internal/services/account/account_service_impl.go

func (acct *AccountService) Login(ctx context.Context, email string, password string) (*account.TokenInfo, error) {

 //first read the user data
 user, err := acct.Repo.UserRepo.Read(context.TODO(), email)
 if err != nil && errors.Is(err, mongo.ErrNoDocuments) {
  return &account.TokenInfo{}, errors.New("email or password does not match our record")
 }
 if err != nil {
  return &account.TokenInfo{}, err
 }

 log.Debugf("[%s]", user.GetEmail())
 log.Debugf("[%s]", email)
 log.Debugf("[%s]", user.GetEncryptedPassword())
 log.Debugf("[%s]", argonFromPassword(password))
 //then, compare
 if (user.GetEmail() != email) || (user.GetEncryptedPassword() != argonFromPassword(password)) {
  return &account.TokenInfo{}, errors.New("email or password does not match our record")
 }

 //login successful, now generate a random token
 newToken := helper.RandomStringBytes(32)
 newTokenExpiry := time.Now().Unix() + (3600 * 24) //one day
 //store the token

 _, err = acct.Repo.AuthTokenRepo.Create(context.TODO(), model.TokenCreationParam{
  Token:  newToken,
  UserID: user.GetID(),
  Email:  email,
  Expiry: int(newTokenExpiry),
 })

 if err != nil {
  return &account.TokenInfo{}, err
 }

 return &account.TokenInfo{Token: newToken, Expiry: int(newTokenExpiry)}, nil
}

func (acct *AccountService) Logout(ctx context.Context, token string) error {

 return acct.Repo.AuthTokenRepo.Delete(ctx, token)
}

Функция входа немного сложна:

  • проверить, существует пользователь или нет
  • сравнить электронную почту и хешированный пароль
  • установить токен
  • вернуть результат

В то время как функция выхода из системы действительно проста, один лайнер.

Модульный тест для службы учетной записи по-прежнему использует онлайн-соединение Mongo DB с локальным хостом и больше не будет разрабатываться. Если вам интересно, посмотрите здесь: https://github.com/hariesef/myschool/blob/master/internal/services/account/account_service_impl_test.go

Обслуживание учетной записи в Twirp

Обслуживание учетной записи в Twirp такое же, как и в студенческом репозитории:

 accountService := accountSvcImpl.AccountService{Repo: repo}
 accountRPCServer := &accountRPCImpl.AccountRPCServer{AccountSvc: &accountService}
 accountTwirpHandler := accountRPCIface.NewAccountServer(accountRPCServer,
  rpc.TwirpHookOption(accountRPCIface.AccountPathPrefix))
 accountTwirpHandler2 := rpc.WithEvaluateHeaders(accountTwirpHandler)

 router := chi.NewRouter()
 router.Mount(accountRPCIface.AccountPathPrefix, accountTwirpHandler2)
 router.Mount(studentRPCIface.StudentPathPrefix, studentTwirpHandler2)
  • создать объект службы учетной записи, который зависит от репозиториев
  • создать сервер RPC со службой учетных записей в качестве входных данных
  • создать обработчик twirp с дополнительной функцией ловушки
  • обернуть обработчик twirp обработчиком заголовков
  • смонтировать обработчик twirp по другому пути

Затем его можно протестировать с помощью Postman:

Это все для этой главы. Далее мы будем проводить модульное тестирование Token Repo, используя другой метод, MongoDB Mocking: https://hariesef.medium.com/go-e2e-tutorial-part-7-unit-testing-with-mongodb-mock-mtest-e32511961925

Спасибо, что читаете! 🍻 Здоровья!