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

ВАКАНСИИ

Добавить вакансию

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