[Из песочницы] Кодогенерация в Go на примере создания клиента к БД
В данной статье хотелось бы рассмотреть вопросы кодогенерации в Golang. Заметил, что часто в комментариях к статьям по Go упоминают кодогенерацию и рефлексию, что вызывает бурные споры. При этом на хабре статей по кодогенерации мало, хотя она применяется довольно много где в проектах на Go. В статье попытаюсь рассказать, что из себя представляет кодогенерация, описать сферы применения с примерами кода. Также не обойду стороной и рефлексию.
Когда применяется кодогенерация
На Хабре есть уже хорошие статьи по теме тут и тут, не буду повторяться.
Кодогенерацию стоит применять в случаях:
- Увеличение скорости работы кода, то есть для замены рефлексии;
- Уменьшение рутинной работы программиста (и ошибок, связанных с ней);
- Реализацию оберток по заданным правилам.
Из примеров можно рассмотреть библиотека Stringer, которая входит в стандартную поставку языка и позволяет автоматически генерировать методы String () для наборов числовых констант. С ее помощью можно реализовать вывод имен переменных. Примеры работы библиотеки подробно описали в указанных выше статьях. Наиболее интересный пример был с выводом названия цвета из палитры. Применение кодогенерации там позволяет избежать изменения кода в нескольких местах при изменении палитры.
Из более практического примера можно упомянуть библиотеку easyjson от Mail.ru. Данная библиотека позволяет ускорить выполнение masrshall/unmarshall JSON из/в структуру. Их реализация по бенчмаркам обошла все альтернативные варианты. Для использования библиотеки необходимо вызвать easyjson, он выполнит генерацию кода для всех структур, которые найдет в переданном файле, либо только для тех, к которым указан комментарий //easyjson: json. Возьмем для примера структуру пользователя:
type User struct{
ID int
Login string
Email string
Level int
}
Для файла, в котором она содержится, запустим генерацию кода:
easyjson -all main.go
В результате мы получаем для User методы:
- MarshalEasyJSON (w*jwriter.Writer) — для преобразования структуры в массив байт JSON;
- UnmarshalEasyJSON (l *jlexer.Lexer) — для преобразования из массива байт в структуру.
Функции MarshalJSON () ([]byte, error) и UnmarshalJSON (data []byte) error необходимы для совместимости со стандартным интерфейсом json.
func TestEasyJSON() {
testJSON := `{"ID":123, "Login":"TestUser", "Email":"user@gmail.com", "Level":12}`
JSONb := []byte(testJSON)
fmt.Println(testJSON)
recvUser := &User{}
recvUser.UnmarshalJSON(JSONb)
fmt.Println(recvUser)
recvUser.Level += 1
outJSON, _ := recvUser.MarshalJSON()
fmt.Println(string(outJSON))
}
В данной функции мы сначала преобразуем JSON в структуру, прибавляет один уровень и печатает получившийся JSON. Генерация кода средствами easyjson позволяет избавиться от рефлекции в рантайме и увеличить производительность кода.
Генерация кода активно используется при создании микросервисов, которые взаимодействуют по gRPC. В нем для описания методов сервисов используется формат protobuf — при помощи промежуточного языка EDL. После описания сервиса выполняется запуск компилятора protoc, который генерирует код для нужного языка программирования. В сгененрированном коде мы получаем интерфейсы, которые необходимо реализовать в сервере и методы, которые используются на клиенте для организации общения. Получается довольно удобно, мы можем в едином формате описывать наши сервисы и генерировать код для того языка программирования, на котором будет описывать каждый из элементов взаимодействия.
Также кодогенерация может использоваться при разработке фреймворков. Например, для реализации кода, который не обязательно писать разработчику приложения, но он необходим для корректной работы. Например, для создания валидаторов полей форм, автоматической генерации Middleware, динамической генерации клиентов к СУБД.
Реализация кодогенератора на Go
Рассмотрим на практике, как же работает механизм кодогенерации в Go. В первую очередь необходимо упомянуть про AST — Abstract Syntax Tree или Абстрактное синтаксическое дерево. За подробностями можно сходить в Википедию. Для наших целей необходимо понимание того, что вся программа строится в виде графа, где вершины сопоставлены (помечены) с операторами языка программирования, а листья — с соответствующими операндами.
Итак, для начала нам понадобятся пакеты:
go/ast
go/parser/
go/token/
Парсинг файла с кодом и составление дерева выполняется следующими командами
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, os.Args[1], nil, parser.ParseComments)
Мы указываем, что имя файла взять из первого аргумента командной строки, также просим добавить в дерево комментарии.
В целом для управления кодогенерацией пользователь (разработчик кода, на основании которого генерируется другой код) может использовать комментарии, либо теги (как мы пишем `json:»` возле поля структуры).
Для примера напишем генератор кода для работы с БД. Генератор кода будет просматривать переданный ему файл, искать структуры, у которых есть соответствующий комментарий и создавать обертку над структурой (CRUD методы) для взаимодействия БД. Будем использовать параметры:
- комментарий dbe:{«table»: «users»}, в котором можно определить таблицу, в которой будут записи структур;
- тег dbe у полей структуры, в котором можно указать имя столбца, в который помещать значение поля и атрибуты для БД: primary_key и not_null. Они будут использоваться при создании таблицы. Причем для имени поля можно использовать »-», чтобы не создавать для него столбец.
Заранее оговорюсь, что проект пока не боевой, в нем не будет части необходимых проверок и защит. Если будет интерес, продолжу его развитие.
Итак, мы определились с задачей и параметрами для управления генерацией кода, можно приступить к написанию кода.
Ссылки на весь код будут в конце статьи.
Начнем обход полученного дерева и будем разбирать каждый элемент первого уровня. В Go для разбора есть предустановленные типы: BadDecl, GenDecl и FuncDecl.
// A BadDecl node is a placeholder for declarations containing
// syntax errors for which no correct declaration nodes can be
// created.
//
BadDecl struct {
From, To token.Pos // position range of bad declaration
}
// A GenDecl node (generic declaration node) represents an import,
// constant, type or variable declaration. A valid Lparen position
// (Lparen.IsValid()) indicates a parenthesized declaration.
//
// Relationship between Tok value and Specs element type:
//
// token.IMPORT *ImportSpec
// token.CONST *ValueSpec
// token.TYPE *TypeSpec
// token.VAR *ValueSpec
//
GenDecl struct {
Doc *CommentGroup // associated documentation; or nil
TokPos token.Pos // position of Tok
Tok token.Token // IMPORT, CONST, TYPE, VAR
Lparen token.Pos // position of '(', if any
Specs []Spec
Rparen token.Pos // position of ')', if any
}
// A FuncDecl node represents a function declaration.
FuncDecl struct {
Doc *CommentGroup // associated documentation; or nil
Recv *FieldList // receiver (methods); or nil (functions)
Name *Ident // function/method name
Type *FuncType // function signature: parameters, results, and position of "func" keyword
Body *BlockStmt // function body; or nil for external (non-Go) function
}
Нас интересуют структуры, поэтому используем GenDecl. На данном этапе может быть полезен FuncDecl, в котором лежат определения функций и вы делаете обертку над ними, но сейчас нам они не нужны. Далее у каждого узла смотрим массив Specs, и ищем, что мы работаем с полем определения типа (*ast.TypeSpec) и это структура (*ast.StructType). После того как мы определили, что перед нами структура, проверим, это у нее есть комментарий //dbe. Полный код обхода дерева и определение, с какой структурой работать, ниже.
for _, f := range node.Decls {
genD, ok := f.(*ast.GenDecl)
if !ok {
fmt.Printf("SKIP %T is not *ast.GenDecl\n", f)
continue
}
targetStruct := &StructInfo{}
var thisIsStruct bool
for _, spec := range genD.Specs {
currType, ok := spec.(*ast.TypeSpec)
if !ok {
fmt.Printf("SKIP %T is not ast.TypeSpec\n", spec)
continue
}
currStruct, ok := currType.Type.(*ast.StructType)
if !ok {
fmt.Printf("SKIP %T is not ast.StructType\n", currStruct)
continue
}
targetStruct.Name = currType.Name.Name
thisIsStruct = true
}
//Getting comments
var needCodegen bool
var dbeParams string
if thisIsStruct {
for _, comment := range genD.Doc.List {
needCodegen = needCodegen || strings.HasPrefix(comment.Text, "// dbe")
if len(comment.Text) < 7 {
dbeParams = ""
} else {
dbeParams = strings.Replace(comment.Text, "// dbe:", "", 1)
}
}
}
if needCodegen {
targetStruct.Target = genD
genParams := &DbeParam{}
if len(dbeParams) != 0 {
err := json.Unmarshal([]byte(dbeParams), genParams)
if err != nil {
fmt.Printf("Error encoding DBE params for structure %s\n", targetStruct.Name)
continue
}
} else {
genParams.TableName = targetStruct.Name
}
targetStruct.GenParam = genParams
generateMethods(targetStruct, out)
}
}
Для хранения информации о целевой структуре, создадим структуры:
type DbeParam struct {
TableName string `json:"table"`
}
type StructInfo struct {
Name string
GenParam *DbeParam
Target *ast.GenDecl
}
Теперь подготовим информацию о полях структуры, чтобы потом на основании полученной информации сгенерировать функции создания таблицы (createTable) и CRUD методы.
func generateMethods(reqStruct *StructInfo, out *os.File) {
for _, spec := range reqStruct.Target.Specs {
fmt.Fprintln(out, "")
currType, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
currStruct, ok := currType.Type.(*ast.StructType)
if !ok {
continue
}
fmt.Printf("\tgenerating createTable methods for %s\n", currType.Name.Name)
curTable := &TableInfo{
TableName: reqStruct.GenParam.TableName,
Columns: make([]*ColInfo, 0, len(currStruct.Fields.List)),
}
for _, field := range currStruct.Fields.List {
if len(field.Names) == 0 {
continue
}
tableCol := &ColInfo{FieldName: field.Names[0].Name}
var fieldIsPrimKey bool
var preventThisField bool
if field.Tag != nil {
tag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1])
tagVal := tag.Get("dbe")
fmt.Println("dbe:", tagVal)
tagParams := strings.Split(tagVal, ",")
PARAMSLOOP:
for _, param := range tagParams {
switch param {
case "primary_key":
if curTable.PrimaryKey == nil {
fieldIsPrimKey = true
tableCol.NotNull = true
} else {
log.Panicf("Table %s cannot have more then 1 primary key!", currType.Name.Name)
}
case "not_null":
tableCol.NotNull = true
case "-":
preventThisField = true
break PARAMSLOOP
default:
tableCol.ColName = param
}
}
if preventThisField {
continue
}
}
if tableCol.ColName == "" {
tableCol.ColName = tableCol.FieldName
}
if fieldIsPrimKey {
curTable.PrimaryKey = tableCol
}
//Determine field type
var fieldType string
switch field.Type.(type) {
case *ast.Ident:
fieldType = field.Type.(*ast.Ident).Name
case *ast.SelectorExpr:
fieldType = field.Type.(*ast.SelectorExpr).Sel.Name
}
//fieldType := field.Type.(*ast.Ident).Name
fmt.Printf("%s- %s\n", tableCol.FieldName, fieldType)
//Check for integers
if strings.Contains(fieldType, "int") {
tableCol.ColType = "integer"
} else {
//Check for other types
switch fieldType {
case "string":
tableCol.ColType = "text"
case "bool":
tableCol.ColType = "boolean"
case "Time":
tableCol.ColType = "TIMESTAMP"
default:
log.Panicf("Field type %s not supported", fieldType)
}
}
tableCol.FieldType = fieldType
curTable.Columns = append(curTable.Columns, tableCol)
curTable.StructName = currType.Name.Name
}
curTable.generateCreateTable(out)
fmt.Printf("\tgenerating CRUD methods for %s\n", currType.Name.Name)
curTable.generateCreate(out)
curTable.generateQuery(out)
curTable.generateUpdate(out)
curTable.generateDelete(out)
}
}
Мы проходим по всем полям искомой структуры и начинаем разбор тегов каждого поля. С использованием рефлексии мы получаем интересующий нас тег (ведь на поле могут быть другие теги, например, для json). Выполняем разбор содержимого тега и определяем, является ли поле первичным ключом (если указали более одного первичного ключа, ругнемся об этом и остановим выполнение), есть ли требование к тому, чтобы поле было ненулевым, вообще нужно ли работать с БД для этого поля и определим имя столбца, если оно было переопределено в теге. Также нам нужно определить тип столбца таблицы на основании типа поля структуры. Типов полей конечное множество, будем генерировать только для базовых типов, строки все сведем в тип поля TEXT, хотя в целом можно определение типа столбца добавить в теги, чтобы можно было настраивать более тонко. С другой стороны, никто не мешает создать нужную таблицу в базе заранее, либо скорректировать созданную автоматически.
После разбора структуры запускаем метод для создания кода функции создания таблицы и методы для создания функций Create, Query, Update, Delete. Мы готовим SQL выражение для каждой функции и обвязку для запуска. С обработкой ошибок не стал сильно заморачиваться, просто отдаю ошибку от драйвера БД. Для кодогенерации удобно использовать шаблоны из библиотеки text/template. С их помощью можно получить гораздо более поддерживаемый и предсказуемый код (код виден сразу, а не размазан по коду генератора).
func (tableD *TableInfo) generateCreateTable(out *os.File) error {
fmt.Fprint(out, "func (in *"+tableD.StructName+") createTable(db *sql.DB) (error) {\n")
var resSQLq = fmt.Sprintf("\tsqlQ := `CREATE TABLE %s (\n", tableD.TableName)
for _, col := range tableD.Columns {
colSQL := col.ColName + " " + col.ColType
if col.NotNull {
colSQL += " NOT NULL"
}
if col == tableD.PrimaryKey {
colSQL += " AUTO_INCREMENT"
}
colSQL += ",\n"
resSQLq += colSQL
}
if tableD.PrimaryKey != nil {
resSQLq += fmt.Sprintf("PRIMARY KEY (%s)\n", tableD.PrimaryKey.ColName)
}
resSQLq += ")`\n"
fmt.Fprint(out, resSQLq)
fmt.Fprint(out, "\t_, err := db.Exec(sqlQ)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n")
fmt.Fprint(out, "\t return nil\n}\n\n")
return nil
}
fmt.Fprint(out, "func (in *"+tableD.StructName+") Create(db *sql.DB) (error) {\n")
var columns, valuePlaces, valuesListParams string
for _, col := range tableD.Columns {
if col == tableD.PrimaryKey {
continue
}
columns += "`" + col.ColName + "`,"
valuePlaces += "?,"
valuesListParams += "in." + col.FieldName + ","
}
columns = columns[:len(columns)-1]
valuePlaces = valuePlaces[:len(valuePlaces)-1]
valuesListParams = valuesListParams[:len(valuesListParams)-1]
resSQLq := fmt.Sprintf("\tsqlQ := \"INSERT INTO %s (%s) VALUES (%s);\"\n",
tableD.TableName,
columns,
valuePlaces)
fmt.Fprintln(out, resSQLq)
fmt.Fprintf(out, "result, err := db.Exec(sqlQ, %s)\n", valuesListParams)
fmt.Fprintln(out, `if err != nil {
return err
}`)
//Setting id if we have primary key
if tableD.PrimaryKey != nil {
fmt.Fprintf(out, `lastId, err := result.LastInsertId()
if err != nil {
return nil
}`)
fmt.Fprintf(out, "\nin.%s = %s(lastId)\n", tableD.PrimaryKey.FieldName, tableD.PrimaryKey.FieldType)
}
fmt.Fprintln(out, "return nil\n}\n\n")
//in., _ := result.LastInsertId()`)
return nil
}
func (tableD *TableInfo) generateQuery(out *os.File) error {
fmt.Fprint(out, "func (in *"+tableD.StructName+") Query(db *sql.DB) ([]*"+tableD.StructName+", error) {\n")
fmt.Fprintf(out, "\tsqlQ := \"SELECT * FROM %s;\"\n", tableD.TableName)
fmt.Fprintf(out, "rows, err := db.Query(sqlQ)\n")
fmt.Fprintf(out, "results := make([]*%s, 0)\n", tableD.StructName)
fmt.Fprintf(out, `for rows.Next() {`)
fmt.Fprintf(out, "\t tempR := &%s{}\n", tableD.StructName)
var valuesListParams string
for _, col := range tableD.Columns {
valuesListParams += "&tempR." + col.FieldName + ","
}
valuesListParams = valuesListParams[:len(valuesListParams)-1]
fmt.Fprintf(out, "\terr = rows.Scan(%s)\n", valuesListParams)
fmt.Fprintf(out, `if err != nil {
return nil, err
}`)
fmt.Fprintf(out, "\n\tresults = append(results, tempR)")
fmt.Fprintf(out, `}
return results, nil
}`)
fmt.Fprintln(out, "")
fmt.Fprintln(out, "")
return nil
}
func (tableD *TableInfo) generateUpdate(out *os.File) error {
fmt.Fprint(out, "func (in *"+tableD.StructName+") Update(db *sql.DB) (error) {\n")
var updVals, valuesListParams string
for _, col := range tableD.Columns {
if col == tableD.PrimaryKey {
continue
}
updVals += "`" + col.ColName + "`=?,"
valuesListParams += "in." + col.FieldName + ","
}
updVals = updVals[:len(updVals)-1]
valuesListParams += "in." + tableD.PrimaryKey.FieldName
resSQLq := fmt.Sprintf("\tsqlQ := \"UPDATE %s SET %s WHERE %s = ?;\"\n",
tableD.TableName,
updVals,
tableD.PrimaryKey.ColName)
fmt.Fprintln(out, resSQLq)
fmt.Fprintf(out, "_, err := db.Exec(sqlQ, %s)\n", valuesListParams)
fmt.Fprintln(out, `if err != nil {
return err
}`)
fmt.Fprintln(out, "return nil\n}\n\n")
//in., _ := result.LastInsertId()`)
return nil
}
func (tableD *TableInfo) generateDelete(out *os.File) error {
fmt.Fprint(out, "func (in *"+tableD.StructName+") Delete(db *sql.DB) (error) {\n")
fmt.Fprintf(out, "sqlQ := \"DELETE FROM %s WHERE id = ?\"\n", tableD.TableName)
fmt.Fprintf(out, "_, err := db.Exec(sqlQ, in.%s)\n", tableD.PrimaryKey.FieldName)
fmt.Fprintln(out, `if err != nil {
return err
}
return nil
}`)
fmt.Fprintln(out)
return nil
}
Запуск получившегося кодогенератора выполняется обычным go run, передаем в флаг -name путь к файлу, для которого нужно сгенерировать код. В результате получаем файл с суффиксом _dbe, в котором лежит созданный код. Для тестов создадим методы для следующий структуры:
// dbe:{"table": "users"}
type User struct {
ID int `dbe:"id,primary_key"`
Login string `dbe:"login,not_null"`
Email string
Level uint8
IsActive bool
UError error `dbe:"-"`
}
package main
import "database/sql"
func (in *User) createTable(db *sql.DB) error {
sqlQ := `CREATE TABLE users (
id integer NOT NULL AUTO_INCREMENT,
login text NOT NULL,
Email text,
Level integer,
IsActive boolean,
PRIMARY KEY (id)
)`
_, err := db.Exec(sqlQ)
if err != nil {
return err
}
return nil
}
func (in *User) Create(db *sql.DB) error {
sqlQ := "INSERT INTO users (`login`,`Email`,`Level`,`IsActive`) VALUES (?,?,?,?);"
result, err := db.Exec(sqlQ, in.Login, in.Email, in.Level, in.IsActive)
if err != nil {
return err
}
lastId, err := result.LastInsertId()
if err != nil {
return nil
}
in.ID = int(lastId)
return nil
}
func (in *User) Query(db *sql.DB) ([]*User, error) {
sqlQ := "SELECT * FROM users;"
rows, err := db.Query(sqlQ)
results := make([]*User, 0)
for rows.Next() {
tempR := &User{}
err = rows.Scan(&tempR.ID, &tempR.Login, &tempR.Email, &tempR.Level, &tempR.IsActive)
if err != nil {
return nil, err
}
results = append(results, tempR)
}
return results, nil
}
func (in *User) Update(db *sql.DB) error {
sqlQ := "UPDATE users SET `login`=?,`Email`=?,`Level`=?,`IsActive`=? WHERE id = ?;"
_, err := db.Exec(sqlQ, in.Login, in.Email, in.Level, in.IsActive, in.ID)
if err != nil {
return err
}
return nil
}
func (in *User) Delete(db *sql.DB) error {
sqlQ := "DELETE FROM users WHERE id = ?"
_, err := db.Exec(sqlQ, in.ID)
if err != nil {
return err
}
return nil
}
Для тестирования работы сгенерированного кода создадим объект с произвольными данными, создадим для него таблицу (если таблица существует в базе, вернется ошибка). После поместим этот объект в таблицу, прочитаем все поля из таблицы, обновим значения уровня и удалим объект.
var err error
db, err := sql.Open("mysql", DSN)
if err != nil {
fmt.Println("Unable to connect to DB", err)
return
}
err = db.Ping()
if err != nil {
fmt.Println("Unable to ping BD")
return
}
newUser := &User{
Login: "newUser",
Email: "new@test.com",
Level: 0,
IsActive: false,
UError: nil,
}
err = newUser.createTable(db)
if err != nil {
fmt.Println("Error creating table.", err)
}
err = newUser.Create(db)
if err != nil {
fmt.Println("Error creating user.", err)
return
}
nU := &User{}
dbUsers, err := nU.Query(db)
if err != nil {
fmt.Println("Error selecting users.", err)
return
}
fmt.Printf("From table users selected %d fields", len(dbUsers))
var DBUser *User
for _, user := range dbUsers {
fmt.Println(user)
DBUser = user
}
DBUser.Level = 2
err = DBUser.Update(db)
if err != nil {
fmt.Println("Error updating users.", err)
return
}
err = DBUser.Delete(db)
if err != nil {
fmt.Println("Error deleting users.", err)
return
}
В текущей реализации функционал клиента к БД сильно ограничен:
- поддерживается только MySQL;
- не все типы полей поддерживаются;
- для SELECT нет фильтрации и лимитов.
Однако, исправление недочетов уже за пределами вопроса разбора исходного кода на Go и генерирования на его основе нового кода.
Использование генератора кода в подобном сценарии позволит менять поля и типы структур, используемых в приложении, только в одном месте, нет необходимости помнить о внесении изменений в код взаимодействия с БД, просто каждый раз необходимо запускать генератор кода. Данную задачу можно было решить с помощью рефлексии, но это бы отразилось на производительности.
Исходники генератора кода и пример сгенерированного кода выложил на Github.