Часть 2 книги «Заставьте интерфейсы Go работать на вас»

На мой взгляд, две общие черты разработчика 10x — это хорошее понимание своего любимого языка и знание того, когда и как можно использовать абстракции для экономии времени.

Меньше значит больше; это примерно 10-кратный результат/воздействие, а не 10-кратный код.

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

*CRUD — создать, прочитать, обновить, удалить

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

Оболочка базы данных «предоставляет объектно-ориентированный интерфейс… и скрывает детали того, как получить доступ к поддерживаемым данным». С Go я могу передать оболочку базы данных вокруг своего приложения в виде структуры или интерфейса, все возможно.

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

Начну реализацию с интерфейса.

Прототип

Идеальный интерфейс для веб-сервера должен иметь метод для:

  • Создайте запись в базе данных.
  • Прочитать запись базы данных.
  • Обновите запись базы данных.
  • Удалить запись базы данных.

Листинг 1

type DatabaseWrapper interface {
 Create(any) error
 Read(int, any) error
 Update(int, any) error
 Delete(int) error
}

В листинге 1 показан интерфейс со всеми функциями, необходимыми для этого проекта. Вот краткое описание каждого метода:

  • Create(any) error— Роль этого метода заключается в добавлении новой записи в базу данных и возврате ошибки, если операция не прошла успешно.
  • Update(int, any) error — Роль этого метода заключается в обновлении записи с указанным идентификатором; этот метод должен возвращать ошибку, если при обновлении записи возникла проблема.
  • Delete(int) error— Роль этого метода заключается в удалении указанной записи из базы данных.
  • Read(int, any) error— Роль этого метода заключается в том, чтобы найти запись с указанным идентификатором и сохранить ее в значение, на которое указывает второй аргумент метода; этот подход позволяет методу интерфейса поддерживать возврат различных типов структур и уменьшает необходимость выполнения утверждений типа после каждого вызова метода чтения. Стандартная библиотека Go также следует этому шаблону со своей функцией Unmarshal. Поскольку первая реализация будет работать с данными «в памяти», я определю служебную функцию, которая поможет мне перемещать данные из одной переменной в указатель другой.
  • Листинг 2:
package main

import "reflect"

// applyDataToPointer is inspired by the
// std library's JSON Unmarshal function,
// I cracked and had to find out how they
// did it for the Unmarshal function.
// This is the result.
func applyDataToPointer(src, dst any) {

 rv := reflect.ValueOf(src)
 ri := reflect.ValueOf(dst).Elem()
 ri.Set(rv)
}
  • В листинге 2 я определяю функцию, которая заполняет параметр dst значением параметра src.

Написание функции с интерфейсом

Преимущество Go в том, что я могу начать писать функции, зависящие от этого интерфейса, без фактической реализации; Обычно я выбираю этот путь, если хочу отложить написание какого-либо кода базы данных.

Листинг 3

package main

import "time"

type memRecord struct {
 Text      string
 CreatedAt time.Time
}

func initDB(db DatabaseWrapper) error {
  return nil
}

В листинге 3 показана функция, которая принимает параметр с именем db типа DatabaseWrapper; Самое волшебное в Go то, что нет реальной реализации этого интерфейса, и этот код все равно будет компилироваться.

Я также определяю тип структуры с именем memRecord, который будет действовать как запись базы данных. В структуре есть поле (с именем CreatedAt), предназначенное для проверки изменения записи перед ее вставкой.

Листинг 4

type memRecord struct {
 Text      string
 CreatedAt time.Time
}


func initDB(db DatabaseWrapper) error {
 
 // construct a memRecord instance
 record := memRecord{Text: "Hello World"}
 
 // construct a second instance
 // to add to the database.
 record2 := memRecord{Text: "Record 2"}

 // Add the first record
 if err := db.Create(record); err != nil {
  return err
 }
 
 // Add the second record
 if err := db.Create(record2); err != nil {
  return err
 }

 // Update a field within the second
 // record.
 record2.Text = "Hello World 2"
 
 // Push the updated record to the database.
 // I'm guessing the id of the second record is 1 because
 // I plan on having the first database id be 0.
 if err := db.Update(1, record2); err != nil {
  return err
 }
 
 // I'm declaring a variable that I will
 // store data to via the interface's Read
 // method.
 var queryResult memRecord
  
 // Load the data onto the variable queryResult
 // by passing it as a pointer.
 if err := db.Read(1, &queryResult); err != nil {
  return err
 }
 
 // Display the data fetched and display it to see
 // if the struct was correctly populated.
 fmt.Println("\n\nQuery Result\n\n", queryResult)
 
 // Remove the second record from the database.
 err := db.Delete(1)

 return err
}

