В этой части мы узнаем, как создать сервисный слой, который будет использовать репозитории, подключенные к 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
Спасибо, что читаете! 🍻 Здоровья!