Русский
Русский
English
Статистика
Реклама

Кодогенерация

Как писать кодогенераторы в Go

03.06.2021 16:16:28 | Автор: admin

Однообразный код писать неинтересно, нудно, но приходится. Испокон веков изворотливые программисты ищут Святой Грааль формализма, позволяющего переложить рутинные задачи на машину, писать только раз и переиспользовать код. Так появились структурное программирование, потом объектно-ориентированное, полиморфизм с параметризованными типами, кодогенерация на основе формальных грамматик, препроцессоры макроязыка и прочее Под катом рассмотрим, как обстоят дела именно в Go.


В Go на сегодня generics нет (хоть третий год и обещают), а выписывать по шаблону GetMax([]MyType) для каждого MyType надоедает.

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

Runtime-доступ к диспетчеру предоставлен пакетом reflect, что обеспечивает сильный, но дорогостоящий механизм интроспекции, позволяющий динамически оперировать статически заявленными типами. Вызовы reflect затратны, но, например, в C нет и этого, там на стадии выполнения данные не знают, какого они типа.

Стандартного препроцессора в Go тоже нет. Зато есть директива go:generate и есть доступ к потрохам компилятора, в частности к дереву разбора (Abstract Syntax Tree), в пакетах go/ стандартной библиотеки. Это в совокупности даёт инструментарий богаче, чем препроцессор макросов.

Идиоматическое применение интерфейсов реализовано в stdlib-пакете sort, интроспекция применяется в пакетах encoding и fmt, go:generate в придворном пакете golang.org/x/tools/cmd/stringer.

Манипулирование AST исходного кода не очень распространено, потому что:

  • кодогенерацию трудно верифицировать;

  • дерево разбора кажется сложным, непонятным и пугает.

Как раз на использовании AST в быту мы и остановимся.

Go- и JS-разработчик Открытой мобильной платформы Дима Смотров рассказал, как писать кодогенераторы в Go и оптимизировать работу над микросервисами с помощью создания инструмента для генерации шаблонного кода.Статья составлена на основе выступления Димы на GopherCon Russia 2020.

О продуктах и компонентах на Go

Наша команда разрабатывает мобильную ОС Аврора, SDK и экосистему приложений под неё, доверенную среду исполнения Аврора ТЕЕ, систему по управлению корпоративной мобильной инфраструктурой Аврора Центр, включающую несколько коробочных продуктов и компонентов.

Группа Дмитрия, в частности, работает над продуктом Аврора Маркет, который обеспечивает управление дистрибуцией приложений. Его бэкенд полностью написан на Go.

В Go принято отдавать предпочтение явному программированию (explicit) в противовес неявному (implicit). Это помогает новым разработчикам легче начинать работать над существующими проектами. Но по пути от неявного программирования к явному можно легко заблудиться и забрести в дебри дубляжа кода, а дубляж кода в дальнейшем превратит поддержку проекта в ад.

Чтобы этого избежать, код выносят в отдельные модули. Но как насчёт кода, который пишется специально для каждого микросервиса и не может быть вынесен в модуль? Например, код репозитория для работы с базой данных. Этот код есть в каждом микросервисе, выглядит примерно одинаково, но он разный и не дублируется. Не хочется писать шаблонный код, который потом придётся ещё и поддерживать во всех микросервисах.

Кодогенерация официальный инструмент от авторов Go

Для решения шаблонных задач можно использовать метапрограммирование это разработка программ, которые создают программы перед этапом компиляции или изменяют их во время выполнения. Этот метод относится к неявному программированию.

И хотя в Go принято отдавать предпочтение явному программированию, разработчики предоставили инструменты для метапрограммирования, такие как кодогенерация ($go help generate) и Reflection API. Reflection API используется на этапе выполнения программы, кодогенерация перед этапом компиляции. Reflection API увеличивает время работы программы. Пример: инструмент для кодирования и декодирования JSON из стандартной библиотеки Go использует Reflection API. Взамен ему сообществом были рождены такие альтернативы, как easyjson, который с помощью кодогенерации кодирует и декодирует JSON в 5 раз быстрее.

Так как кодогенерация неявное программирование, она недооценивается сообществом Go, хотя и является официальным инструментом от создателей этого языка программирования. Поэтому в интернете немного информации о написании кодогенераторов на Go. Но всё же на Хабре примеры есть: 1 и 2.

При разработке микросервисов есть много похожего шаблонного кода, который нужно писать в каждом микросервисе. Например, код репозитория по работе с базой данных. Мы создали кодогенераторы для того, чтобы разработчики не тратили время на написание этого шаблонного кода и могли сфокусироваться на решении задач, относящихся к дизайну кода и предметной области бизнеса. Команда использует кодогенераторы и для сокращения времени на создание новых микросервисов. Это позволяет не ограничивать разработчика в принятии архитектурных решений, так как создание нового микросервиса не влияет на трудоёмкость выполнения задачи.

Пример дублирующего кода:

type UserRepository struct{ db *gorm.DB }func NewRepository(db *gorm.DB) UserRepository {    return UserRepository{db: db}}func (r UserRepository) Get(userID uint) (*User, error) {    entity := new(User)    err := r.db.Limit(limit: 1).Where(query: "user_id = ?", userID).Find(entity).Error    return entity, err}func (r UserRepository) Create(entity *User) error {    return r.db.Create(entity).Error}func (r UserRepository) Update(entity *User) error {    return r.db.Model(entity).Update(entity).Error}func (r UserRepository) Delete(entity *User) error {    return r.db.Delete(entity).Error}

Про удачные кодогенераторы

