Проблематика
В этой статье мы сосредоточимся на том, как разработать структуру данных для обзора продукта. Речь идет о структуре данных продукта, структуре данных пользователя и структуре данных обзора продукта.
Давайте начнем определять необходимый минимум для этого упражнения:
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.