akalend 18 июля 2021
Go

🛠 Взаимодействие MySQL и Go: подводные камни автоматической кодогенерации

Большинство статей про использование MySQL в Golang повторяет примеры из официального руководства. Реальная разработка далека от простых примеров: из-за строгой типизации часто возникают проблемы. Разбираемся с их решением, если вам необходимо создать много однотипных функций.

Введение в проблему

Для пользователя всё очень просто в скриптовых языках, вроде PHP или Python: данные из базы мапятся в словарь, доступ к которому можно организовать как по имени поля, так и по его номеру в датасете.

В Go все устроено немного иначе: это язык строгой типизации и данные должны соответствовать типам, а количество переменных должно совпадать с количеством принятых данных. Запросы типа SELECT * подходят далеко не всегда, а когда полей становится больше десяти, отлаживание запроса превращается в муку.

Вот пример из руководства:

           // выполнение запроса
    results, err := db.Query("SELECT id, name FROM tags")
    if err != nil {
        panic(err.Error()) // обработка ошибки
    }

    for results.Next() {
        var id   int
      var name string

        // тут переписываем результат в наши переменные
        err = results.Scan(&id, &name)
        if err != nil {
            panic(err.Error()) 
        }
}

    

При использовании функции Row.Scan необходимо извлечь из базы данных два поля, т.е. нам потребуется объявить всего две переменные. Если необходимо извлечь 10 полей, нужно уже 10 переменных. А если полей более 20? На практике используются таблицы и с полусотней полей, тогда написание кода превращается в ад… И тут нам на помощь приходит кодогенерация.

Как заставить машину написать повторяющийся код?

В MySQL есть конструкция DESCRIBE, которая описывает структуру таблицы. Используя эту конструкцию, можно сгенерировать:

  • объявление списка полей;
  • списки полей для операторов SELECT, INSERT или UPDATE;
  • список переменных для функции Scan;
  • готовые типовые функции для выборки/вставки данных.

Данные из запроса DESCRIBE <имя таблицы> заносим в структуру:

        type Field struct {
    name  string      // имя поля
    ftype string      // тип поля
    sqltype string    // тип SQL поля
    fn_conv string    // имя функции преобразования
}

    

Про последние два поля поговорим чуть попозже. Из этой структуры можно сгенерировать список переменных и список полей безо всякого анализа и построения AST (abstract syntax structure):

Для примера взята таблица отзывов review со следующей структурой:

        CREATE TABLE `review` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `model` varchar(30) DEFAULT NULL,
  `url` varchar(126) DEFAULT NULL,
  `rate` int(11) DEFAULT NULL,
  `positive` varchar(510) DEFAULT NULL,
  `negative` varchar(510) DEFAULT NULL,
  `review` text,
  `created` int(10) unsigned DEFAULT NULL,
  `title` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
);

    

Когда мы выполняем команду DESCRIBE review, то получаем следующий результат:

        +----------+------------------+------+-----+---------+----------------+
| Field    | Type             | Null | Key | Default | Extra          |
+----------+------------------+------+-----+---------+----------------+
| id       | int(11)          | NO   | PRI | NULL    | auto_increment |
| model    | varchar(30)      | YES  |     | NULL    |                |
| url      | varchar(126)     | YES  |     | NULL    |                |
| rate     | int(11)          | YES  |     | NULL    |                |
| positive | varchar(510)     | YES  |     | NULL    |                |
| negative | varchar(510)     | YES  |     | NULL    |                |
| review   | text             | YES  |     | NULL    |                |
| created  | int(10) unsigned | YES  |     | NULL    |                |
| title    | varchar(255)     | YES  |     | NULL    |                |
+----------+------------------+------+-----+---------+----------------+
9 rows in set (0.00 sec)

    

Попытаемся прочитать этот датасет приведенным ниже кодом:

        var fieldName string
var fieldType string
var fieldIsNull  string 
var fieldDefault string 
var fieldComment string
var isKey string

