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