Введение в проблему
Для пользователя всё очень просто в скриптовых языках, вроде 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("ttvar(") fmt.Printf("tttret %sn", strings.Title(strings.ToLower(dg.tablename ))) for _,field := range dg.fields { if field.name == dg.pk { continue } fieldList = append(fieldList, field.name) fmt.Printf("ttt%s %sn", field.name, field.sqltype) } fmt.Println("tt)") 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("ttsql_s := " %s "n" , sql_txt) fmt.Printf("ttrows, err := db.Query(sql_s, %s)n ", dg.pk) fmt.Println("tterrorCheck(err)") fmt.Println("ttdefer rows.Close()") fmt.Println("ttfor rows.Next() {") fmt.Printf("ttterr = rows.Scan(&%s)n", out_vars) fmt.Println("ttterrorCheck(err)") for _,field := range dg.fields { if field.name == dg.pk { fmt.Printf("tttret.%s=%sn", field.name, field.name) } else { fmt.Printf("tttret.%s=%s(%s)n", field.name, field.fn_conv, field.name) } } fmt.Println("tt}") fmt.Println("ttreturn 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
. Можно развивать код в направлении составного первичного ключа или вообще не используя первичный ключ – все зависит от вашей фантазии. Всегда есть куда развиваться: например, прикрутить к коду шаблонизатор, чтоб проще генерировать шаблоны функций. Надеюсь, материал моей статьи и приведенный в ней кодогенератор кому-то сократит время разработки. Удачи!