Из примеров написанных и удачно используемых в нашей команде кодогенераторов хотим подробнее рассмотреть генератор репозитория по работе с базой данных. Нам нравится переносить опыт из одного языка программирования в другой. Так, наша команда попыталась перенести идею генерации репозиториев по работе с базой данных из Java Spring (https://spring.io/).

В Java Spring разработчик описывает интерфейс репозитория, исходя из сигнатуры метода автоматически генерируется реализация в зависимости от того, какой бэкенд для базы данных используется: MySQL, PostgreSQL или MongoDB. Например, для метода интерфейса с сигнатурой FindTop10WhereNameStartsWith (prefix string) автоматически генерируется реализация метода репозитория, которая вернёт до 10 записей из базы данных, имя которых начинается с переданного в аргументе префикса.

О нюансах и траблах внедрения кодогенератора

Существует парадигма Monolith First, когда пишут первую версию как монолит, а потом распиливают на микросервисы. На заре новой версии проекта, когда все команды должны были разбить монолит на микросервисы, мы решили написать свой генератор, который:

  • позволит вводить в систему новые микросервисы с меньшими усилиями, чем при его создании вручную (копируя предыдущий и удаляя лишнее);

  • сократит время на код-ревью за счёт общего шаблона для генерируемых микросервисов;

  • сократит время на будущие обновления одинакового кода микросервисов (main, инфрастуктура, etc).

Для разработки микросервисов командами было принято решение использовать go-kit. За основу мы взяли один из популярных существующих кодогенераторов для go-kit и стали его дорабатывать под наши требования для микросервисов. Он был написан с использованием не очень удобной библиотеки, которая использовала промежуточные абстракции для генерации кода Go. Код получался громоздким и трудным для восприятия и поддержки. В будущих версиях мы отказались от такого подхода и начали генерировать код Go с помощью шаблонов Go. Это позволило нам писать тот же самый код без каких-либо промежуточных абстракций. За пару недель нашей командой был написан прототип. А ещё через месяц был написан кодогенератор для go-kit, который буквально умел делать всё.

Разработчик описывает интерфейс go-kit-сервиса, а кодогенератор генерирует сразу всё, что для сервиса нужно:

  • CRUD-эндпоинты и REST-, gRPC- и NATS-транспорты;

  • репозиторий для работы с базой данных с возможностью расширять интерфейс репозитория;

  • main для всех go-kit-сервисов.

После того как кодогенератор был закончен, началось его внедрение. Мы сразу же столкнулись с проблемами. Разработчики компании неохотно принимали кодогенератор. Он генерировал слишком много кода, который нужно было ревьюить и перерабатывать. Сначала команды помогали исправлять генерируемый код, но подошло время уже разрабатывать сами микросервисы, а не кодогенератор для генерации сервисов. В итоге одни команды продолжали использовать кодогенератор, обходя его баги, а другие начали писать микросервисы без кодогенератора. Мы получили сегментацию сервисов. Когда инфраструктура менялась, изменения давались нам очень тяжело.

Позже наша команда работала над проектом, где нужно было больше заниматься адаптацией существующих Open-Source-продуктов под наши требования, чем разработкой новых микросервисов. Мы больше не могли заниматься кодогенератором, так как не использовали его активно в работе. А впоследствии мы тоже начали копировать сервисы вместе со всеми их недостатками.

Когда наша команда вернулась к разработке своего основного продукта, мы провели ретроспективу кодогенератора и поняли, в чём была основная проблема. Кодогенераторы, которые генерируют ВСЁ, сложно внедрять и поддерживать.

  • Кодогенератор генерировал слишком много кода.

  • Весь код нужно было ревьювить и перерабатывать.

  • Только часть команд решила пользоваться кодогенератором.

  • Получили сегментацию микросервисов.

Мы исправили эту проблему. Сейчас кодогенератор разбили на несколько мелких, у каждого из которых своя собственная ответственность. Разработчики сами выбирают, какие из них использовать в своих продуктах, а какие нет.

Как же всё-таки генерировать Go-код

Можно просто использовать шаблоны. Можно написать шаблон и начинить его параметрами, на это вполне способны продвинутые редакторы текста. Можно использовать неинтерактивные редакторы sed или awk, порог входа круче, зато лучше поддаётся автоматизации и встраивается в производственный конвейер. Можно использовать специфические инструменты рефакторинга Go из пакета golang.org/x/tools/cmd, а именно gorename или eg. А можно воспользоваться пакетом text/template из стандартной библиотеки решение достаточно гибкое, человекочитаемое (в отличие от sed), удобно интегрируется в pipeline и позволяет оставаться в среде одного языка.

И всё же для конвейерной обработки этого маловато: требует существенного вмешательства оператора.

Можно пойти по проторённому пути: gRPC, Protobuf, Swagger. Недостатки подхода:

  • привязывает к gRPC, Protobuf;

  • не заточен конкретно под Go, а, напротив, требует изучения и внедрения новых, сторонних абстракций и технологий.

Чтобы остаться в родных пенатах воспользуемся средствами из стандартной библиотеки пакетами go/:

  • go/ast декларирует типы дерева разбора;

  • go/parser разбирает исходный код в эти типы;

  • go/printer выливает AST в файл исходного кода;

  • go/token обеспечивает привязку дерева разбора к файлу исходного кода.

Можно сделать шаблон, разобрать его AST и манипулировать этим деревом, подгоняя под нужный вид. Основной недостаток метода плохая читаемость манипуляций с AST и трудоёмкость верификации.

Можно вытащить из AST параметры, вмонтировать в шаблон и всё. Недостаток разрозненные обработки неудобно собирать в конвейер.

Поэтому выбран такой алгоритм кодогенерации:

  1. Разбираем AST исходного файла.

  2. Создаём пустое AST для генерируемого файла.

  3. Генерируем код из шаблонов Go (template/text).

  4. Разбираем AST сгенерированного кода.

  5. Копируем узлы AST из сгенерированного кода в AST генерируемого файла.

  6. Печатаем и сохраняем AST генерируемого файла в файл.

Чтобы было понятней и не пугала загадочная аббревиатура AST дерево разбора Hello World:

package mainimport "fmt"func main() {    fmt.Println("Hello, World!")}

...выглядит вот так:

...или вот так, напечатанное специализированным принтером ast.Print():

ast.Print
0  *ast.File {1  .  Package: 2:12  .  Name: *ast.Ident {3  .  .  NamePos: 2:94  .  .  Name: "main"5  .  }6  .  Decls: []ast.Decl (len = 2) {7  .  .  0: *ast.GenDecl {8  .  .  .  TokPos: 4:19  .  .  .  Tok: import10  .  .  .  Lparen: -11  .  .  .  Specs: []ast.Spec (len = 1) {12  .  .  .  .  0: *ast.ImportSpec {13  .  .  .  .  .  Path: *ast.BasicLit {14  .  .  .  .  .  .  ValuePos: 4:815  .  .  .  .  .  .  Kind: STRING16  .  .  .  .  .  .  Value: "\"fmt\""17  .  .  .  .  .  }18  .  .  .  .  .  EndPos: -19  .  .  .  .  }20  .  .  .  }21  .  .  .  Rparen: -22  .  .  }23  .  .  1: *ast.FuncDecl {24  .  .  .  Name: *ast.Ident {25  .  .  .  .  NamePos: 6:626  .  .  .  .  Name: "main"27  .  .  .  .  Obj: *ast.Object {28  .  .  .  .  .  Kind: func29  .  .  .  .  .  Name: "main"30  .  .  .  .  .  Decl: *(obj @ 23)31  .  .  .  .  }32  .  .  .  }33  .  .  .  Type: *ast.FuncType {34  .  .  .  .  Func: 6:135  .  .  .  .  Params: *ast.FieldList {36  .  .  .  .  .  Opening: 6:1037  .  .  .  .  .  Closing: 6:1138  .  .  .  .  }39  .  .  .  }40  .  .  .  Body: *ast.BlockStmt {41  .  .  .  .  Lbrace: 6:1342  .  .  .  .  List: []ast.Stmt (len = 1) {43  .  .  .  .  .  0: *ast.ExprStmt {44  .  .  .  .  .  .  X: *ast.CallExpr {45  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {46  .  .  .  .  .  .  .  .  X: *ast.Ident {47  .  .  .  .  .  .  .  .  .  NamePos: 7:248  .  .  .  .  .  .  .  .  .  Name: "fmt"49  .  .  .  .  .  .  .  .  }50  .  .  .  .  .  .  .  .  Sel: *ast.Ident {51  .  .  .  .  .  .  .  .  .  NamePos: 7:652  .  .  .  .  .  .  .  .  .  Name: "Println"53  .  .  .  .  .  .  .  .  }54  .  .  .  .  .  .  .  }55  .  .  .  .  .  .  .  Lparen: 7:1356  .  .  .  .  .  .  .  Args: []ast.Expr (len = 1) {57  .  .  .  .  .  .  .  .  0: *ast.BasicLit {58  .  .  .  .  .  .  .  .  .  ValuePos: 7:1459  .  .  .  .  .  .  .  .  .  Kind: STRING60  .  .  .  .  .  .  .  .  .  Value: "\"Hello, World!\""61  .  .  .  .  .  .  .  .  }62  .  .  .  .  .  .  .  }63  .  .  .  .  .  .  .  Ellipsis: -64  .  .  .  .  .  .  .  Rparen: 7:2965  .  .  .  .  .  .  }66  .  .  .  .  .  }67  .  .  .  .  }68  .  .  .  .  Rbrace: 8:169  .  .  .  }70  .  .  }71  .  }72  .  Scope: *ast.Scope {73  .  .  Objects: map[string]*ast.Object (len = 1) {74  .  .  .  "main": *(obj @ 27)75  .  .  }76  .  }77  .  Imports: []*ast.ImportSpec (len = 1) {78  .  .  0: *(obj @ 12)79  .  }80  .  Unresolved: []*ast.Ident (len = 1) {81  .  .  0: *(obj @ 46)82  .  }83  }

Хватит трепаться, покажите код

В целом задача сводится к тому, чтобы разработчик микросервиса мог описать свои данные обычным образом в стандартном синтаксисе Go, только добавить в комментариях директивы процессинга:

//repogen:entitytype User struct {    ID              uint `gorm:"primary_key"`    Email           string    PasswordHash    string}

...запустить go generate и получить вот такой файл с готовой обвязкой для работы с DB, в котором прописаны методы именно для его типа данных User:

User
type UserRepository struct{db *gorm.DB}func NewRepository(db *gorm.DB) UserRepository {    return UserRepository{db: db}}func (r UserRepository) Get(userID uint) (*User, error) {    entity := new(User)    err := r.db.Limit(limit: 1).Where(query: "user_id = ?", userID).Find(entity).Error    return entity, err}func (r UserRepository) Create(entity *User) error {    return r.db.Create(entity).Error}func (r UserRepository) Update(entity *User) error {    return r.db.Model(entity).Update(entity).Error}func (r UserRepository) Delete(entity *User) error {    return r.db.Delete(entity).Error}

Напишем такой генератор прямо здесь и сейчас, не применяя готовых решений, а ограничившись стандартной библиотекой.

Кода потребовалось не очень много, поэтому он представлен одним листингом, чтобы не терялась общая картина. Пояснения даны в комментариях, в стиле literate programming.

Вот модель, для которой нам нужно сгенерировать методы работы с DB. В комментариях видны директивы:

  • go:generate repogen для команды go generate на запуск процессора repogen;

  • repogen:entity помечает цель для процессора repogen;

  • и тег поля структуры gorm:"primary_key" для процессора gorm помечает первичный ключ в таблице DB.

package gophercon2020//go:generate repogen//repogen:entitytype User struct {    ID              uint `gorm:"primary_key"`    Email           string    PasswordHash    string}

Вот код, собственно, процессора repogen:

Процессор repogen
package mainimport (    "bytes"    "go/ast"    "go/parser"    "go/printer"    "go/token"    "golang.org/x/tools/go/ast/inspector"    "log"    "os"    "text/template")//Шаблон, на основе которого будем генерировать//.EntityName, .PrimaryType  параметры,//в которые будут установлены данные, добытые из AST-моделиvar repositoryTemplate = template.Must(template.New("").Parse(`package mainimport (    "github.com/jinzhu/gorm")type {{ .EntityName }}Repository struct {    db *gorm.DB}func New{{ .EntityName }}Repository(db *gorm.DB) {{ .EntityName }}Repository {    return {{ .EntityName }}Repository{ db: db}}func (r {{ .EntityName }}Repository) Get({{ .PrimaryName }} {{ .PrimaryType}}) (*{{ .EntityName }}, error) {    entity := new({{ .EntityName }})    err := r.db.Limit(1).Where("{{ .PrimarySQLName }} = ?", {{ .PrimaryName }}).Find(entity).Error()    return entity, err}func (r {{ .EntityName }}Repository) Create(entity *{{ .EntityName }}) error {    return r.db.Create(entity).Error}func (r {{ .EntityName }}Repository) Update(entity *{{ .EntityName }}) error {    return r.db.Model(entity).Update.Error}func (r {{ .EntityName }}Repository) Update(entity *{{ .EntityName }}) error {    return r.db.Model(entity).Update.Error}func (r {{ .EntityName }}Repository) Delete(entity *{{ .EntityName }}) error {    return r.db.Delete.Error}`))//Агрегатор данных для установки параметров в шаблонеtype repositoryGenerator struct{    typeSpec    *ast.TypeSpec    structType  *ast.StructType}//Просто helper-функция для печати замысловатого ast.Expr в обычный stringfunc expr2string(expr ast.Expr) string {    var buf bytes.Buffer    err := printer.Fprint(&buf, token.NewFileSet(), expr)    if err !- nil {        log.Fatalf("error print expression to string: #{err}")    return buf.String()}//Helper для извлечения поля структуры,//которое станет первичным ключом в таблице DB//Поиск поля ведётся по тегам//Ищем то, что мы пометили gorm:"primary_key"func (r repositoryGenerator) primaryField() (*ast.Field, error) {    for _, field := range r.structType.Fields.List {        if !strings.Contains(field.Tag.Value, "primary")            continue        }        return field, nil    }    return nil, fmt.Errorf("has no primary field")}//Собственно, генератор//оформлен методом структуры repositoryGenerator,//так что параметры передавать не нужно://они уже аккумулированы в ресивере метода r repositoryGenerator//Передаём ссылку на ast.File,//в котором и окажутся плоды трудовfunc (r repositoryGenerator) Generate(outFile *ast.File) error {    //Находим первичный ключ    primary, err := r.primaryField()    if err != nil {        return err    }    //Аллокация и установка параметров для template    params := struct {        EntityName      string        PrimaryName     string        PrimarySQLName  string        PrimaryType     string    }{        //Параметры извлекаем из ресивера метода        EntityName      r.typeSpec.Name.Name,        PrimaryName     primary.Names[0].Name,        PrimarySQLName  primary.Names[0].Name,        PrimaryType     expr2string(primary.Type),    }    //Аллокация буфера,    //куда будем заливать выполненный шаблон    var buf bytes.Buffer    //Процессинг шаблона с подготовленными параметрами    //в подготовленный буфер    err = repositoryTemplate.Execute(&buf, params)    if err != nil {        return fmt.Errorf("execute template: %v", err)    }    //Теперь сделаем парсинг обработанного шаблона,    //который уже стал валидным кодом Go,    //в дерево разбора,    //получаем AST этого кода    templateAst, err := parser.ParseFile(        token.NewFileSet(),        //Источник для парсинга лежит не в файле,        "",        //а в буфере        buf.Bytes(),        //mode парсинга, нас интересуют в основном комментарии        parser.ParseComments,    )    if err != nil {        return fmt.Errorf("parse template: %v", err)    }    //Добавляем декларации из полученного дерева    //в результирующий outFile *ast.File,    //переданный нам аргументом    for _, decl := range templateAst.Decls {        outFile.Decls = append(outFile.Decls, decl)    }    return nil}func main() {    //Цель генерации передаётся переменной окружения    path := os.Getenv("GOFILE")    if path == "" {        log.Fatal("GOFILE must be set")    }    //Разбираем целевой файл в AST    astInFile, err := parser.ParseFile(        token.NewFileSet(),        path,        src: nil,        //Нас интересуют комментарии        parser.ParseComments,    )    if err != nil {        log.Fatalf("parse file: %v", err)    }    //Для выбора интересных нам деклараций    //используем Inspector из golang.org/x/tools/go/ast/inspector    i := inspector.New([]*ast.File{astInFile})    //Подготовим фильтр для этого инспектора    iFilter := []ast.Node{        //Нас интересуют декларации        &ast.GenDecl{},    }    //Выделяем список заданий генерации    var genTasks []repositoryGenerator    //Запускаем инспектор с подготовленным фильтром    //и литералом фильтрующей функции    i.Nodes(iFilter, func(node ast.Node, push bool) (proceed bool){        genDecl := node.(*ast.GenDecl)        //Код без комментариев не нужен,        if genDecl.Doc == nil {            return false        }        //интересуют спецификации типов,        typeSpec, ok := genDecl.Specs[0].(*ast.TypeSpec)        if !ok {            return false        }        //а конкретно структуры        structType, ok := typeSpec.Type.(*ast.StructType)        if !ok {            return false        }        //Из оставшегося        for _, comment := range genDecl.Doc.List {            switch comment.Text {            //выделяем структуры, помеченные комментарием repogen:entity,            case "//repogen:entity":                //и добавляем в список заданий генерации                genTasks = append(genTasks, repositoryGenerator{                    typeSpec: typeSpec,                    structType: structType,                })            }        }        return false    })    //Аллокация результирующего дерева разбора    astOutFile := &ast.File{        Name: astInFile.Name,    }    //Запускаем список заданий генерации    for _, task := range genTask {        //Для каждого задания вызываем написанный нами генератор        //как метод этого задания        //Сгенерированные декларации помещаются в результирующее дерево разбора        err = task.Generate(astOutFile)        if err != nil {            log.Fatalf("generate: %v", err)        }    }    //Подготовим файл конечного результата всей работы,    //назовем его созвучно файлу модели, добавим только суффикс _gen    outFile, err := os.Create(strings.TrimSuffix(path, ".go") + "_gen.go")    if err != nil {        log.Fatalf("create file: %v", err)    }    //Не забываем прибраться    defer outFile.Close()    //Печатаем результирующий AST в результирующий файл исходного кода    //Печатаем не следует понимать буквально,    //дерево разбора нельзя просто переписать в файл исходного кода,    //это совершенно разные форматы    //Мы здесь воспользуемся специализированным принтером из пакета ast/printer    err = printer.Fprint(outFile, token.NewFileSet(), astOutFile)    if err != nil {        log.Fatalf("print file: %v", err)    }}

Подводя итоги

Работа с деревом разбора в Go не требует сверхъестественных способностей. Язык предоставляет для этого вполне годный инструментарий. Кода получилось не слишком много, и он достаточно читаем и, надеемся, понятен. Высокой эффективности здесь добиваться нет нужды, потому что всё происходит ещё до стадии компиляции и на стадии выполнения издержек не добавляет (в отличие от reflect). Важнее валидность генерации и манипуляций с AST. Кодогенерация сэкономила нам достаточно времени и сил в написании и поддержке большого массива кода, состоящего из повторяющихся паттернов (микросервисов). В целом кодогенераторы оправдали затраты на своё изготовление. Выбранный pipeline показал себя работоспособным и прижился в производственном процессе. Из стороннего опыта можем рекомендовать к использованию:

  • dst (у которого лучше разрешение импортируемых пакетов и привязка комментариев к узлам AST, чем у go/ast из stdlib).

  • kit (хороший toolkit для быстрой разработки в архитектуре микросервисов. Предлагает внятные, рациональные абстракции, методики и инструменты).

  • jennifer (полноценный кодогенератор. Но его функциональность достигнута ценой применения промежуточных абстракций, которые хлопотно обслуживать. Генерация из шаблонов text/template на деле оказалась удобней, хоть и менее универсальной, чем манипулирование непосредственно AST с использованием промежуточных абстракций. Писать, читать и править шаблоны проще).

Набор узконаправленных генераторов оказался сподручней одного универсального и в применении, и в поддержке. Здесь допустима аналогия микросервисы vs монолит. Делитесь опытом и мнениями в комментариях.

Подробнее..

Delphi 7 на костылях автоматизация подготовки ресурсов

31.03.2021 18:20:59 | Автор: admin

Эпиграф: Пусть это вдохновит Вас на подвиг! (Бел Кауфман, Вверх по лестнице, ведущей вниз).

О костылях и велосипедах, неотъемлемой части современной некромантии.

Это история интеграции в процесс разработки одного единственного решения. Решение доведено до конечного результата, ссылка на репозиторий будет далее по тексту.

Казалось бы, что может быть более простым и естественным, чем прозрачная, без нелепых телодвижений, работа с размещёнными в специально выделенной для этого папке ресурсами? А если среда разработки выпущена на границе тысячелетия?

Первым великим неудобством, с которым я столкнулся, подписавшись года четыре назад на сопровождение и активную доработку проекта на Delphi 7, было категорическое неудобство работы с входящими в проект относительно крупными SQL запросами. Проект обеспечивает отчётность перед поставщиками (десятки поставщиков, взаимодействие с которыми идёт через множество компаний-интеграторов), и этих запросов там, что гуталина у сторожа ну просто завались. Причём запросы эти изначально описывались прямо в тексте, вперемешку с кодом представьте мой восторг с учётом того, что запросы приходилось время от времени переносить в SSMS, исправлять и переносить обратно. А если вспомнить, что и Delphi, и в SQL используются одиночные кавычки, становится ещё печальнее.

Первая реакция на эту красоту была вполне предсказуемой: срочно отделить данные от кода! Идеальным (и очевидным) решением кажется создание структуры папок с файлами запросов, которые при компиляции автоматически попадали бы в ресурсы с соответствующими идентификаторами. При этом, однако, компилятору нужно явно предоставлять список ресурсов в виде отдельно сформированного *.rc файла с соответствующими именами для доступа, который надо ещё предварительно сформировать.

Однако, компиляция проекта из-под IDE Delphi 7 является чёрным ящиком без малейшей возможности прикрутить к ней хоть что-то своё. У неё просто нет ни одного хука, чтобы зацепить собственный обработчик. Современные версии работают с MS Build, но у меня-то этого нет! Конечно, для сборки продуктивной версии можно использовать батник и компилятор командной строки, где можно добавить любую предварительную обработку, но для запуска из-под IDE этот вариант не годится.

Ещё одна печалька оказалась в том, что файл ресурсов (*.rc) перекомпилируется только тогда, когда изменилась его собственная дата. То, что изменилась дата файла, на который он ссылается (то есть сам ресурс), компилятор не волнует никак. Плюс rc-файл ещё и создать надо! И очень, очень хочется делать это автоматически.

Но сначала появился костыль. До этого я работал с Дельфи лет восемь назад, и с ресурсами тогда совсем не сталкивался, зато свободно делал компоненты. Итак, для решения вопроса по-быстрому я набросал визуальный компонент, который можно было кинуть на форму и открыть текст в редакторе (в окончательной версии - вообще двойным кликом). Набросок был готов в течение пары часов, затем после нескольких подходов компонент был окончательно оформлен и разделён на две части (просто текст и SQL) ради синтаксической подсветки в редакторе кода. Ниже, на скриншоте, Вы можете увидеть аж четыре компонента (два из которых успели активно поработать), но Проект, хотя и обеспечил очень быстро возможность по-человечески работать с текстом запросов, был заброшен в силу присущих ему принципиальных недостатков.

Первым и самым важным из них оказался формат хранения контента в .dfm файле почти текстовый. Текст принудительно разбивался на строки, закавычивался, все символы не из базовой латиницы записывались многосимвольными кодами Представляете себе, как выглядели при этом диффы? Невозможным оказывался также контекстный поиск по всему проекту (тексты запросов в него не попадали). Поиск сторонними средствами по латинице мог споткнуться о размер строки и её принудительный перенос посреди слова.

  object SqlSALES: TSqlVar    SQL.Strings = (      'SET NOCOUNT ON'      ''      'IF OBJECT_ID(N'#39'tempdb..#Sales'#39', N'#39'U'#39') IS NOT NULL '      'DROP TABLE #Sales;'      ''      'IF OBJECT_ID(N'#39'tempdb..#ClientIDs'#39', N'#39'U'#39') IS NOT NULL '      'DROP TABLE #ClientIDs;'      ''      '-- '#1055#1072#1088#1072#1084#1077#1090#1088#1099': '#1076#1072#1090#1072' '#1086#1090', '#1076#1072#1090#1072' '#1076#1086', '#1075#1088#1091#1087#1087#1072      '-- '#1056#1077#1072#1083#1080#1079#1072#1094#1080#1103      'SELECT '              #9'OperDate,                                                  -- '#1044 +        #1072#1090#1072              ...

На это накладывался ещё один немаловажный недостаток, присущий самописным компонентам Дельфи в принципе: компонент не может быть зарегистрирован на уровне проекта, его надо явно и заранее устанавливать в IDE, но далеко не все это умеют. Если открыть проект в среде без соответствующих компонентов, то они, после гневного сообщения просто пропадут. В наше время, с сокращением количества опытных кадров, не гнушающихся работать с древнючей Delphi, это выглядит, как натуральная бас-факторная мина. Хотя и без того там этих мин хоть ведром черпай...

Еще одна существенная проблема заключалась в невозможности использовать для редактирования при таком подходе сторонние редакторы.

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

Итак, я вернулся к идее хранить отдельные запросы в файлах. Предстояло решить следующие проблемы:

  1. Должен автоматически формироваться файл со списком ресурсов для компилятора

  2. ВАЖНО!!! изменение файла с запросом должно автоматически попасть в программу, при первой же компиляции (даже если не делать build)

  3. Обеспечить включение ресурсов в операцию контекстного поиска по проекту.

  4. Опционально ещё и шифровать для пущего пафоса. Требование проекта на уровне Очень желательно.

По первому пункту я решил использовать Gulp.js инструмент для сборки фронтенда, с которым мельком удалось познакомится незадолго до этого. Он умеет следить за изменением файлов в папке и обрабатывать это событие. Мне требовалась лишь возможность запустить по событию командный файл. Этот же файл используется и для билда продуктивной версии.

Обеспечение второго пункта разложилось на две половинки. Во-первых, при изменении исходных файлов требовалось сразу же, не дожидаясь перестроения списка, изменить дату .rc файла. Это инициирует запрос на перезагрузку файла при переключении в Delphi. Во-вторых, этот файл должен быть постоянно открыт в IDE, иначе компилятор не обращает внимания на то, что он изменился (он-то, наивный, предполагает, что я всё через IDE делаю). А так при переключении извне в IDE дата файла проверяется и задаётся вопрос о необходимости его перезагрузить.

Третий пункт (контекстный поиск) решается добавлением исходного файла в проект, до первого USES, в директиве компилятора вида:

{%File 'Res\SRC\SQL\Import\Sectors\leafSectors.sql'}

Четвёртый решается элементарно запуском отдельной утилитки для шифрования всё в том же пакетном файле, и расшифровкой непосредственно в основном проекте при обращении к ресурсу.

Ну, и собственно список файлов сформировать надо было. В результате шапка проекта стала выглядеть примерно так:

SomeProject.dpr

library SomeProject;{  Текст между тегами ниже генерируется и обновляется автоматически!  Этот фрагмент нужен для подключения исходных файлов ресурсов к поиску  текста по файлам проекта.   Для разового обновления запустить res/CompileRc/CompileAllResources.cmd  Для автоматического обновления запустить _Автообновление ресурсов.cmd  Пока работает автообновление, после каждого изменения исходных файлов в папке res\src  автоматически запускается res/CompileRc/CompileAllResources.cmd  Чтобы среда разработки автоматически подтягивала изменения, в ней должен быть  открыт в редакторе исходник 'Res\AutoGenerated.rc'. <= Можно поместить  курсор куда-нибудь сюда -----^^^^^^^^^^^^^^^^^^^^ и нажать Ctrl + Enter}{<AUTOGENERATED_RC>}{%File 'Res\SRC\SQL\DB_Updates.sql'}{%File 'Res\SRC\SQL\Foo\Sales.sql'}{%File 'Res\SRC\SQL\Foo\Stocks.sql'}...{$R 'Res\AutoGenerated.res' 'Res\AutoGenerated.rc'}{</AUTOGENERATED_RC>}uses...

AutoGenerated.rc (пример):

SQL_DB_Updates     RCDATA PREPARED\SQL_DB_UpdatesSQL_Foo_Sales     RCDATA PREPARED\SQL_Foo_SalesSQL_Foo_Stocks     RCDATA PREPARED\SQL_Foo_Stocks...

Галпфайл был простой до безобразия:

gulpfile.js

const { watch, series } = require('gulp');const spawn = require('child_process').spawn; function compileResources(cb) { var cmd = spawn('CompileRc\\CompileAllResources.cmd', [], {stdio: 'inherit'}); cmd.on('close', function (code) {   cb(code); });} exports.default = function() { compileResources(code=>{}) watch('Src/**', compileResources); // series(compileResources, ...)};

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

Кстати, пришлось его радикально править, когда пришлось обновить Галп после обновления Ноды. Текущие версии у меня такие:

>gulp --versionCLI version: 2.3.0Local version: 4.0.2>node -vv12.20.0

Впрочем, это уже не важно от Галпа я буквально в процессе написания статьи избавился.

Основной пакетник:

  • сразу же меняет дату сгенерированного файла

  • составляет список файлов в обслуживаемой папке, за вычетом исключений

  • запускает программку на Perl, которая преобразует сырой список файлов во все виды, в которых он употребляется, в том числе генерирует rc-файл, и формирует задание на подготовку (шифрование) для изменённых файлов

  • запускает подготовку ресурсов

  • выделяет из dpr ранее добавленный туда список ссылок. Если он изменился, то заменяет его

  • компилирует сформированный rc-файл

  • Информирует о времени запуска и завершения и об обслуживаемой папке (на случай одновременного запуска нескольких экзкмпляров.

И всё это запускается из отслеживающего изменения монитора.

CompileAllResources.cmd

@Echo offset BatchDir=%~dp0cd %BatchDir%touch ..\AutoGenerated.rcFOR %%i IN ("%BatchDir%..") DO (set target=%%~fi)echo.echo [%TIME%] STARTING: ===== %target% =====echo.if not exist %BatchDir%..\prepared\*.* md %BatchDir%..\prepared > nulcall %BatchDir%..\AutoCompileRc.Config.cmdFOR %%i IN ("%DprFile%") DO (set DprFileOnly=%%~nxi)call %BatchDir%WaitWhileRunned.cmd gitcd %BatchDir%..%BatchDir%bin\find SRC -type f | %BatchDir%bin\grep -E -v --file=%BatchDir%excludes.lst>%BatchDir%ResFiles.lst%BatchDir%bin\perl %BatchDir%CreateRc.pl %BatchDir%ResFiles.lst AutoGenerated.rc %BatchDir%RcSources.lst %BatchDir%PrepareIt.cmdcd %BatchDir%echo {$R 'Res\AutoGenerated.res' 'Res\AutoGenerated.rc'}>>RcSources.lstcall between.cmd %DprFile% "\{<AUTOGENERATED_RC>\}\s*" "\{<\/AUTOGENERATED_RC>\}">RcSourcesOld.lstfc RcSources.lst RcSourcesOld.lst>nulif errorlevel 1 (call ReplaceBetween.cmd %DprFile% RcSources.lst "\{\<AUTOGENERATED_RC\>\}\s*" "\{\<\/AUTOGENERATED_RC\>\}">%DprFileOnly%.tmpcopy %DprFileOnly%.tmp %DprFile%>nuldel %DprFileOnly%.tmp)echo Preparing updated and new files...cd ..call %BatchDir%PrepareIt.cmdecho Compiling resources...brcc32 AutoGenerated.rccd %BatchDir%echo.echo [%TIME%] DONE: ===== %target% =====echo.

Внутри он содержит сборную солянку технологий, использует линуксовские find и grep (под Виндой они ставятся вместе с git) и даже Перл. Каюсь, побаловаться захотелось. Забавный опыт, хотя и немного травматичный. Своей лаконичностью и непрозрачностью (вроде наличия переменной по умолчанию) он напомнил мне ассемблер:

CreateRc.pl

#!c:/Perl/bin/perlopen (IN, "<".$ARGV[0]) || die $!;      # Список исходных файлов для ресурсовopen (RC_ENC, ">".$ARGV[1]) || die $!;  # Формируемый исходник (.rc)open (LST, ">".$ARGV[2]) || die $!;     # Формируемый файл со списком  # исходников, который будет вставлен в проект между тегами  # {<AUTOGENERATED_RC>} и {</AUTOGENERATED_RC>}open (ENC_CMD, ">".$ARGV[3]) || die $!; # Формируемый пакетный файл (.cmd)  # для подготовки каждого изменённого файла (например, щифрование)while(<IN>){  split /\n/;  $File = $_;                     # Для каждого из исходных файлов  $File =~ s/\//\\/g;             # - приводим разделитель каталогов  $File =~ s/\n//;                # - исключаем перевод строки  $Name = $File;                  # Имя ресурса  $Name =~ s/^Src\\//i;           # - исключаем префикс (папка)  $Name =~ s/\\/_/g;              # - вместо разделителя каталогов подчёркивание  $Name =~ s/\..*$//;             # - исключаем расширение  $Dest = "PREPARED\\".$Name."";  $_ = $File;  $EncryptIt = ! /\\Bin\\/i;      # Ресурсы из папок и подпапок bin не шифруем  print RC_ENC $Name    , substr("                                        ", 1, 40 - length($Name))    , "    RCDATA  "    , $Dest    , "\r\n";  $_ = $File;  if (! /\\Bin\\/i) {    # Добавляем в проект текстовые ресурсы (здесь: формируем вставку в dpr файл)    # Ресурсы из папок и подпапок bin не считаем текстом и в проект не включаем    print LST        "{\%File 'Res\\"      , $File      , "'}\r\n";  }  # Только для новых или обновлённых файлов: добавляем команду на подготовку  # (шифрование либо просто копирование)  if (! (-f $Dest) || ( (stat $File)[9] > (stat $Dest)[9] ) ) {    if ($EncryptIt) {      # Шифрование: encrypt.cmd <источник> <приёмник> <имя ресурса в нижнем регистре>      # имя ресурса может быть использовано для генерации пароля.      # Еncrypt.cmd определяете самостоятельно.      print ENC_CMD "call encrypt.cmd "        , $File        , substr("                                        ", 1, 40 - length($File))        , " "        , $Dest        , " \""        , lc $Name        , "\"\r\n";    } else {      print ENC_CMD "copy "        , $File        , substr("                                        ", 1, 40 - length($File))        , " "        , $Dest        , ">nul\r\n";    }  }}close IN;close RC_ENC;close LST;close ENC_CMD;

В процессе многолетней эксплуатации вылезли забавные особенности. Для примера, обновление git (с последующим обновлением линуксовских утилит) как-то раз мою автоматизацию сломало. Конкретно, более новый grep отказался воспринимать список исключений как список регэкспов, по одному на строку. Find тоже что-то такое подбрасывал (по крайней мере, тот, что установлен глобально сейчас, уже не отрабатывает именно так, как ожидается). В результате пришлось зафиксировать их версию банально кинуть бинарники в репозиторий, чтобы потом не плакать, благо, они не шибко большие, и изменять я их не планирую. Было ли что-то подобное в связи с Перлом не помню, но на всякий случай и его туда же пихнул.

Помню ещё, что добавление шифрования вынудило запоминать дату изменения файлов и шифровать только обновлённые, иначе просто долго получалось скрипт не успевал отработать до того, как я отвечал согласием на предложение перезагрузить исходные файлы в IDE.

И уже после того, как взялся за эту статью, решил всё-таки избавиться от Галпа. Зачем мне (или тому, кто попробует это за мной повторить) неконтролируемая глобальная внешняя зависимость, имеющая ещё одну неконтролируемую внешнюю зависимость (я по node.js), и из возможностей которой используется откровенный мизер?

Результат проект FolderMonitor, написанный на Delphi (https://bitbucket.org/danik-ik/foldermonitor/src/master/). Собственно, именно он прописан как монитор по умолчанию в инсталляторе предыдущего проекта (да, я не только сделал из этого отдельный проект, но даже сдалал инсталлятор в виде пакетного файла, см. https://bitbucket.org/danik-ik/compilerc/src/master/README.md).

Инсталлятор проекта CompileRc работает с использованием git. Он создаёт ветку в репозитории проекта и добавляет туда необходимые модули и настроечные файлы. Вместо шифрования по умолчанию используется заглушка (копирование), что опзволяет добавлять ресурсы как есть. Вот скриншот репозитория тестового проекта в SmartGit, после добавления в него CompileRc (все три коммита ветки CompileRc, как и сама ветка, сформированы инсталлятором), запуска монитора и добавления нескольких файлов ресурсов (в рабочем дереве):

В основу монитора изменений был положен широко известный в пример, с некоторыми доработками по результатам эксплуатации. Во что он там обёрнут, можете глянуть в репозитории, если интересно, там кода всего ничего. Консольное приложение, в режиме ожидания управляется с клавиатуры (пуск/пауза/принудительный запуск).

Основа монитора:

(******************************************************************************  Ожидание (в отдельном потоке) изменений в папке, формирование  соответствующего события.  На основании широко известного примера:  https://webdelphi.ru/2011/08/monitoring-izmenenij-v-direktoriyax-i-fajlax-sredstvami-delphi-chast-1/  Проверяются события:  - изменение имени файла или папки  - изменение размера  - изменение времени последней записи ******************************************************************************)unit FolderMonitorCore;interfaceuses Classes, Windows, SysUtils;type  TFolderMonitorCore = class(TThread)    private      FDirectory: string;      FScanSubDirs: boolean;      FOnChange   : TNotifyEvent;      procedure DoChange;    public      constructor Create(ASuspended: boolean; ADirectory:string; AScanSubDirs: boolean);      property OnChange: TNotifyEvent read FOnChange write FOnChange;    protected      procedure Execute; override;  end;implementation{ TFolderMonitorCore }constructor TFolderMonitorCore.Create(ASuspended: boolean; ADirectory: string;  AScanSubDirs: boolean);begin  inherited Create(ASuspended);  FDirectory:=ADirectory;  FScanSubDirs:=AScanSubDirs;  FreeOnTerminate:=true;end;procedure TFolderMonitorCore.DoChange;begin  if Assigned(FOnChange) then    FOnChange(Self);end;procedure TFolderMonitorCore.Execute;var ChangeHandle: THandle;begin  // инициируем ожидание изменений, получаем соответствующий хэндл  ChangeHandle:=FindFirstChangeNotification(PChar(FDirectory),                                            FScanSubDirs,                                            FILE_NOTIFY_CHANGE_FILE_NAME+                                            FILE_NOTIFY_CHANGE_DIR_NAME+                                            FILE_NOTIFY_CHANGE_SIZE+                                            FILE_NOTIFY_CHANGE_LAST_WRITE                                            );  // Проверяем корректность инициации, иначе выбрасывается исключение{$WARNINGS OFF}  Win32Check(ChangeHandle <> INVALID_HANDLE_VALUE);{$WARNINGS ON}  try    // выполняем цикл пока не получим запрос на завершение    while not Terminated do    begin      { Важное отличие от оригинального примера: ожидание НЕ бесконечно,        периодически проверяется флаг выхода }      case WaitForSingleObject(ChangeHandle, 1000) of        WAIT_FAILED: Terminate; {Ошибка, завершаем поток}        WAIT_OBJECT_0: // Дождались изменений          begin            // Задержка страховка от повторной реакции на несколько изменений подряд            // в процессе единственного сохранения            // (при сохранении исходника в Sublime Text выскакивало стабильно).            // Проблема была следствием ключевого правила: если изменение происходит            // ПОСЛЕ запуска приложения, то по окончании приложение запускается повторно.            // Величина задержки подобрана пальцем в небо.            sleep(5);            WaitForSingleObject(ChangeHandle, 1); // изменение уже было, поэтому результат не проверяется            // Ещё одно отличие: завершение имеет приоритет перед событием изменения            if not Terminated then              Synchronize(DoChange);          end;        WAIT_TIMEOUT: {DO NOTHING}; // идём на следующий круг,                                    // либо завершаем по условию цикла       end;      FindNextChangeNotification(ChangeHandle);    end;  finally    FindCloseChangeNotification(ChangeHandle);  end;end;end.

Итог.

На сегодня работа в ресурсами выглядит следующим образом. Запросы хранятся в отдельных файлах и сгруппированы по папкам. Когда я собираюсь их править, запускаю монитор. Всю папку с исходниками открываю в редакторе (например, VS Code). Исправляю то, что надо, принудительно сохраняю (если полагаться на автосохранение при потере фокуса, и переключиться из редактора прямо в Delphi, то изменения не успеют дойти до rc-файла). В Delphi заранее и всегда открыт AutoGenerated.rc. Когда я переключаюсь в Delphi, дата rc-файла уже изменена монитором, и среда задаёт запрос на его перезагрузку. Любая последующая компиляция подхватывает произведённые изменения с первого раза. То есть, если упростить, то при запущенном мониторе: изменил исходный запрос (или что там в ресурсах лежит) сохранил переключился в Delphi Reload? Yes! запустил. При этом риск запуска с устаревшим вариантом ресурса практически отсутствует.

Ссылка на репозиторий проекта: https://bitbucket.org/danik-ik/compilerc/

На мой взгляд, всё это преодоление позволило вопреки всем ретронеудобствам поддерживать постоянный интерес к работе, качественно разбавляя рутину и не позволяя затосковать по причине отсутствия каких-нибудь модных перламутровых пуговиц. Больше скажу, когда подобных вызовов стало меньше, а рутинные операции многократно ускорились (в смысле трудозатрат), даже как будто чего-то не хватать стало. Наверное, мне просто сложно работать на всём готовом.

Так что если Вы вдруг оказались на должности штатного некроманта просто помните о том, что не боги горшки обжигают, и у Вас есть шансик сделать себе хорошо и нескучно.

P.S.

Если честно,хотел в первую очередь рассказать о параметризованных модульных тестах и об автоматизации их запуска, но вот вылезла именно эта тема, не отвертишься: пиши меня, и всё тут.

Подробнее..

Tarantool и кодогенерация на Lua

21.05.2021 14:10:59 | Автор: admin

Довольно часто на практике попадается класс задач, когда требуется обойти какую-то структуру или преобразовать формат данных из одного в другой. И в самом общем случае выполнение таких действий приводит к большому числу проверок и аллокаций памяти. Одним из примеров является парсинг JSON. При этом схема данных обычно задана, и мы ожидаем данные вполне определенного формата.

Мой рассказ будет посвящен кодогенерации подходу, который обычно позволяет ускорить работу алгоритма и избавиться от излишних проверок и аллокаций.

При работе с компилируемыми языками мы переносим часть работы из рантайма на стадию компиляции и получаем программу, которая способна очень быстро выполнять нашу специализированную задачу. Много материала про кодогенерацию уже написано. Сразу вспоминается язык Go, хотя это, конечно, не единственный пример в каждом языке возникают задачи, связанные с маршаллингом, парсингом и т.д. Я буду рассказывать про то, что ближе мне про использование кодогенерации при разработке приложений на Tarantool. При этом в отличие от компилируемых языков нам не потребуется производить перекомпиляцию кода в случае изменения каких-либо параметров всё будет происходить прямо в рантайме.

Немного про Tarantool и LuaJIT

Tarantool это платформа для in-memory вычислений флакон, объединяющий сервер приложений и базу данных. Сам Tarantool написан на языке С, но пользователь может работать с ним с помощью языка Lua. А если совсем точно, то одной из его реализаций LuaJIT не с просто интерпретатором, а ещё и с поддержкой и JIT-компиляции. И часто при работе возникают задачи по трансформации сущностей при записи в базу или после извлечения из неё, а также их валидации на соответствие схеме, заданной пользователем. Типичный подход для решения этой и схожих задач написание функций для преобразования данных. Эти функции не привязаны к конкретной схеме и зачастую представляют из себя набор замыканий. Однако не стоит забывать, что мы работаем с LuaJIT языком, который способен компилировать и достаточно быстро выполнять "горячие" участки кода.

Но, к сожалению, не всё подряд может быть скомпилировано, у платформы есть ряд ограничений это так называемые NYI (Not yet implemented) функции. Кроме того, работа с данными активно использует дополнительные структуры массивы и хэш-мапы. В Lua они представлены общим типом данных "table" (таблица). Перед нами две основные проблемы использование части функций серьезно влияет на производительность, а избыточное использование вспомогательных структур приводит к излишней нагрузке на GC, с которым у и Lua 5.1, и у LuaJIT проблемы. Поэтому задача написание кода, который сможет быть скомпилирован LuaJIT, и будет приводить к минимально возможному количеству аллокаций.

К реальным задачам

Данный подход мы будем разбирать на реальном примере, на примере модуля CRUD. Задача данного модуля это упрощение работы с шардированными данными. То есть данные распределены между несколькими стораджами (инстансами Tarantool, хранящими данные), и мы, обращаясь к ним через роутер (по сути, клиент), не хотим задумываться, на каком именно из стораджей лежат интересующие нас данные, а просто указываем условие поиска, и модуль возвращает нам уже готовые данные. Немного про хранение. Tarantool хранит данные в спейсах (spaces) аналог таблиц в реляционных БД. Единица хранения кортеж (tuple) массив заданных нами значений. При этом нам привычно работать именно с Lua-таблицами обращаться к полю по названию, а не по номеру в кортеже. В качестве аналогии можно привести формат JSON. Обычно именно в таком формате поступают данные из внешних систем которые затем парсятся в Lua-таблицы, "сплющиваются" и сохраняются в базу. Соответственно типичными для тарантула операциями являются так называемый "флаттенинг" (flatten) и "анфлаттенинг" (unflatten) получение из луа-таблицы плоского тапла и наоборот. И в частном случае пользователь может написать руками все эти операции.

-- Создаем space - аналог таблицы в реляционных БДbox.schema.space.create('data')-- Создаем первичный ключbox.space.data:create_index('primary_key')-- Попробуем вставить в наш space следующий объектobject = { id = 1, key = "key", value = "value" }-- Выполняем "сплющивание" объекта - flattentuple = {object["id"], object["key"], object["value"]}-- Единицей хранение в Tarantool является tuple - кортеж из значенийbox.space.data:insert(tuple)-- После сохранения мы можем достать наш объект по первичному ключуtuple = box.space.data:get({1})-- Преобразуем объект в исходное состояние - unflattenobject = {   id = tuple[1],   key = tuple[2],   value = tuple[3],}

Здесь мы явно захардкодили порядок полей в спейсе. Однако в общем случае схема задается извне некоторым форматом, и мы пишем простенькие функции, которые занимаются трансформацией объекта в соответствии с этим форматом. Модуль CRUD, как и сам Tarantool, имеет функцию replace она точно также вставляет кортеж в базу. Для упрощения жизни пользователям была также добавлена функция replace_object которая принимает объект, преобразует в плоский вид в соответствии с форматом спейса, а затем уже сохраняет.

Ближе к коду и измерению производительности

Перед тем, как перейти к демонстрации кода, давайте рассмотрим бенчмарк, с помощью которого мы будем оценивать производительность нашего кода. Состоять он будет из нескольких файлов. Входные данные:

-- test_data.lua-- Формат - 8 строковых полей + bucket_id-- (специальное поле, необходимое при шардировании данных).local format = {    {name = 'field1', type = 'string', is_nullable = false},    {name = 'field2', type = 'string', is_nullable = false},    {name = 'field3', type = 'string', is_nullable = false},    {name = 'field4', type = 'string', is_nullable = false},    {name = 'field5', type = 'string', is_nullable = false},    {name = 'field6', type = 'string', is_nullable = false},    {name = 'field7', type = 'string', is_nullable = false},    {name = 'field8', type = 'string', is_nullable = false},    {name = 'bucket_id', type = 'unsigned', is_nullable = false},}-- Объект необходимого форматаlocal data = {    field1 = 'string1',    field2 = 'string2',    field3 = 'string3',    field4 = 'string4',    field5 = 'string5',    field6 = 'string6',    field7 = 'string7',    field8 = 'string8',    bucket_id = nil,}return {    format = format,    data = data,}

Функция, замеряющая время выполнения нашего кода.

-- bench.lua-- Замеряем, сколько времени займет 1 миллион итерацийlocal clock = require('clock')local count = 1e6local function run(f, ...)    local start = clock.time()    for _ = 1, count do        f(...)    end    return clock.time() - startendreturn {    run = run,}

И входная точка, исполняемый файл, в который поочередно можно будет добавлять тесты:

#!/usr/bin/env tarantool-- init.lualocal bench = require('bench')local test_data = require('test_data')-- Это наш первый тестlocal naive = require('naive')local res = bench.run(naive.flatten, test_data.data, test_data.format, 1)print(string.format('Naive result: %0.3f s', res))-- После добавления нужного модуля, мы раскомментируем каждый фрагмент.-- local code_gen_v1 = require('code_gen_v1')-- local res = bench.run(code_gen_v1.flatten, test_data.data, test_data.format, 1)-- print(string.format('code_gen_v1 result: %0.3f s', res))-- local code_gen_v2 = require('code_gen_v2')-- local res = bench.run(code_gen_v2.flatten, test_data.data, test_data.format, 1)-- print(string.format('code_gen_v2 result: %0.3f s', res))

Давайте рассмотрим, как раньше выполнялось данное преобразование самый наивный подход:

-- naive.lualocal system_fields = { bucket_id = true }local function flatten(object, space_format, bucket_id)    if object == nil then return nil end    local tuple = {}    local fieldnames = {}    for fieldno, field_format in ipairs(space_format) do        local fieldname = field_format.name        local value = object[fieldname]        if not system_fields[fieldname] then            if not field_format.is_nullable and value == nil then                return nil, string.format("Field %q isn't nullable", fieldname)            end        end        if bucket_id ~= nil and fieldname == 'bucket_id' then            value = bucket_id        end        tuple[fieldno] = value        fieldnames[fieldname] = true    end    for fieldname in pairs(object) do        if not fieldnames[fieldname] then            return nil, string.format("Unknown field %q is specified", fieldname)        end    end    return tupleendreturn {    flatten = flatten,}

Пример слегка упрощен. Но стоит заметить несколько вещей:

  • При каждом запросе мы пересоздаем таблицу "fieldnames", содержащую поля формата. Необходима для проверки, что наш объект не содержит лишних полей (и без кодогенерации её следовало бы как-то закэшировать).

  • Обходим весь объект в соответствии с форматом. При этом формат нам известен и меняется достаточно редко. Это одна из предпосылок для использования кодогенерации.

Запускаем:

  tarantool init.lua Naive result: 1.109 s

На моём ноутбуке этот тест выполнился за 1 секунду.

Теперь попробуем написать функцию, которая развернет нам цикл. Объект же продолжит обрабатываться в соответствии с форматом.

-- code_gen_v1.lua-- Небольшой хелпер для работы со строкамиlocal function append(lines, s, ...)    table.insert(lines, string.format(s, ...))end-- Кэш, где ключ - таблица с "форматом", а значение - функция флаттенинга.-- Для простоты считаем, что формат не меняется, не занимаемся инвалидацией кэша.local cache = {}local function flatten(object, space_format, bucket_id)    -- В случае если функция уже сгенерирована,    -- берем её из кэша. Иначе приступаем к кодогенерации.    local fun = cache[space_format]    if fun ~= nil then        return fun(object, bucket_id)    end    -- Будем "готовить" наш код построчно и сохранять в массив lines.    local lines = {}    append(lines, 'local object, bucket_id = ...')    append(lines, 'local result = {}')    for i, field in ipairs(space_format) do        if field.name ~= 'bucket_id' then            append(lines, 'result[%d] = object[%q]', i, field.name)        else            append(lines, 'result[%d] = bucket_id', i)        end    end    append(lines, 'return result')    -- Конкатенируем элементы массива, чтобы получить полный текст функции.    local code = table.concat(lines, '\n')        -- Раскомментриуйте, чтобы увидеть результат    -- print(code)        -- С помощью функции "load" преобразуем текст функции в саму функцию    fun = assert(load(code))    cache[space_format] = fun    return fun(object, bucket_id)endreturn {    flatten = flatten,}

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

local object, bucket_id = ...local result = {}result[1] = object["field1"]result[2] = object["field2"]result[3] = object["field3"]result[4] = object["field4"]result[5] = object["field5"]result[6] = object["field6"]result[7] = object["field7"]result[8] = object["field8"]result[9] = bucket_idreturn result

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

Что еще мы можем улучшить? В самом начале сгенерированного кода мы создаем таблицу result и постепенно её заполняем. Такой подход приводит к неоднократным реаллокациям, что плохо и довольно бессмысленно ведь размер таблицы известен заранее. Давайте учтём это и поменяем строку append(lines, 'local result = {}') на append(lines, 'local result = {%s}', string.rep('box.NULL,', #space_format)). Так мы сразу создадим массив нужного нам размера local result = {box.NULL, ..., box.NULL}. Запуск бенчмарка выдает 0.2 секунды.

Давайте попробуем улучшить код так, чтобы сгенерированный код проходил тесты модуля CRUD. Для этого нам не хватает валидации.

-- code_gen_v2.lualocal function append(lines, s, ...)    table.insert(lines, string.format(s, ...))endlocal cache = setmetatable({}, {__mode = 'k'})local function flatten(object, space_format, bucket_id)    local fun = cache[space_format]    if fun ~= nil then        return fun(object, bucket_id)    end    local lines = {}    append(lines, 'local object, bucket_id = ...')    append(lines, 'for k in pairs(object) do')    append(lines, '    if fieldmap[k] == nil then')    append(lines, '        return nil, format(\'Unknown field %%q is specified\', k)')    append(lines, '    end')    append(lines, 'end')    local len = #space_format    append(lines, 'local result = {%s}', string.rep('NULL,', len))    local fieldmap = {}    for i, field in ipairs(space_format) do        fieldmap[field.name] = true        if field.name ~= 'bucket_id' then            if field.is_nullable ~= true then                append(lines, 'if object[%q] == nil then', field.name)                append(lines, '    return nil, \'Field %q isn\\\'t nullable\'', field.name)                append(lines, 'end')            end            append(lines, 'result[%d] = object[%q]', i, field.name)        else            append(lines, 'if bucket_id ~= nil then')            append(lines, '    result[%d] = bucket_id', i, field.name)            append(lines, 'else')            append(lines, '    result[%d] = object[%q]', i, field.name)            append(lines, 'end')        end    end    append(lines, 'return result')    local code = table.concat(lines, '\n')    local env = {        pairs = pairs,        format = string.format,        fieldmap = fieldmap,        NULL = box.NULL,    }    fun = assert(load(code, '@flatten', 't', env))    cache[space_format] = fun    return fun(object, bucket_id)endreturn {    flatten = flatten,}

Данный код добавляет те же самые проверки, что были и в исходной функции. Кроме этого, я добавил отступы, чтобы такой код было легче читать и отлаживать, хотя интерпретатор их никак не учитывает.

Бенчмарк показал 0.3 секунды.

  tarantool init.lua Naive result: 1.109 scode_gen_v1 result: 0.210 scode_gen_v2 result: 0.299 s

Также стоит отметить, что у функции load появились дополнительные аргументы, а именно chunkname название нашей функции (может быть полезным при отладке), mode t мы создаем функцию на основе обычного текста, а не байткода и env окружение, доступное внутри нашей функции. На последний аргумент стоит обратить особое внимание. Кроме возможности создавать удобные песочницы для выполнения пользовательского кода (обычно не давать доступа к "опасным" функциям), данная опция позволяет передавать в глобальное окружение нужные нам функции и аргументы. В нашем случае это pairs, format, fieldmap и NULL. Отдельно стоит отметить, что load это функция из Lua 5.2 расширение LuaJIT. Тот, кто работает с чистым Lua 5.1, может использовать функции loadstring для создания функции и setfenv для установки окружения у этой функции.

Не обязательно использовать кодогенерацию для каждой функции. Например, если бы я бы захотел добавить ещё и валидацию типов полей, то мне бы не потребовалось генерировать функции для проверки (is_number, is_string, ...) их достаточно просто передать через окружение.

Небольшой пример:

local function is_string(value)    return type(value) == 'string'end-- Функции is_string нет в языке Lua,-- но с помощью окружения мы можем добавить в нужные нам функции-- и убрать лишние.local code = [[local value = ...local result = {NULL}if not is_string(value) then    error("value is not a string")endresult[1] = valuereturn result]]local fun = load(code, '@test', 't', {    error = error,    -- Функция is_string будет доступна внутри    -- загружаемого нами кода    is_string = is_string,    NULL = box.NULL,})

Как в будущем всё не сломать

Возникает вопрос, как убедиться в том, что мы генерируем правильный и оптимальный код? При том, что он может занимать несколько сотен или тысяч строк, которые ещё и не особо приспособлены для прочтения человеком. Самый простой и очевидный способ тестирование методом черного ящика: есть набор входных параметров и набор ожидаемых результатов так мы обычно тестируем свой код. Однако из-за ошибки или невнимательности мы легко сможем пропустить ситуацию, когда код проходит такие тесты, но при этом сгенерирован не оптимально какие-то фрагменты кода дублируются, и это не приводит к неверному результату, а просто снижает производительность.

Для того чтобы решить данную проблему, стоит писать проверку сгенерированного текста. Вы сможете один раз проверить корректность вашего кода, а затем смотреть, как именно влияют вносимые вами изменения в генерируемый код. На моей практике применение данного подхода не раз помогало находить проблемы.

Более качественная оценка результатов

В статье я привел не так много результатов тестирования просто сравнил несколько цифр: время выполнения программы до и после. При этом я говорил, что мы уменьшили количество аллокаций и написали код, пригодный для компиляции LuaJIT'ом. Но как это можно проверить, как в этом можно убедиться?

Не хочется превращать статью в гайд о том, как профилировать код на Tarantool. Но всё-таки мы слегка затронем эту тему.

Во-первых, это memory profiler, который появился в версии 2.7.1 инструмент, который покажет в каких именно местах и в каких количествах выделяется/реаллоцируется память. Как по мне, вывод довольно удобен а в будущем станет ещё удобнее. Воспользовавшись этим инструментом, можно показать количественную разницу между кодом до и кодом после. В нашем случае мы получили бы вывод в формате @<filename>:<function_line>, line <line where event was detected>: <number of events> <allocated> <freed>. Для наглядности напротив некоторых строк я помещу фрагменты кода, которые находятся на этих строках:

Для кода "до" (naive.lua):

ALLOCATIONSINTERNAL: 39999533600003800@../naive.lua:4, line 26: 10000383840039360           // fieldnames[fieldname] = true@../naive.lua:4, line 7: 1000000640000000           // local tuple = {}@../naive.lua:4, line 9: 1000000640000000           // local fieldnames = {}@../naive.lua:4, line 25: 163840@../naive.lua:4, line 0: 46720REALLOCATIONSINTERNAL: 199998211200056064000288Overrides:@../naive.lua:4, line 0@../naive.lua:4, line 25INTERNAL@../naive.lua:4, line 25: 100002213600123272000704Overrides:@../naive.lua:4, line 25INTERNALDEALLOCATIONSINTERNAL: 59535720784628243Overrides:@../naive.lua:4, line 0@../naive.lua:4, line 25@../naive.lua:4, line 26@../naive.lua:4, line 7@../naive.lua:4, line 9INTERNAL@../naive.lua:4, line 26: 10000220192001584Overrides:@../naive.lua:4, line 26INTERNAL

Для кода "после" (code_gen_v2.lua):

ALLOCATIONS@flatten:0, line 7: 10000001440000000 // local result = {NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,}@../code_gen_v3.lua:7, line 55: 1480REALLOCATIONSINTERNAL: 519843968Overrides:INTERNALDEALLOCATIONSINTERNAL: 9742980140298062Overrides:@flatten:0, line 7

Во-вторых, сам LuaJIT поставляется с профилировщиком require('jit.p')

Для кода "до":

52%  ../naive.lua:11  // for fieldno, field_format in ipairs(space_format) do30%  ../naive.lua:26  // fieldnames[fieldname] = true12%  ../naive.lua:9   // local fieldnames = {}

Для кода "после":

36%  flatten:3  // if fieldmap[k] == nil then36%  flatten:7  // local result = {NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,}11%  ../code_gen_v3.lua:9 // выбираем значение из кэша 4%  flatten:39 4%  flatten:2 4%  ../code_gen_v3.lua:8 4%  flatten:8 4%  ../code_gen_v3.lua:10

А также для тех, кто хочет копнуть совсем глубоко, есть возможность дампа байткода, который LuaJIT генерирует и выполняет require('jit.dump')

Заключение

Мы рассмотрели применение кодогенерации при разработке на Tarantool. Это позволило достаточно просто ускорить в 3 раза один из участков кода в реальном проекте патч был принят. При разработке не стоит забывать о специфике платформы. По возможности стоит генерировать код, который будет приводить к выделению минимально возможного количества памяти, а также не использовать медленные функции в нашем случае те, которые не компилируются LuaJIT. Также советую обратить внимание на то, что в проекте CRUD и до этого использовалась кодогенерация. C её помощью создаются быстрые функции для проверки соответствия тапла пользовательским условиям.

Уверен, если вы ещё не используете кодогенерацию в своем проекте, найдутся несколько мест, которые могут быть ускорены применением данного подхода вне зависимости от языка программирования.

Подробнее..

Используем Xtend для прикладной кодогенерации сеанс чёрной магии с разоблачением

25.06.2020 10:15:49 | Автор: admin

Привет Хабр! Меня зовут Когунь Андрей. В КРОК я руковожу группой разработчиков Java (у нас большая распределённая по всей стране команда). Ещё я провожу встречи московского сообщества Java разработчиков JUG.MSK. Делаю это исключительно в корыстных целях: фотографируюсь там со всеми докладчиками, и однажды открою галерею с самыми интересными людьми в мире Java-разработки. Также помогаю делать конференции для разработчиков: JPoint, Joker и DevOops в качестве члена программного комитета. Ну и для души, так сказать, преподаю Java-технологии студентам.


В КРОК мы с коллегами в основном занимаемся заказной разработкой. Одно из наших направлений так называемые учётные системы. Их надо делать по возможности быстро. Они типовые, различия обычно наблюдаются только в доменной модели. Поэтому мы постоянно боремся за то, чтобы писать меньше бойлерплейт-кода, будь то тривиальные геттеры-сеттеры, конструкторы и т.п. или CRUD-репозитории и контроллеры. Мы для этого активно пользуем кодогенерацию.


Про неё сейчас и расскажу: покажу как взять кодогенерацию под контроль, чтобы она стала реально полезным инструментом разработчика. Попутно расскажу про технологию Xtend и покажу практические приёмы как с ней можно работать. Покажу как создавать собственные активные аннотации, писать и отлаживать код процессора аннотаций.



О чём статья и чего в статье не будет


За много лет работы с Java мы перепробовали много чего интересного:


  • поддержка генерации в IDE,
  • генерация байт-кода при помощи Lombok,
  • процессоры аннотаций, порождающие новый код,
  • фреймворки, позволяющие по описанию модели получить готовое (почти) приложение,
  • и много чего ещё, в том числе новые и не очень JVM-языки, которые позволяют писать более лаконичный код и реализовывать DSL для решения прикладных задач.

В какой-то момент, проанализировав сильные и слабые стороны всех этих подходов, их ограничения и практическую применимость, мы пришли к тому, что в нашем собственном фреймворке для быстрой разработки (jXFW) будем использовать Xtend. Использовать для кодогенерации исходного Java-кода по доменной модели и для аккумулирования того опыта, который мы накопили в работе с различными технологиями. Сейчас расскажу, как в jXFW это всё работает и покажу, как вы можете сделать то же самое для своих нужд. Причём первую версию вы сможете изготовить буквально за пару дней и дальше начать применять подход know-how как код.


Рассказывать буду на примере упрощённого демо-проекта, который был реализован в рамках доклада на JPoint.


Ремарка: чего в статье не будет:


  1. Я не хочу, чтобы мы в итоге делали выводы про то, что технология А лучше технологи Б. Или что там Eclipse лучше IDEA или наоборот. Поэтому я не буду напрямую сравнивать какие-то языки, технологии. Всё что упоминаю, это лишь для того, чтобы какую-то аналогию объяснить на понятных примерах.
  2. Я не буду делать введение в Spring и Spring Boot. Исхожу из того, что вы имеете хотя бы какой-то опыт работы с этими технологиями. Мне кажется, сейчас сложно найти джависта, который не работал с ними. Но если вы вдруг слышите о Spring и Spring Boot впервые, вам срочно надо посмотреть доклады и тренинги Евгения Борисова и Кирилла Толкачева, там мои коллеги рассказали об этих технологиях очень подробно.
  3. Не буду очень сильно погружаться в Xtend. Но поскольку, как показывает мой опыт выступления на Java-конференциях, эта технология мало кем используется, сделаю небольшой ликбез. Чтобы вы уже дальше могли для себя решить, нужен вам Xtend или нет.

Короткий ликбез по Xtend


Xtend это статически типизированный язык программирования, приемник Xpand, построенный с использованием Xtext и компилирующийся в исходный код Java. Технология Xtext нужна для того, чтобы реализовывать всевозможные DSL. По сути, Xtend это такой своеобразный DSL.


Xtend совсем не новый язык программирования. Его создали ещё в 2011, примерно тогда же, когда появлялось большинство JVM-языков. Интересно, что у Xtend был слоган: Java 10 сегодня! Да, сегодня Java 10 у нас уже есть, слоган морально устарел. Но, похоже, люди что-то знали про Java, когда создавали Xtend, и некоторые фичи, заложенные в Xtend, они вот как раз прямо в Java 10 и появились. В частности, вывод типа локальной переменной (var). Но есть в Xtend и такие фичи, которых у Java пока ещё нет:


  • активные аннотации,
  • шаблонные выражения,
  • Switch Expressions.

Как работает кодогенератор в jXFW


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


Запускаю Eclipse.



Как видите, здесь практически ничего нет. Только application.java (конфигурация для Spring Boot) и собственно исходник на Xtend, в нём реализована доменная модель.



Как видите, Xtend-исходник очень похож на Java. Здесь нет ничего особенного. Просто класс с полями и несколько аннотаций. А что в итоге? jXFW генерирует два приложения (см. рисунок ниже): одно выполняется на сервере (тот самый Spring Boot) и даёт нам апишечку, а другое на клиенте.



Если мы что-нибудь введём в клиентской части (например, как зовут спикера) и сохраним...



то получим соответствующую запись и на клиенте, и на сервере.



То есть всё по-честному.


Мы просто описали одну сущность доменной модели, и всё автоматически заработало.


Что за магия здесь под капотом? И как в ней замешан Xtend? Рассказываю. У нас есть класс, на нём проставлены аннотации, вернее активные аннотации. Вся магия скрывается в них. Аннотации в Xtend очень похожи на аннотации в Java. Просто в Xtend для них есть отдельное ключевое слово:annotation.



Активной аннотация становятся, если её, в свою очередь, пометить другой аннотацией: @Active, а в ней указать класс процессора, который активируется, когда эта аннотация поставлена над каким-то элементом.



Дальше всё как обычно.


Xtend из коробки имеет некоторое количество таких аннотаций.



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


Если вдруг вам ещё пока не понятно, о чём тут идёт речь, то теперь я буду рассказывать про кодогенерацию медленно и подробно. Результат, как уже сказал, доступен тут.


Как активные аннотации помогают писать меньше кода


Открываем проект jp-boot-xtend-demo. Я его получил при помощи Spring Initializr.



Дополнительных зависимостей здесь практически нет (см. файл pom.xml). Есть только spring-boot-starter-data-jpa и spring-boot-starter-data-rest. Плюс, подключен модуль jp-boot-xtend-demo-compile, в котором реализована наша активная аннотация. Если вам доводилось работать с процессорами аннотаций, вы наверно в курсе, что сам процессор определяется в отдельном модуле. Xtend в этом смысле не исключение.


И уже здесь, в jp-boot-xtend-demo-compile (см. файл pom.xml), мы подключаем все Xtend-зависимости, которые нам нужны: org.eclipse.xtend.lib, org.eclipse.xtend.lib.macro. Плюс, подключаем плагин xtend-maven-plugin. На случай если захотим тестировать наш Xtend-код, нам понадобится ещё несколько зависимостей: org.eclipse.xtend.core, org.eclipse.xtext.testing, org.eclipse.xtext.xbase.testing.


Кроме того, в Eclipse, я соответственно подключил плагин, который называется Xtend IDE. Актуальная инструкция как установить плагин тут. Ещё один вариант: сразу взять дистрибутив, в котором этот плагин предустановлен Eclipse for Java and DSL Developers.


Давайте смотреть как тут всё работает. Как и в случае с jXFW здесь есть приложение (см. файл DemoApplication.java), а также Java-класс, который будет нашей Entity, на базе которой мы будем всё строить (см. файл Country.xtend).



При необходимости мы можем сразу посмотреть на то как выглядит Java-файл, сгенерированный из этого Xtend-исходника. Он нам сразу же доступен, и мы можем им пользоваться во всём остальном коде.



Например, в нашем DemoApplication есть кусок кода, который пытается вызывать метод setName. Но пока он красненький.



Я добавляю в Xtend-исходник активную аннотацию @Accessors, и у меня в сгенерированном Java-коде автоматически появляются геттеры и сеттеры, в том числе setName.



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



Тут я ещё вписал в Xtend-файл аннотации @ToString и @EqualsHashCode, и в итоге получил Java-исходник прямо такой, как и хотел.


Небольшой лайфхак, который избавит вас от необходимости после каждой правки Xtend-исходника отыскивать в target сгенерированный Java-файл. В Eclipse есть специальная оснастка: Generated Code. Что она делает? Встаньте на любую строчку в Xtend-исходнике, и увидите в окне Generated Code Java-код, который для неё сгенерирован. А оттуда при необходимости уже можете пойти непосредственно в Java-исходник. Вот такая удобная штука.


Самый маленький кодогенератор на основе аннотаций


В принципе, всё хорошо работает. Но как только мы начинаем работать с кодогенерацией, тут же возникает вопрос: А можно такой же, но только с перламутровыми пуговицами? Так Что бы я ещё хотел? Я бы хотел наверно, чтобы у меня сеттеры мои вызывались в цепочке т.е. не просто устанавливалось значение, но ещё, чтобы и сам объект возвращался из этого сеттера, и я мог на нём следующий позвать.


Из коробки в Xtend такой аннотации нет. Поэтому нам придётся её делать ручками. И какие тут есть варианты?


В принципе, мы знаем, что существует аннотация @Accessors мы посмотрим на её исходный код, увидим, что там есть Accessors Processor, специально написанный. И вот мы уже смотрим на Xtend-код и пытаемся понять, а в каком месте мы могли бы здесь что-то подкрутить, чтобы у нас работало так, как надо. Но это не очень продуктивный путь. Мы по нему не пойдём.


Мы будем писать полностью новую аннотацию. И вот почему. Дело в том, что в активных аннотациях, которые применяются в Xtend, есть возможность привязаться к конкретной фазе компиляции. Т.е. в тот момент, когда AST у нас уже есть, а исходных файлов ещё нет мы можем как угодно этот наш AST менять. И это очень удобно.


Соответственно, вот эта наша аннотация (это я уже зашёл в проект jp-boot-xtend-demo-compile; см. файл EntityProcessor.xtend) @Active она нам говорит про те самые четыре фазы, к которым мы можем привязываться. На каждой фазе работает свой собственный Participant-вариант, и мы можем реализовать тот, который нам нужен.


В принципе, есть базовый специальный класс AbstractClassProcessor, в котором мы оверрайдим метод doTransform. В него нам будут приходить соответственно описания наших классов, помеченных этой аннотацией. И дальше мы, соответственно, что-то делаем.


Ну и вот весь код, собственно говоря, который нам нужно написать, чтобы наши сеттеры стали правильными такими, как мы хотим.



Мне кажется, это самый маленький по объёму код для генерации при помощи аннотаций который я видел в жизни.


Что здесь делает Xtend? У него есть вот эти самые шаблонные выражения. Мы ставим три одинарные кавычки, и дальше пишем то, что хотим получить на выходе. И при этом форматируем так, как нам удобно.



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


Код написан на Xtend. Мне кажется читать его, с одной стороны легко, потому что его мало. С другой стороны, он может мне быть понятен сходу. Например, почему мы позвали метод modifySetter, который я определил чуть ниже, и передали в него всего один аргумент?


Дело в том, что в Xtend есть такая вещь как Extension-методы. И у объекта того типа, которым является первый аргумент, можно этот Extension-метод позвать. Хорошо, а почему мы тогда его здесь не указали? Да потому что мы внутри лямбды, а в ней есть переменная it. Когда у нас есть переменная it, к лямбде можно обращаться, не указывая её. То же самое вот с it, который мы указали в качестве аргумента. Поэтому declaredFields-property у MutableClassDeclaration мы зовём напрямую, безо всяких префиксов.


Это вот всё, что в принципе придётся знать про Xtend.


А можно такой же, но только с перламутровыми пуговицами?


Давайте теперь посмотрим как это работает. Я определяю аннотацию @Entity. Затем иду вот в этот наш класс.



Заменяю текущую @Entity с javax.persistence на свою на активную аннотацию.



И вот теперь сеттер у нас такой как надо. Т.е. из Country возвращается this мы возвращаемое значение поменяли с void на тип объекта, над которым стоит аннотация: @Id Long id.


Но, допустим, я хочу, чтобы айдишник сеттился немножко по-другому (всё к той же идее хочу такое же, но с перламутровыми пуговицами). Тогда я вписываю в свой класс setID. И оно даже отчасти работает. Ведь сеттер появился в нужном месте сразу после id.



Вот только Eclipse мне здесь немножко подсвечивает и возмущается: ты мне, вообще, о чём здесь говоришь?



И хотя в том коде джавовом, который получился, ошибки нет, он скомпилируется и всё будет работать в коде есть проблема. Она заключается в том, что мы подменяем тело метода, который определили в Xtend-исходнике.


Поэтому нам надо внимательно следить за тем, чтобы таких казусов не возникало, когда пишем что-то на Xtend. Как такое отследить? Например, можно у того Transformation-контекста, который сюда приходит, прописать метод isThePrimaryGeneratedJavaElement, и соответственно передать туда сеттер. Получается прямо в таком же стиле, как мы обычно пишем на Java.



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



Теперь всё работает как надо. Ошибки компиляции больше нет, а сеттерный айдишник стал такой как я и хотел.



Насколько это сложно прикручивать новые улучшения? Не увеличивают ли они сложность кода?


Давайте на примере посмотрим. Допустим, мы хотим ещё кое-что улучшить: добавить специальный филд, который можно будет проставлять в сеттере, и который будет признаком того, что наш объект изменился.



Не знаю, для чего в жизни это может пригодиться вам, но лично мне в работе такая штука нужна. Что мы тут указываем? Мы здесь указали имя филда. И дальше опять вот эта наша квадратная скобочка открываем лямбду; здесь дальше соответственно указываем, что нас интересует. Причём, нам важно, чтобы поле было транзиентное.


И теперь, вот здесь в сеттере тоже важно, чтобы такое поведение было на все поля, кроме транзиентных. Потому что когда мы меняем транзиентное поле, мы в общем-то не ожидаем, что наш объект будет dirty ведь эти значения не попадают в хранилище.



Давайте посмотрим, насколько это нам помогло.



Да, всё хорошо! Причём dirty написано ровно в том месте, где и должно. Нет никаких выкрутасов с отступами и т.д. Код выглядит хорошо и там, и там. Несмотря на то, что получился в результате кодогенерации. Плюс, как видите, код всё ещё остался простым для понимания.


Пишем процессор на смешанном диалекте Xtend и Java


@Entity больше мучить не будем. Убираю её в комментарии. И объявляю ту же самую аннотацию, но на Java (см. файл Entity.java). Здесь, как и на Xtend, всё просто, только чуть больше букв.



Процессор тоже можно писать на Java (см. файл JavaEntityProcessor.java).



Что я тут сделал? Я добавил обработчик для ещё одной фазы: doRegisterGlobals и докинул в контекст классы, которые мне понадобятся: Service и Repository. Плюс, заоверрайдил метод doTransform тот самый doTransform, который написал чуть раньше на Xtend. Причём я тут нормально навигируюсь по коду. Могу попадать в Xtend-код



и обратно в Java-код.



Дальше (см. метод doTransform) я добавляю к нашей entity аннотацию. Обратите внимание, здесь, в отличие от Xtend все методы надо вызывать явно через context.


Затем идёт метод, который создаёт репозиторий: createRepository. Важный момент: для всего того что мы генерируем, важно указывать PrimarySource: context.setPrimarySourceElement(repositoryType, entity);. Зачем? Чтобы при кодогенерации, когда у нас появляется Java-файл, он был связан со своим Xtend-источником.


Дальше немного скучного кода: пользую типы из Spring Data, чтобы указать какой у репозитория должен быть интерфейс.



Дальше прохожусь по всем филдам, и выбирай из них те, которые не транзиентные и не являются айдишниками.



И смотрите, здесь видно, что лямбды в Java очень хорошо дружат с лямбдами в Xtend. Одно на другое взаимозаменяется. Т.е. функциональные интерфейсы все здесь работают. И API был задизайнен так, что сюда джавововые лямбды нормально встают.


Дальше добавляем к нашим филдам всякие разные findBy-методы. Причём смотрим на аннотацию @Column, которая стоит над филдом. Если она имеет установленный атрибут признака уникальности значения (isUnique), просто возвращаем entityType. Если нет, возвращаем List. В конце ставим аннотацию @Param, которая нужна для того чтобы работал Spring Data Rest.



Всё! Для Repository генератор готов. Теперь если откроем Xtend-исходник, на основе которого будет генерироваться Java-код, и посмотрим на Gentrated Code, то здесь у нас добавился ещё и репозиторий. Мы можем смотреть на него, вот он такой.



Дальше пишем генератор для Service. Там всё почти всё так же как и с Repository.



Вот и всё. Процессор готов. Можно запускать сгенерированное приложение.


Ещё несколько улучшений, и запускаем сгенерированное приложение


Хорошо, сервис и репозиторий есть. Но как нам узнать, что у нас с моделью нашей всё хорошо? Добавим ещё одну фазу фазу валидации. Я добавляю два валидатора.



Теперь, если разработчик, который пишет Extend-код, вдруг забудет поставить перед своим классом аннотацию @ToString, валидатор выведет на экран Warning.



Или если разработчик поставит аннотацию @ManyToOne, а под ней ещё и @Column, то это уже ошибка. А ошибиться-то очень легко. Мы же программируем очень часто на копи-пасте, особенно когда есть возможность всё в один и тот же файл писать, как в Xtend. Скопировали, вроде работает успокоились. Но можно нарваться на коварную ошибку.


Допустим, у меня в Country.xtend у филда lastName прописано nullable = false, и я хочу, чтобы у Country тоже было nullable = false. Так неправильно. Поэтому Eclipse предупреждает меня. Но при этом генерируется Java код, в котором вроде как нет проблем.



Я меняю на @JoinColumn(nullable = false), и теперь всё хорошо. Можно запускать приложение.



Давайте наберём в браузере localhost:8080



затем localhost:8080/users/search.



Все наши findBy на месте. Приложение работает!


Пишите меньше кода, делайте меньше ошибок, применяйте технологии правильно


Ну вот и всё. Теперь вы тоже можете брать кодогенерацию под контроль, эффективно использовать её в своей работе. То есть проводить время с пользой: пару дней потерять на то, чтобы создать кодогенаратор, а потом за 5 минут долететь. Будете писать меньше кода, будете делать меньше ошибок.


Вы теперь умеете создавать собственные активные аннотации, писать и отлаживать код процессора. Причём делать всё это на смешанном диалекте Java и Xtend, без необходимости переносить всю свою кодовую базу на Xtend.


Демо-проект, который мы с вами прямо в этой статье сейчас разработали, я заопенсорсил на гитхабе. Скачивайте, изучайте, пользуйте. А если информацию легче воспринимаете на слух и с видео, вот мой доклад с конференции JPoint, где рассказываю всё то же самое, что и здесь в статье.
У меня всё. Пишите меньше скучного кода, делайте меньше ошибок, применяйте технологии осознанно. Буду рад ответить на ваши вопросы. Можете писать мне на akogun@croc.ru. Кстати, помните, я в начале статьи говорил, что участвую в подготовке конференций для джавистов? JPoint 2020 из-за известных причин будет проходить онлайн, но это даже совсем неплохо, у нас много отличных спикеров, которые не смогли бы приехать и выступить очно, а сама конференция будет идти целых 5 дней! С 29 июня по 3 июля jpoint.ru. Приходите!

Подробнее..

Оживляем деревья выражений кодогенерацией

02.01.2021 00:07:07 | Автор: admin

Деревья выражений System.Linq.Expressions дают возможность выразить намерения не только самим кодом, но и его структурой, синтаксисом.

Их создание из лямбда-выражений это, по сути, синтаксический сахар, при котором пишется обычный код, а компилятор строит из него синтаксическое дерево (AST), которое в том числе включает ссылки на объекты в памяти, захватывает переменные. Это позволяет манипулировать не только данными, но и кодом, в контексте которого они используются: переписывать, дополнять, пересылать, а уже потом компилировать и выполнять.

Run-time компиляция порождает производительные делегаты, которые часто быстрее тех, что компилируются во время сборки (за счет меньшего оверхеда). Однако сама компиляция происходит до десятков тысяч раз дольше, чем вызов результата компиляции.

(бенчмарк)

Действие

Время, нс

Cached Compile Invoke

0.5895 0.0132 ns

Compile and Invoke

83,292.3139 922.4315 ns

Это особенно обидно, когда выражение простое, например содержит только доступ к свойству (в библиотеках для маппинга, сериализации, дата-байндинга), вызову конструктора или метода (для IoC/DI решений).

Скомпилированные делегаты обычно кэшируют, чтобы переиспользовать, но это не спасает в сценариях, когда первый доступ происходит к большому количеству за раз. В таких случаях время run-time компиляции выражений становится значимым и оттягивает запуск приложения или отдельных окон.

Для уменьшения времени получения делегатов из деревьев выражений используют:

  • Встроенную интерпретацию.
    Необходимость использования интерпретатора вместо компилятора указывается соответствующим флагом:

    Expression.Compile(preferInterpretation: true)
    

    Происходит через рефлексию, но с накладными расходами на формирование стека инструкций.

    Для платформ Xamarin.iOS, Xamarin.watchOS, Xamarin.tvOS, Mono.PS4 и Mono.XBox стандартная компиляция через генерацию IL (System.Reflection.Emit) долгое время была недоступна и на данный момент под капотом всегда откатывается к этому варианту.

  • FastExpressionCompile от @dadhi.
    Ускоряет компиляцию за счет оптимизиpованной генерации IL и с меньшим количеством проверок совместимости.

    На платформах без поддержки JIT компиляции может использоваться только с включенным Mono Interpreter.

  • Ручную интерпретацию.
    Используется для оптимизации вызовов рефлексии под специальные сценарии использования, например для добавления кэширования отдельных вызовов.

    Интерпретируя вручную, уже можно воспользоваться способами ускорения рефлексии. Самые эффективные из них, например Fasterflect, используют System.Reflection.Emit и на некоторых платформах так же могут требовать включения Mono Interpreter.

Для случаев, когда производительности указанных методов недостаточно, напрашивается решение:

Компилировать выражения или какие-то их части во время написания кода (design-time) или сборки (compile-time).

Для compile-time компиляции делегатов к фрагментам деревьев выражений требуется сгенерировать соответствующий код.

API доступа к делегатам не стоит генерировать вместе с ними, лучше держать его в отдельной сборке. Причина заключается в том, что анализ и использование деревьев выражений часто происходит в проектах, отдельных от их инициализации. Фреймворки для дата-байндинга и DI это сторонние библиотеки, да и сами программы часто разбиваются на несколько сборок из соображений архитектуры.

От самого API требуется только давать нужный делегат по ключу, как в словаре. У интересующих нас фрагментов кода: методов, конструкторов и свойств на стыке run-time и compile-time естественный идентификатор это сигнатура. По ней генерируемый код будет класть делегаты в словарь, а клиенты забирать.

Например, для класса со свойством

namespace Namespace{  public class TestClass  {    public int Property { get; set; }  }}

используемым внутри System.Linq.Expressions.Expression<T> лямбды

Expression<Func<TestClass, int>> expression = o => o.Property;

делегатами чтения и записи в общем виде являются

Func<object, object> _ = obj => ((Namespace.TestClass)obj).Property;Action<object, object> _ => (t, m) => ((Namespace.TestClass)t).Property  = (System.Int32)m;

и генерируемый код для их регистрации будет примерно таким:

namespace ExpressionDelegates.AccessorRegistration{  public static class ModuleInitializer  {    public static void Initialize()    {      ExpressionDelegates.Accessors.Add("Namespace.TestClass.Property",        getter: obj => ((Namespace.TestClass)obj).Property,        setter: (t, m) => ((Namespace.TestClass)t).Property = (System.Int32)m);    }  }}

Генерация

Наиболее известные решения для кодогенерации, на мой взгляд, это:

Отдельная область применения есть у каждого решения, и только Roslyn Source Generators умеет анализировать исходный C# код даже в процессе его набора.

Кроме того, именно Roslyn Source Generators видятся более или менее стандартом для кодогенерации, т. к. были представлены как фича основного компилятора языка и используют Roslyn API, используемый в анализаторах и code-fix.

Принцип работы Roslyn Source Generators описан в дизайн-документе (местами не актуален!) и гайде.

Вкратце: для создания генератора требуется создать реализацию интерфейса

namespace Microsoft.CodeAnalysis{  public interface ISourceGenerator  {    void Initialize(GeneratorInitializationContext context);    void Execute(GeneratorExecutionContext context);  }}

и подключить ее к проекту как анализатор.

Метод Initialize пригодится для выполнения какой-либо единоразовой логики. GeneratorInitializationContext на данный момент может быть полезен только для подключения посетителя узлов синтаксиса кода.

В Execute имеется контекст, из которого можно как собрать информацию по существующему коду, так и, собственно, добавить новый.

Для каждого файла исходного кода Roslyn предоставляет синтаксическое дерево в виде объекта SyntaxTree:

GeneratorExecutionContext.Compilation.SyntaxTrees

а так же семантическую модель:

semanticModel =  GeneratorExecutionContext.Compilation.GetSemanticModel(SyntaxTree)

Последняя нужна, чтобы по участку кода (узлу синтаксиса) понять его связи с другими частями программы, типами, другими сборками.

Среди всех узлов синтаксических деревьев сборки нам нужно найти только интересующие нас лямбда-выражения типа System.Linq.Expressions.Expression<T> и отобрать из их узлов-потомков выражения, описывающие доступ к членам классов, создание объектов и вызов методов:

По семантике узла, так называемому символу (Symbol), можно определять:

  • типы, используемые выражением;

  • область видимости;

  • IsStatic, IsConst, IsReadOnly и другие характеристики.

На основе такой информации и будем генерировать подходящий код.

В Roslyn API (Microsoft.CodeAnalysis) построить сигнатуру намного проще, чем c API рефлексии (System.Reflection). Достаточно сконвертировать символ в строку при помощи методаISymbol.ToDisplayString(SymbolDisplayFormat) c подходящим форматом:

Зная сигнатуры свойства/поля, его типа и обладателя формируем строки для добавления делегатов:

Оформляем код добавления делегатов в класс и отдаем компилятору:

var sourceBuilder = new StringBuilder(@"namespace ExpressionDelegates.AccessorRegistration{  public static class ModuleInitializer  {    public static void Initialize()    {");      foreach (var line in registrationLines)      {        sourceBuilder.AppendLine();        sourceBuilder.Append(' ', 6).Append(line);      }      sourceBuilder.Append(@"    }  }}");GeneratorExecutionContext.AddSource(  "AccessorRegistration",  SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));

Этот код обязательно будет добавлен в сборку ...если генератор сможет отработать :)

Дело в том, что хоть Source Generators технически и не фича языка, поддерживаются они только в проектах с C# 9+. Позволить такую роскошь без костылей и ограничений на данный момент могут только проекты на .NET 5.

Совместимость

Поддержку Roslyn Source Generators API для .NET Standard, платформ .NET Core, .NET Framework и даже Xamarin поможет организовать Uno.SourceGeneration.

Uno.SourceGeneration предоставляет собственные копии интерфейса ISourceGenerator и атрибута [Generator], которые при миграции на С# 9 меняются на оригинальные из пространства имен Microsoft.CodeAnalysis простым удалением импортов Uno:

using Uno.SourceGeneration;using GeneratorAttribute = Uno.SourceGeneration.GeneratorAttribute;using ISourceGenerator = Uno.SourceGeneration.ISourceGenerator;
Для подключения достаточно добавить несколько строк в файл проекта.

В проект, где генератор будет использоваться:

<ItemGroup>  <SourceGenerator Include="PATH\TO\GENERATOR.dll" /></ItemGroup>

Например, распространяя генератор через nuget, подключение можно осуществлять вложением MSBuild props файла со следующим путём:

Инициализация

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

Для этих целей отлично подходит Module Initializer. Это конструктор сборки (а точнее ее модуля), который запускается сразу после ее загрузки и до вызовов к остальному коду. Он давно есть в CLR, но к сожалению, в C# его поддержка c атрибутом [ModuleInitializer] добавлена только в 9 версии.

Решение по добавлению конструктора в сборку с более широкой поддержкой платформ есть у Fody плагин Fody.ModuleInit. После компиляции добавляет классы с именами ModuleInitializer в конструктор сборки. В такой класс и будем оборачивать инициализацию сгенерированных делегатов.

Подключение Fody.ModuleInit через MSBuild свойства вместо FodyWeavers.xml исключит конфликты с другими Weaver-ами Fody в проекте клиента.

Использование

Таким образом, при сборке проекта:

  1. Source Generator добавит в сборку код, регистрирующий делегаты для деревьев выражений, в обертке класса ModuleInitializer.

  2. Fody.ModuleInit добавит ModuleInitializer в конструктор сборки.

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

Проверяем:

Expression<Func<string, int>> expression = s => s.Length;MemberInfo accessorInfo = ((MemberExpression)expression.Body).Member;Accessor lengthAccessor = ExpressionDelegates.Accessors.Find(accessorInfo);var length = lengthAccessor.Get("17 letters string");// length == 17

При декомпиляции сборки видно, что сгенерированный код и инициализатор модуля на месте:

Бенчмарки

Сгенерированные делегаты выполняются немного медленнее, чем обычные из-за приведения типов и возможной упаковки параметров значимых типов.

Действие

Время, нс

Вызов простого делегата конструктора

4.6937 0.0443

Вызов сгенерированного делегата конструктора

5.8940 0.0459

Поиск и вызов сгенерированного делегата конструктора

191.1785 2.0766

Компиляция выражения и вызов конструктора

88,701.7674 962.4325

Вызов простого делегата доступа к свойству

1.7740 0.0291

Вызов сгенерированного делегата доступа к свойству

5.8792 0.1525

Поиск и вызов сгенерированного делегата доступа к свойству

163.2990 1.4388

Компиляция выражения и вызов геттера

88,103.7519 235.3721

Вызов простого делегата метода

1.1767 0.0289

Вызов сгенерированного делегата метода

4.1000 0.0185

Поиск и вызов сгенерированного делегата метода

186.4856 2.5224

Компиляция выражения и вызов метода

83,292.3139 922.4315

Полный вариант таблицы, с бенчмарками интерпретации.

А судя по результату профилирования поиска сгенерированного делегата, самое долгое построение сигнатуры, ключа для поиска.

Flame-график бенчмарка поиска и вызова сгенерированного делегата доступа к свойствуFlame-график бенчмарка поиска и вызова сгенерированного делегата доступа к свойству

Идеи насчёт оптимизации построения сигнатур по System.Reflection.MemberInfo приветствуются. Реализация на момент написания.

Заключение

По итогу получилось современное решение для кодогенерации с актуальной совместимостью и автоматической инициализацией.

Полный код можно посмотреть на: github/ExpressionDelegates, а подключить через nuget.

Для тех, кто будет пробовать Source Generators хотелось бы отметить несколько полезностей:

  • Source Generator Playground (github).
    Позволяет экспериментировать с Roslyn Source Generators в браузере, онлайн.

  • Окно визуализации синтаксиса для Visual Studio.
    Удобный инструмент для знакомства с Roslyn Syntax API на собственном коде.

  • Отлаживается Source Generator вызовом отладчика из его кода. Пример.
    Для этого нужен компонент Visual Studio Just-In-Time debugger и включенная настройка Tools -> Options -> Debugging -> Just-In-Time Debugging -> Managed.

  • В сгенерированных *.cs файлах срабатывают брейкпоинты, проверено в Visual Studio16.8.
    При генерации через Uno.SourceGeneration файлы размещаются по пути: \obj\{configuration}\{platform}\g\.
    С Roslyn Source Generators их появление включается через MSBuild свойство EmitCompilerGeneratedFiles.
    Стандартный путь: \obj\{configuration}\{platform}\generated\, переопределяется в свойстве CompilerGeneratedFilesOutputPath.

  • Source Generators можно конфигурировать свойствами MSBuild.
    При использовании Uno.SourceGeneration значение получают вызовом

    GeneratorExecutionContext.GetMSBuildPropertyValue(string)
    

    Для Roslyn Source Generators требуемые свойства необходимо сперва отдельно обозначить в MSBuild группе CompilerVisibleProperty и только после вызывать:

    GeneratorExecutionContext.AnalyzerConfigOptions.GlobalOptions  .TryGetValue("build_property.<PROPERTY_NAME>", out var propertyValue)
    
  • Из генератора можно кидать предупреждения и ошибки сборки.

    //Roslyn Source GeneratorsGeneratorExecutionContext.ReportDiagnostic(Diagnostic)//Uno.SourceGeneration:GeneratorExecutionContext.GetLogger().Warn/Error().
    
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru