Кодогенерация в 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.

Код работы с easyjson

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
}


Обновление записи (работает по primary key)

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
}


Удаление записи (работает по primary key)

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.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    0

    С одной стороны кодогенерация позволяет видеть созданный код, с другой — мне приходится писать проекты типа
    https://github.com/reddec/struct-view,
    https://github.com/reddec/jsonrpc2/tree/master/cmd/jsonrpc2-gen/internal или
    https://github.com/reddec/godetector/tree/master/deepparser
    А в большинстве случаев можно было бы обойтись generic/template. Надеюсь их все таки завезут при моей жизни в язык.


    Если кто-то будет писать свои кодогенераторы на основе разбора кода Го — будьте аккуратны, там много пограничных случаев (например встраивание типов, алиасинг и enum'ы). Посмотрите мои проекты выше — там много собрано костылей и боли.
    Первый — более старый и менее чистый но разносторонний, третий более новый и причесанный.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое