Проблематика
В этой статье мы сосредоточимся на том, как разработать структуру данных для обзора
продукта. Речь идет о структуре данных продукта, структуре данных пользователя и структуре
данных обзора продукта.
Давайте начнем определять необходимый минимум для этого упражнения:
type Product struct {
ID int
Name string
}
type User struct {
ID int
Username string
}
type ProductReview struct {
ProductID int
UserID int
Review string
}
Примечание
: Для этого примера я написал базу данных
in-memory, которая имитирует
реальную базу данных. Поскольку на самом деле неважно, какую базу данных мы используем, мы
не
будем рассматривать этот код. Однако позже мы увидим, как он используется в наших
тестах.
Теперь давайте посмотрим, как будет выглядеть метод, который извлекает из базы
данных обзор продукта:
func Find(productID, userID int) (*ProductReview, error)
Этот метод принимает идентификатор продукта и идентификатор
пользователя и извлекает
отзывы этого пользователя для указанного продукта.
Что не так?
С технической точки зрения в коде пока нет ничего страшного. Это правильный Go-код.
Он компилируется и запускается.
Чтобы показать, что так оно и есть, давайте напишем интеграционный
тест для проверки наших утверждений.
package products
import (
"github.com/google/go-cmp/cmp"
"testing"
)
func TestProductReview(t *testing.T) {
// Создаем инстанс базы данных
db := NewDatabase()
// Создаем продукт
product := Product{Name: "Computer"}
if err := db.Products.Create(&product); err != nil {
t.Fatal(err)
}
// Создаем пользователя
user := User{Username: "Gopher"}
if err := db.Users.Create(&user); err != nil {
t.Fatal(err)
}
// Создаем отзыв о продукте
exp := &ProductReview{
UserID: user.ID,
ProductID: product.ID,
Review: "Комп супер!",
}
if err := db.ProductReview.Save(exp); err != nil {
t.Fatal(err)
}
// Получаем отзыв о продукте
got, err := db.ProductReviews.Find(user.ID, product.ID)
if err != nil {
t.Fatal(err)
}
if !cmp.Equal(got, exp) {
t.Fatalf("не ожидаемый отзыв о продукте:\n%s", cmp.Diff(exp, got))
}
}
Если мы запустим тест, то увидим, что он действительно прошел:
go test -v -run TestProductReview === RUN TestProductReview database_test.go:41: &{ProductID:1 UserID:1 Review:This computer is awesome!}
PASS ok store 0.064s
В конце теста мы также вышли из системы, чтобы убедиться, что сохранили и извлекли
из базы данных правильный отзыв о продукте. Итак, где же проблема? Возможно, вы
уже заметили
ее... а может, и нет.
Проблема в том, что мы поменяли местами аргументы метода при вызове метода `Find`
.
Если мы посмотрим на сигнатуру, то увидим, что она определена как:
```go
func Find(productID, userID int) (*ProductReview, error)
Однако мы вызывали его с заменой идентификатора продукта на идентификатор
пользователя (фактически отправляя идентификатор продукта вместо идентификатора пользователя, и
наоборот):
db.ProductReviews.Find(user.ID, product.ID)
Итак, мы точно знаем, что написали плохой тест, так почему же он
прошел? Причина в
том, что, как и во многих интеграционных тестах, мы тестируем из «пустой» базы данных. Поэтому,
когда мы создаем первого пользователя, его ID
будет 1
, а когда мы
создаем первый товар, его ID
также будет 1
. И поскольку оба типа ID
- int
, мы никогда
не увидим, что написали плохой
тест.
Вы можете подумать: «Хорошо, мы написали плохой тест, такое бывает. Почему бы нам
просто не исправить тест, отправив аргументы метода в правильном порядке?
Мы могли бы сделать это, однако та же проблема, с которой мы столкнулись в тесте,
может возникнуть и в продакшене. Ничто не мешает нам случайно изменить порядок аргументов, когда
мы пишем производственный код. Однако существенная разница заключается в том, что в продакшене
мы теперь написали действительно уродливую ошибку, которая приведет к неблагоприятным
последствиям, вероятно, испортит данные, а также доставит много хлопот при отслеживании и
исправлении.
Зачем вы говорите мне это?
Помните, что в этой статье речь идет об использовании системы типов. И слишком
часто, когда я
делаю ревью кода, я вижу, что код на самом деле не использует безопасность типов
языка. Приняв
пару небольших проектных решений, мы можем гарантировать, что не создадим этот баг в нашем
программном обеспечении.
Типизированные идентификаторы
Самый простой способ гарантировать, что мы не отправим по ошибке
ID
продукта, когда
нужен ID
пользователя, - это создать пользовательский
тип для наших ID
. Вот как будет выглядеть
наша структура, когда мы это сделаем:
type ProductID int
type Product struct {
ID ProductID
Name string
}
type UserID int
type User struct {
ID UserID
Username string
}
type ProductReview struct {
ProductID ProductID
UserID UserID
Review string
}
Теперь, вместо того чтобы использовать общий тип int
для идентификаторов,
каждая
структура имеет свой собственный тип для идентификатора.
Это означает, что мы также должны обновить метод Find
,
который теперь будет
использовать пользовательские типы для аргументов:
func Find(productID ProductID, userID UserID) (*ProductReview, error)
Теперь при выполнении тестов мы видим следующую ошибку:
$ go test -v -run TestProductReview
# store [store.test]
database_test.go:33:41: cannot use user.ID (type UserID) as type ProductID in argument to db.ProductReviews.Find
database_test.go:33:53: cannot use product.ID (type ProductID) as type UserID in argument to db.ProductReviews.Find
FAIL store [build failed]
И если мы посмотрим на неудачную строку кода, то увидим, что аргументы действительно
поменялись местами:
got, err := db.ProductReviews.Find(user.ID, product.ID)
Именно это я имею в виду, когда говорю об использовании системы типов в
Go. Если вы
правильно организуете типы данных, компилятор проделает гораздо больше работы,
чтобы не
допустить ошибок в тестах или рабочем коде.
Если мы не сделаем преобразование, Go применит безопасность типов,
и мы получим
ошибку времени компиляции.
Хорошо, понял. Всегда использую типизированные идентификаторы
На самом деле... нет. При разработке программного обеспечения
всегда нужно смотреть
на плюсы и минусы. Хотя использование типизированных идентификаторов позволяет
отлавливать
ошибки, иногда это становится просто хлопотным делом, потому что приходится
постоянно
преобразовывать типы из чего-то вроде базового int
в
ProductID
.
Если вы посмотрите на следующий код, это не сработает, поскольку Go
обеспечивает
безопасность типов, и хотя ProductID
основан на int
, он
НЕ является int
и не может быть напрямую
присвоен значению int
:
package main
import "fmt"
type ProductID int
type Product struct {
ID ProductID
Name string
}
func main() {
id := 1
product := Product{
ID: id,
Name: "Computer",
}
fmt.Println(product)
}
$ go run invalid.go
# command-line-arguments
invalid.go:16:3: cannot use id (type int) as type ProductID in field value
Чтобы убедиться, что вы следуете правилам безопасности типов, вы
должны сообщить Go,
что хотите преобразовать значение id
, равное int
, в тип ProductID
,
выполнив преобразование
типов (type conversion
):
ProductID(id)
Вот как теперь будет выглядеть код:
package main
import "fmt"
type ProductID int
type Product struct {
ID ProductID
Name string
}
func main() {
id := 1
product := Product{
ID: ProductID(id),
Name: "Computer",
}
fmt.Println(product)
}
Как узнать, когда их следует использовать?
Есть несколько признаков, по которым можно определить, что типизированные
идентификаторы могут принести пользу вашему коду. Во-первых, если у вас
есть функции, которые
используют более одного идентификатора из разных типов данных для выполнения
операций. В нашем
примере это проявилось в том, что ProductReview
использовала составной ключ ProductID
и UserID
.
Это может произойти в таких проектах, как RESTful Web API. А может быть,
вы потребляете данные
из XML
или JSON
и хотите убедиться, что при обработке данных
соблюдаются все типы данных.
Если ваш код не смешивает идентификаторы, то, скорее всего,
использование
типизированных идентификаторов приведет только к лишнему коду без какой-либо пользы.
Резюме
Как мы видели в этой статье, даже при написании тестов вы можете получить
код,
который пройдет, но не будет корректным. Используя безопасность типов в Go,
мы позволяем
компилятору гарантировать, что мы никогда случайно не поменяем местами аргументы разных
типов.
Хотите еще?
Подробнее о том, как система типов и константы могут сделать ваш код более удобным,
вы можете узнать из нашей статьи Использование
собственных типов в Golang.