Код в листинге 4 — это обновленная версия функции initDB; код вызывает методы интерфейса DatabaseWrapper для достижения желаемой функциональности.

Первая реализация

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

Листинг 5

package main

type memRecord struct {
 Text      string
 CreatedAt time.Time
}

type InMemoryDb struct {
 // The int ID to make the simulation
 // of a PostGRE database feel more
 // accurate.
 storage map[int]memRecord
}

// NewInMemory is a test database
// that will log the updated database
// each time an operation is called.
func NewInMemoryDb() *InMemoryDb {
 return &InMemoryDb{
  map[int]memRecord{},
 }
}

func logDB(db InMemoryDb) {
 fmt.Println("\n\nCurrent database:")
 for key, data := range db.storage {
  fmt.Println("Row", key, "Data:", data)
 }
}

В листинге 5 я определяю:

  • Тип структуры, представляющий записи, которые будут храниться в базе данных.
  • Тип структуры базы данных в памяти — это может показаться запутанным, потому что оболочка обновляет данные внутри себя. Я также определяю фабричную функцию, которая возвращает указатель на экземпляр InMemoryDb; Я делаю это, потому что все методы имеют приемник указателя, чтобы гарантировать, что любые обновления, сделанные на карте, останутся постоянными.
  • Функция с именем logDB, которая будет вызываться каждый раз при изменении базы данных; так я буду наблюдать за текущим состоянием базы данных.

Листинг 6

func (db *InMemoryDb) Create(r any) error {
 
 nextId := len(db.storage)
 entry, ok := r.(memRecord)
 if !ok {
  return errors.New("Wrong concrete type passed")
 }
 entry.CreatedAt = time.Now()
 db.storage[nextId] = entry
 // Log contents of database
 logDB(*db)
 return nil
}

В листинге 6 представлена ​​реализация метода DatabaseWrapper create. Я определяю идентификатор новой записи, считая ключи в карте хранилища, а затем устанавливая его как значение переменной nextId. Как только идентификатор будет определен, я объявлю переменную с именем entry и установлю ее значение в базовый конкретный тип параметра функции r; ошибка возвращается, если передается другой тип, кроме memRecord. Затем я установлю в поле CreatedAt время ввода переменной значение «сейчас». Что касается добавления записи в базу данных, я установлю значение для ключа nextId; это значение является переменной.

Листинг 7

func (db *InMemoryDb) Update(q int, r any) error {
 data, ok := db.storage[q]

 // return early if item not present.
 if !ok {
  return errors.New("Record not found")
 }

 data.Text = r.(memRecord).Text
 db.storage[q] = data
 logDB(*db)
 return nil
}

В листинге 7 показана реализация метода обновления интерфейса. Функция попытается прочитать запись с карты и вернет ошибку, если значение карты id/key не существует, этот id/key представлен как параметр функции q. Если запись существует, база данных обновит текстовое поле тем, что было передано с параметром функции r, и сохранит значение для поля CreatedAt. Обновленная запись снова сохраняется на карте.

Листинг 8

func (db *InMemoryDb) Delete(q int) error {
 // This delete function will only work
 // if you're removing the last element.
 // This is due to the fact that I'm guessing
 // the next ID with this statement:
 // `nextId := len(db.storage)`
 delete(db.storage, q)
 logDB(*db)
 return nil
}

В листинге 8 показана реализация метода удаления интерфейса. он удаляет запись с указанным ключом, вызывая встроенную в Go функцию delete.

Обратите внимание, что этот код ненадежен из-за того, как эта реализация определяет идентификатор нового элемента.

Листинг 9

func (db *InMemoryDb) Read(q int, r any) error {

 data, ok := db.storage[q]

 // return early if item not present.
 if !ok {
  return errors.New("Record not found")
 }

 applyDataToPointer(data, r)
 return nil
}