sql_1 := "DESCRIBE " + tabName
rows, err := dg.Db.Query(sql_1)
errorCheck(err)
defer rows.Close()
for rows.Next() {
    err = rows.Scan(&fieldName, &fieldType, &fieldIsNull, &isKey, &fieldDefault, &fieldComment)

    

Мы получим ошибку:

        panic: sql: Scan error on column index 4, name "Default": converting NULL to string is unsupported
    

Ошибка преобразования возникла из-за того, что значение поля NULL не может быть явно преобразовано в тип string.

Как обработать NULL?

Чтобы таких ошибок не возникало, необходимо использовать следующие типы:

            sql.NullString
    sql.NullFloat64
    sql.NullInt32 или  sql.NullInt64 
    sql.NullBool
    sql.NullTime 

    

Все они представляют приблизительно одинаковую структуру (на примере sql.NullString):

        type NullString struct {
    String string   // данные строки, если не NULL, иначе пусто
    Valid  bool     // значение true если String имеет значение NULL
}

    

Если поле Valid имеет значение true, то в поле String находится значение, иначе NULL. Мы будем использовать представленную ниже функцию преобразования:

        func sql2String(str sql.NullString) string {
    if str.Valid {
        return str.String
    }
    return ""
}

    

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

            var fieldIsNull  sql.NullString
    var fieldDefault sql.NullString

    

Упрощенный код анализа таблицы представлен ниже:

            for rows.Next() {
        err = rows.Scan(&fieldName, &fieldType, &fieldIsNull, &isKey, &fieldDefault, &fieldComment)
        errorCheck(err)
        type_out := "string"
        sql_type := "sql.NullString"
        fn_conv := "sql2String"
        if strings.Index(fieldType, "int") >= 0 {
            type_out = "int64"
            sql_type = "sql.NullInt64"
            fn_conv  = "sql2Int"
        } else if strings.Index(fieldType, "char") >= 0 {
            type_out = "string"
            sql_type = "sql.NullString"
            fn_conv = "sql2String"
        } else if strings.Index(fieldType, "date") >= 0 {
            type_out = "string"
            sql_type = "sql.NullString"
            fn_conv = "sql2String"
        } else if strings.Index(fieldType, "double") >= 0 {
            type_out = "float"
            sql_type = "sql.NullFloat64"
            fn_conv  = "sql2Float"
        } else if strings.Index(fieldType, "text") >= 0 {
            type_out = "string"
            sql_type = "sql.NullString"
            fn_conv = "sql2String"
        }
        fields = append(fields, Field{fieldName, type_out, sql_type, fn_conv} )
    }

    
Обратите внимание
В нашем примере бизнес логика была такова, что тип DateTime или Date преобразовывались в строку. При необходимости можно изменить тип на sql.Time.

Далее – дело техники.

Кодогенерация – это очень просто

Из среза полей fields можно сгенерировать любой код. В качестве примера взят код функции, которая сохраняет все данные структуру (структура тоже сгенерирована этим кодом):

        func (dg *DbGen) generate() {
    var fieldList []string

    fmt.Printf("\tfunc get%s(db *sql.DB, %s %s) %s {\n", strings.Title(strings.ToLower(dg.tablename)),
        dg.pk, dg.type_pk, strings.Title(strings.ToLower(dg.tablename )))
    fmt.Println("\t\tvar(")
    fmt.Printf("\t\t\tret %s\n", strings.Title(strings.ToLower(dg.tablename )))

    for _,field := range dg.fields {
        if field.name == dg.pk {
            continue
        }
        fieldList = append(fieldList, field.name)
        fmt.Printf("\t\t\t%s %s\n", field.name, field.sqltype)
    }
    fmt.Println("\t\t)")

    out_fieldList := strings.Join(fieldList, ",")
    out_vars := strings.Join(fieldList, ", &")

    sql_txt := fmt.Sprintf( "SELECT %s FROM %s WHERE %s=?", out_fieldList, dg.tablename, dg.pk)
    fmt.Printf("\t\tsql_s := \" %s \"\n" , sql_txt)
    fmt.Printf("\t\trows, err := db.Query(sql_s, %s)\n ", dg.pk)
    fmt.Println("\t\terrorCheck(err)")
    fmt.Println("\t\tdefer rows.Close()")
    fmt.Println("\t\tfor rows.Next() {")
    fmt.Printf("\t\t\terr = rows.Scan(&%s)\n", out_vars)
    fmt.Println("\t\t\terrorCheck(err)")

    for _,field := range dg.fields {
        if field.name == dg.pk {
            fmt.Printf("\t\t\tret.%s=%s\n", field.name, field.name)
        } else {
            fmt.Printf("\t\t\tret.%s=%s(%s)\n", field.name, field.fn_conv, field.name)
        }
    }

    fmt.Println("\t\t}")
    fmt.Println("\t\treturn ret")
    fmt.Println("\t}")
} 

    

В этом примере используется хрестоматийная функция errorCheck(err):

            func errorCheck(err) {
        if err != nil {
            panic(err.Error())
        }
    }

    

Также используются функции обработки NULL:

  • sql2String
  • sql2Int
  • sql2Float

Заключение

Предложенный подход сокращает время написания и отладки кода чуть ли не вдвое, а может и втрое, если вам потребуется написать API для импорта более десятка таблиц (от 10 до 20 и более полей). Более подробный работающий код можно найти в репозитории на GitHub: https://github.com/akalend/mysql-golang-generator

Приведенный пример описывает только извлечение информации по первичному ключу, но так можно реализовать генератор для вставки и обновления записей, используя конструкцию ON DUPLICATE KEY UPDATE. Можно развивать код в направлении составного первичного ключа или вообще не используя первичный ключ – все зависит от вашей фантазии. Всегда есть куда развиваться: например, прикрутить к коду шаблонизатор, чтоб проще генерировать шаблоны функций. Надеюсь, материал моей статьи и приведенный в ней кодогенератор кому-то сократит время разработки. Удачи!

Источники

МЕРОПРИЯТИЯ

Комментарии 0

ВАКАНСИИ

Добавить вакансию
Java Junior Software Developer
Москва, по итогам собеседования
Junior Python разработчик
от 60000 RUB до 80000 RUB
Data Scientist
Новосибирск, от 700 USD до 1000 USD

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