В листинге 9 показана реализация функции чтения интерфейса. Метод будет считывать значение карты, хранящееся в ключе, переданном в качестве параметра метода q; ошибка возвращается, если переданный ключ отсутствует на карте. Я вызову функцию applyDataToPointer, определенную в листинге 2, для хранения переменных data в базовом конкретном типе параметра метода r.

Со всеми реализованными методами интерфейса я могу вызвать функцию initDB и передать переменную типа InMemoryDb.

Листинг 10

func main() {

 db := NewInMemoryDb()

 if err := initDB(db); err != nil {
  panic(err)
 }

 // Database setup complete
}

В листинге 10 показана программа, которая создает InMemoryDb и передает его как параметр типа DatabaseWrapper, не вызывая никаких новых ошибок, связанных с компилятором; это результат того, что тип структуры имеет все методы, ожидаемые интерфейсом.

Листинг 11

$ go run ./

Current database:
Row 0 Data: {Hello World 2023-08-07 17:59:38.515067526 +0000 GMT m=+0.000068293}


Current database:
Row 0 Data: {Hello World 2023-08-07 17:59:38.515067526 +0000 GMT m=+0.000068293}
Row 1 Data: {Record 2 2023-08-07 17:59:38.515323853 +0000 GMT m=+0.000324623}


Current database:
Row 0 Data: {Hello World 2023-08-07 17:59:38.515067526 +0000 GMT m=+0.000068293}
Row 1 Data: {Hello World 2 2023-08-07 17:59:38.515323853 +0000 GMT m=+0.000324623}


Query Result

{Hello World 2 2023-08-07 17:59:38.515323853 +0000 GMT m=+0.000324623}


Current database:
Row 0 Data: {Hello World 2023-08-07 17:59:38.515067526 +0000 GMT m=+0.000068293}

В листинге 11 показан вывод кода из листинга 4 и листинга 10; вывод является подробным, потому что каждый раз, когда происходит изменение базы данных, содержимое базы данных записывается на терминал.

Вторая реализация

Следующая реализация будет взаимодействовать с SQLite; Я делаю это, чтобы продемонстрировать, что мне не нужно будет обновлять код функции initDB для поддержки другого поставщика базы данных.

Листинг 12

package main

import (
 "database/sql"
)

type SQLite struct {
 db *sql.DB
}

func NewSQLite(db *sql.DB) *SQLite {
 return &SQLite{
  db,
 }
}

В листинге 12 я определяю тип структуры с именем SQLite с полем с именем db; это поле является указателем на значение типа sql.DB и будет использоваться для взаимодействия (выполнения запросов) с базой данных SQLite.

Фабричная функция NewSQLite требует предварительно инициализированного подключения к базе данных. Я выбрал этот вариант, потому что он позволяет вызывающему абоненту выбирать способ инициализации и управления подключением к базе данных.

Листинг 13

func (db *SQLite) Create(r any) error {

 now := time.Now()
 entry, ok := r.(memRecord)

 if !ok {
  return errors.New("wrong concrete type passed")
 }

 _, err := db.db.Exec(
  fmt.Sprintf(
   `INSERT INTO entries(text, created_at) VALUES('%s',%d);`,
   entry.Text,
   now.Unix(),
  ),
 )

 return err
}

В листинге 13 показана реализация метода create интерфейса DatabaseWrapper. Я использую пакет fmt в качестве импровизированного построителя запросов, поскольку я не полностью понимаю драйвер базы данных, который использую. Я также выполняю утверждение типа, чтобы получить значение, переданное с параметром метода r, и сохранить его в базе данных.

Листинг 14

func (db *SQLite) Update(q int, r any) error {
 record := r.(memRecord)

 _, err := db.db.Exec(
  "UPDATE entries SET text = '?' WHERE id = ?;",
  record.Text,
  q,
 )

 return err
}

В листинге 14 показана реализация метода обновления интерфейса DatabaseWrapper. Я выполняю утверждение типа, чтобы получить новое значение и обновить запись с указанным новым значением.

Листинг 15

func (db *SQLite) Delete(q int) error {

 _, err := db.db.Exec("DELETE FROM entries WHERE id = ?;", q)

 return err
}

В листинге 15 показана реализация метода удаления интерфейса. Чтобы удалить запись, я запускаю запрос с оператором DELETE и передаю идентификатор записи.

Листинг 16

func (db *SQLite) Read(q int, r any) error {

 var text string
 var timestamp int

 row := db.db.QueryRow("SELECT text,created_at FROM entries WHERE id = ?", q)
 err := row.Scan(&text, &timestamp)

 if err != nil {
  return err
 }
 result := memRecord{
  CreatedAt: time.Unix(
   int64(timestamp),
   0,
  ),
  Text: text,
 }

 applyDataToPointer(result, r)

 return nil
}

В листинге 16 показана реализация метода чтения интерфейса. Чтобы прочитать запись, я запускаю SQL-запрос и сохраняю значения столбца строки в отдельных переменных; Затем я использую эти переменные для создания экземпляра структуры memRecord. После создания я вызываю служебную функцию applyDataToPointer для сохранения экземпляра структуры в базовое конкретное значение указателя, переданного в качестве параметра r.

Листинг 17

package main

import (
 "database/sql"
 "fmt"

 _ "github.com/mattn/go-sqlite3"
)


func main() {

 // Database setup complete
 // sqlite test. init database in memory
 sqldb, err := sql.Open("sqlite3", "./test.db")

 if err != nil {
   panic(err)
 }

 // close connection
 // after function returns.
 defer sqldb.Close()

 if _, err := sqldb.Exec(`
 DROP TABLE IF EXISTS entries;
 CREATE TABLE entries(id INTEGER PRIMARY KEY, text TEXT, created_at INT);`); err != nil {
  panic(err)
 }

 sqlWrapper := NewSQLite(sqldb)

 if err := initDB(sqlWrapper); err != nil {
  panic(err)
 }

}

В листинге 17 представлена ​​программа, которая:

  • Открывает локальный файл базы данных SQLite.
  • Удалите данные из предыдущего теста и добавьте новую таблицу со столбцами с именами: id, text и created_at.
  • Создайте оболочку базы данных SQLite и передайте ее в качестве параметра при вызове функции initDB.

Обратите внимание на путь импорта с пустым идентификатором (_) import; это делается для того, чтобы функция инициализации пакета выполнялась для регистрации драйвера базы данных.

Листинг 18

$ go run ./

Query Result

{Hello World 2023-08-07 21:29:51 +0000 GMT}

В листинге 18 показан результат выполнения кода, показанного в листинге 17. Показано только одно выходное сообщение, поскольку эта реализация DatabaseWrapper интерфейса не отображает состояние базы данных после каждого изменения.

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

Заключение

Как видно из этого сообщения, интерфейсы оболочки базы данных дают вам возможность быстро переключать поставщика базы данных без обновления зависящего от него кода. Хотя это утомительный первый шаг, он сделает поддержку вашего кода в будущем проще и разумнее.

Критика решения, изложенного в этом посте, заключается в том, что функции должны выполнять утверждения типа для обновления и создания записи. Мне нужно посмотреть, как этот шаблон влияет на производительность и какие потенциальные риски для безопасности он влечет за собой. Обычно библиотеки баз данных Go, которые я использую, запрашивают значение интерфейса для хранения и предоставляют возможность указать идентификатор документа. Эту закономерность можно наблюдать в следующем пакете:



Еще один способ обновить поля базового конкретного типа интерфейса — использовать пакет reflect; опять же, мне еще предстоит понять, какое влияние этот шаблон окажет на производительность программы.

Я планирую написать обработчики веб-сервера в следующем посте; эти обработчики будут использовать интерфейс DatabaseWrapper для доступа к данным, хранящимся в файле SQLite.

Здесь вы можете найти исходный код, использованный в этом посте, с обеими реализациями:

Спасибо, что дочитали до конца. Пожалуйста, следите за автором и этой публикацией. Посетите Stackademic, чтобы узнать больше о том, как мы демократизируем бесплатное обучение программированию по всему миру.



miwfy/p1 at main · cheikh2shift/miwfy
Серия «Заставьте интерфейсы работать на вас
. Внесите свой вклад в разработку cheikh2shift/miwfy, создав учетную запись на GitHub.github.com»



Источники:

Рисунок 12 — загружено Joseph O Dada https://www.researchgate.net/figure/Example-of-database-wrapper-class-right-design-model-that-provides- интерфейс-for_fig12_272486452