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

Ast

Минифицируем приватные поля в TypeScript. Доклад Яндекса

13.06.2020 14:36:10 | Автор: admin
Меня зовут Лёша Гусев, я работаю в команде разработки видеоплеера Яндекса. Если вы когда-нибудь смотрели фильмы или трансляции на сервисах Яндекса, то использовали именно наш плеер.

Я сделал небольшую оптимизацию размера бандла минификацию приватных полей. В докладе на Я.Субботнике я рассказал об использовании Babel-плагинов, трансформеров TypeScript и о том, насколько в итоге уменьшился размер продакшен-сборки проекта.


Конспект и видео будут полезны разработчикам, которые ищут дополнительные способы оптимизации своего кода и хотят узнать, как webpack, Babel и TypeScript могут в этом помочь. В конце будут ссылки на GitHub и npm.

С точки зрения структуры наш видеоплеер довольно типичный фронтенд-проект. Мы используем webpack для сборки, Babel для транспиляции кода, скин нашего плеера мы пишем на React, весь код написан на TypeScript.

Мы также активно используем опенсорсные библиотеки, чтобы реализовывать адаптивный видеостриминг. Одна из таких библиотека shaka-player, которая разрабатывается в Google, и нужна она для того, чтобы поддерживать в вебе адаптивный формат MPEG-DASH.

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



В процессе работы с этой библиотекой я познакомился с инструментом, который называется Google Closure Compiler. Наверняка многие из вас слышали про этот инструмент. Возможно, кто-то пользовался вот такой классной онлайн-страничкой, куда можно вставить кусок JavaScript-кода и минифицировать его с помощью Google Closure Compiler.



Кто-то, может быть, использовал плагин webpack-closure-compiler. Здесь на слайде сравнение webpack-closure-compiler с другими инструментами минификации webpack, и, как видно, closure-compiler по этому сравнению лидирует.



Почему он такой классный? Дело в том, что в closure-compiler есть так называемый advanced-уровень оптимизации. На этой страничке он кроется за переключалкой в radio button.

Что из себя представляют advanced-оптимизации и почему они такие классные? Это некоторый набор оптимизаций, которых нет в других инструментах. Рассмотрим некоторые из них.



Closure-compiler умеет инлайнить функции. Здесь он просто заинлайнил тело функции f и удалил объявление самой функции, так как она больше нигде не используется.



Он умеет удалять неиспользуемый код. Здесь объявлен класс A и класс B. По факту используется только один метод из класса A. Класс B был удален совсем. Из класса A method() был заинлайнен, и мы в итоге получили только console.log.



Closure-compiler умеет инлайнить и вычислять значения переменных на этапе сборки.



Еще он умеет минифицировать поля объектов. Здесь объявлен класс A со свойством prop, и после обработки closure-compiler свойство prop заменилось на короткий идентификатор a, за счет чего код стал меньше весить.

Это не все оптимизации. В статье можно подробно почитать, что еще может closure-compiler. Там довольно много чего крутого.

Когда я про это узнал, мне очень понравилось. Я захотел притащить все эти оптимизации к нам в проект. Что мне особенно понравилось, так это минификация полей объектов, а именно минификация приватных полей для TypeScript.



Если вы никогда не писали на TypeScript, то что такое приватные поля? Это такой синтаксический сахар, который просто удаляется при сборке, при компиляции TS-кода в JavaScript. Но если вы используете приватное поле за пределами класса, вы получите ошибку компиляции.

Почему мне понравилась идея минифицировать приватные поля?



У нас в проекте довольно много React-компонентов, написанных в ООП-стиле с классом. И есть TypeScript-код, в котором используются классы и приватные поля.

Давайте рассмотрим вот такой компонент. В нем есть приватное поле clickCount.



Сейчас при сборке и компиляции кода TypeScript оставляет название этого поля как есть, просто удаляет модификатор private. Было бы клево, если бы clickCount заменился на короткий идентификатор A.

Чтобы достичь этого, давайте попробуем использовать Closure Compiler в advanced-режиме как минификатор.

И тут можно столкнуться с проблемами. Давайте рассмотрим пример. Объявлен объект с полем foobar. Обратимся к этому полю. Здесь все хорошо. Closure Compiler отработает такой код корректно. Поле foobar будет переименовано.


Ссылка со слайда

Но если мы вдруг зачем-то будем обращаться к этому полю через строковой литерал, то после сборки получим no reference, ошибку в коде. Она связанна с тем, что в поле идентификатор foobar Closure Compiler переименует, а строковые литералы оставит как есть.


Ссылка со слайда

Следующий пример. Здесь мы объявляем метод у объекта, который внутри использует ключевое слово this. После сборки с помощью Closure Compiler и удаления мертвого кода, как видно на слайде, идентификатор this станет глобальным. Вы опять же получите ошибку в коде.

Примеры немножко утрированные, надеюсь, такие конструкции у себя в проектах вы не применяете. Тем не менее, есть более сложные случаи, когда advanced-оптимизации могут сломать ваш код. По ссылке вы можете посмотреть документацию Closure Compiler, какие ограничения он привносит на ваш код. Тем более нельзя гарантировать, что ваши внешние npm-зависимости после обработки Closure Compiler будут работать корректно.


Ссылка со слайда

Для нашего проекта это еще критичнее. Как я говорил, мы разрабатываем видеоплеер. Видеоплеер это встраиваемая библиотека. Если Closure Compiler переименует поле в нашей встраиваемой библиотеке, переименует публичный метод, то внешний код сломается и не сможет взаимодействовать с плеером.

Наверное, как-то нужно сказать Closure Compiler о том, что какие-то поля можно минифицировать, какие-то нельзя, какие-то, очевидно, публичные, какие-то приватные.

Но выходит, что advanced-оптимизации в общем случае не безопасны. Их можно сделать безопасными, если вы используете Closure Compiler на полную мощность.



Я немножко слукавил. Closure Compiler не просто минификатор, а целый комбайн. Он заменяет собой webpack, Babel и TypeScript. За счет чего и как ему это удается?


Ссылка со слайда

В Closure Compiler есть своя модульная система goog.provide, goog.require.


Ссылка со слайда

Есть своя транспиляция и вставка полифиллов для различных таргетов, для разных версий ECMASCRIPT.


Ссылка со слайда

Еще там есть свои аннотации типов. Только описываются они не как в TypeScript, а в JSDoc. Точно так же там можно пометить, например, модификаторы доступа public, private и подобные.



Если сравнивать микросистему webpack-Babel-TypeScript с Closure Compiler, то, на мой вкус, Closure Compiler проигрывает. У него чуть хуже документация, им умеют пользоваться меньше разработчиков. В целом не самый удобный инструмент.

Но я все-таки хочу оптимизации. Может, можно как-то взять Closure Compiler, взять TypeScript и объединить их?



Такое решение есть. Называется оно tsickle.


Ссылка со слайда

Это проект, который разрабатывается в Angular и занимается тем, что компилирует TypeScript-код в JS-код с аннотациями Closure Compiler.


Ссылка со слайда

Есть даже webpack loader, tsickle-loader называется, который внутри использует tsickle и заменяет собой tsickle loader. То есть он подгружает TypeScript-код в webpack и эмитит JavaScript с аннотациями. После чего можно запустить Closure Compiler как минификатор.



Проблема в том, что в README tsickle явно написано, что работа над проектом в процессе, и Google внутри его использует, но снаружи большого опыта использования этого проекта нет. И завязываться на отдельный компилятор от TypeScript не очень-то и хочется, потому что тогда вы будете отставать по фичам от TypeScript и не получать какие-то новые возможности.

Собственно, tsickle loader имеет ту же проблему, так как он основан на tsickle. В общем, это решение сейчас не выглядит как хорошее продакшен-решение.



Какие еще есть варианты? В TypeScript есть issue про предложение о минификации. Там идет обсуждение похожей реализации на Closure Compiler: оптимизировать код, используя знания о типах. Проблема в том, что этот issue открыт 15 июля 2014 года, там до сих пор ничего не происходит. Вернее, там происходит 145 комментариев, но результатов пока нет. Судя по всему, команда TypeScript не считает, что компилятор TypeScript должен заниматься минификацией. Это задача других инструментов.

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



Не так давно в Babel появилась поддержка TypeScript. Существует babel/preset-ypescript, который добавляет в Babel возможность парсить TypeScript-код и эмитить JavaScript. Он делает это путем удаления всех TypeScript-модификаторов.



Мы, кстати, не так давно перешли с ts-TS loader на Babel с использованием babel/preset-typescript и этим сильно ускорили сборку. Наконец-то настроили конкатенацию модулей в webpack, сделали еще некоторые оптимизации и настроили разные сборки под ES5- и ES6-браузеры. Про это можно узнать подробнее из доклада моего коллеги.

Окей, давайте попробуем написать babel-plugin, который будет минифицировать приватные поля, раз Babel умеет работать с TypeScript.


Ссылка со слайда

Как Babel работает? На эту тему есть много хороших материалов, статей и докладов. Можно начать, например, с этого материала на Хабре. Я лишь бегло расскажу, как происходит процесс обработки кода через Babel.

Итак, Babel, как и любой транспойлер кода, сначала парсит его и строит абстрактное синтаксическое дерево, abstract syntax tree, AST. Это некоторое дерево, которое описывает код.



Давайте попробуем на коротком кусочке кода посмотреть, как строится AST. Наша маленькая программа состоит из двух выражений. Первое выражение это Variable Declaration, объявление перемены. Из чего оно состоит? Из оператора VAR на схеме это Variable Declarator. У оператора есть два операнда идентификатор A, мы создаем переменную A, и Numeric Literal, мы присваиваем им значение 3.

Второе выражение в программе это Expression Statement, просто выражение. Оно состоит из бинарного выражения, то есть операции, у которой есть два аргумента. Это выражение +, первый аргумент a, второй 5, числовой литерал. Примерно так строятся абстрактные статические деревья.



Если вы хотите подробнее в это окунуться или когда-нибудь будете писать свой плагин для Babel, вам очень сильно поможет инструмент AST Explorer. Это онлайн-приложение, куда вы можете просто скопировать ваш код и посмотреть, как строится для него абстрактное синтаксическое дерево.

Здесь есть поддержка Babel и TypeScript. Очень удобный, практически незаменимый инструмент, чтобы заниматься написанием плагинов для Babel.


Ссылка со слайда

Когда мы построили AST, мы его трансформируем. Трансформация это превращение одного AST в новое, измененное. Как происходит трансформация? Это тот самый процесс, который вы описываете с помощью настроек Babel в файлике .babelrc или где-то еще.

Вы задаете список плагинов, которые вы трансформируете в ваше AST дерево.



Что такое плагин? Это просто функция, которая имплементирует паттерн Visitor. Babel обходит AST в глубину, в том порядке, как указано на схеме на слайде. Babel вызывает функцию вашего плагина, который возвращает объект с некоторыми методами.

В зависимости от того, в каком узле мы сейчас находимся, вызывается соответствующий метод объекта, который вернул плагин. Для идентификатора вызовется Identifier, для строки вызовется StringLiteral и т. д.

Подробно об этом можно узнать из документации Babel и воспользоваться AST Explorer, чтобы понять, какие операции в коде каким узлам в AST- дереве соответствуют.


Ссылка со слайда

Когда мы вызвали метод на узле, узел можно модифицировать, чтобы трансформировать AST-дерево. После этого мы получаем новое AST. Тогда Babel запускает генерацию и эмитит из AST-дерева итоговый код. Примерно так выглядит вся схема работы Babel и, на самом деле, практически любого другого инструмента, который занимается транспиляцией кода.

Давайте напишем плагин, который модифицирует приватные поля. Спойлер: у меня ничего не получилось. Расскажу, почему.

Как будет работать наш плагин? Давайте возьмем какой-нибудь класс, в котором есть приватное поле. Наш плагин Visitor будет заходить во все узлы AST-дерева, соответствующего классу, и в MemberExpression. Это узел, соответствующий операции доступа к полю в объекте.


Ссылка со слайда

Если объект, к которому мы обращаемся, this, то надо проверить, является ли поле приватным.


Ссылка со слайда

Для этого нужно подняться вверх по AST-дереву и найти декларацию этого поля. Тут нам повезло: она имеет модификатор private, значит, можно переименовать это поле.


Ссылка со слайда

Магия! Все работает, классно. Плагин готов.

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


Ссылка со слайда

Рассмотрим пример. Тут мы добавили в наш класс еще один метод, внутри которого обращаемся к приватному полю не this, а другого инстанса этого класса. Это валидный код, мы обращаемся к приватному полю внутри класса, здесь все ок.

Но мой плагин при обработке этого кода делал вот такое. Почему так происходило? Потому что я сделал так, что мы ищем доступы к объекту this. Если же мы обращаемся к другому объекту, эти узлы AST-дерева мы не рассматриваем.

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

Звучит как какой-то костыль, будто нам не хватает информации о знании типа.


Ссылка со слайда

Рассмотрим еще один пример. Здесь мы this присваиваем переменную и обращаемся к полю bar из этой переменной. Это тоже валидный код. Но в итоге я получал такое. Здесь тоже нужен костыль, который будет разбирать такие обращения, искать, что this foo на самом деле this, что у него тип foo. И в этом случае bar нужно точно так же переименовать.

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

Я расстроился и уже было похоронил эту идею.



Но потом я вернулся в тот долгий тред про минификацию в TypeScript и увидел там комментарий Евгения Тимохова.



Он писал, что реализовал TypeScript в трансформер, который занимается минификацией приватных полей. И этот трансформер вроде бы даже работает.



У Евгения немножко другой подход. В своем трансформере он не переименовывает поля полностью, как я хотел, а добавляет к ним префикс, нижнее подчеркивание private нижнее подчеркивание.



Чтобы потом минифицировать такие поля, он предлагает использовать uglifyJS или terser, то есть uglifyJS с поддержкой ES6, и настроить минификацию полей, название которых соответствует регулярным выражениям.

Почему трансформер Евгения работает, а подход, который выбрал я, ни к чему не привел?


Ссылка со слайда

Тут нам потребуется немножко поговорить о том, как работает TypeScript. TypeScript это ведь тоже транспойлер. Он занимается тем, что берет TypeScript-код и парсит его. Но вместе с парсингом TypeScript еще запускает Type Checker. И при построении AST-дерева TypeScript обогащает его информацией о типах идентификаторов. Это как раз та самая информация, которой мне не хватало в моем Babel-плагине.

Дальше компилятор TypeScript может трансформировать AST-дерево точно так же, как Babel. Построить новое AST-дерево и эмитить из него JavaScript-код.

На этапе трансформации можно подключить кастомный трансформер. Это точно такой же плагин, точно такая же сущность, как плагин Babel, только для TypeScript. Она использует такой же паттерн Visitor и реализует примерно те же идеи.

Как это все использовать? Проблема в том, что в CLI компилятора TypeScript нет возможности подключать кастомные трансформации. Если вы хотите такое делать, вам потребуется пакет ttypescript.


Ссылка со слайда

Это не опечатка, а обертка над компилятором TypeScript, которая позволяет в настройках компилятора в tsconfig указать возможность использовать кастомную трансформацию. Здесь кастомная трансформация будет просто браться из node_modules.


Ссылка со слайда

Такая фича есть и в ts-loader. Там тоже можно задать функцию getCustomTransformers, которая позволит вам применить кастомный трансформер на этапе сборки.



Когда я решил попробовать этот подход, то столкнулся с проблемой. У нас-то в проекте используется Babel и babel/preset-typescript. Как вы помните из рассказа, мы на него переехали из ts-loader и получили кучу профита, сделали кучу оптимизаций. Откатываться обратно и терять все это мне не хотелось.

Окей, будем делать свой велосипед еще раз. Как выглядит сейчас пайплайн сборки в моем проекте? Мы подгружаем TypeScript-код в Babel loader и эмитим из него JS. Тут мне нужна сущность, которая перед Babel позволит запускать TypeScript-трансформер.

В Babel этого сделать нельзя, потому что он не запускает компилятор TypeScript. Как я говорил, он просто вырезает модификаторы TypeScript из кода.


Ссылка со слайда

Идею такой сущности я подсмотрел в проекте react-docgen-typescript-loader. Это такой loader для webpack, который пригодится, если вы используете Storybook. Storybook инструмент, который позволяет строить визуальные гайды и документацию к вашим React-компонентам.



Чем занимается этот loader? Он подгружает TypeScript-код, обрабатывает его и эмитит TypeScript-код с дополнительными полями у React-компонентов. Поля называются docgenInfo, и в них содержится информация для Storybook, чтобы построить документацию к React-компоненту, используя не propTypes, а аннотации TypeScript.

Потом этот код, заэмиченный в react-docgen-typescript-loader, как-то обрабатывается. Например, с помощью TS loader и Babel. В итоге, когда он попадает в Storybook, тот успешно строит документацию по полям docgenInfo.

Мне нужна похожая штука. Мне нужен webpack loader. Как это сделать?


Ссылка со слайда

Webpack loader это просто функция. Она принимает исходный код файла в виде строки и возвращает исходный код, тоже может его как-то модифицировать.

Здесь на слайде очень глупый loader, который занимается тем, что все ваши файлы превращает в код, содержащий console.log(Hello World!).


Ссылка со слайда

Loader может быть и синхронный. Можно получить callback, сделать асинхронную операцию. Вернее, что-то прочитать с диска или сделать что-то подобное и вызвать callback с новым модифицированным source.



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

Дальше я смогу его обработать с помощью Babel loader, как я делал это сейчас, и эмитить JS. А нашлепка из моего кастомного loader будет опциональной. Если что-то пойдет не так, я всегда смогу ее отключить, и максимум, что я здесь потеряю, минификацию приватных полей. И это не потребует от меня перестройки всего остального пайплайна сборки.

Окей, мы разобрались, как писать loader. Функция, которая обрабатывает source. Теперь нужно понять, как применить на наш файл кастомную TypeScript-трансформацию.


Ссылка со слайда

Для этого нам потребуется TypeScript Transformation API как раз тот самый программный API, который позволяет обработать исходный код на TypeScript, применить к нему трансформацию и заэмитить TypeScript-код. Это то, что нужно моему loader.

Примерно как это работает? Сначала нужно получить TS program, это объект, который содержит коллекцию файлов и настройки компилятора TypeScript. Нам нужно распарсить исходный файл и получить для него AST. Потом мы трансформируем это дерево и подключаем здесь myCustomTransformer, нашу кастомную трансформацию. И получаем в переменной result новое AST. Дальше мы его можем сериализовать обратно в TypeScript-код. Этим занимается компонент printer.

Кажется, ничего не мешает использовать это в webpack loader. Единственная проблема: документация по Transformation API не очень хорошая. И вообще, документация по внутренним сущностям компилятора в TypeScript сильно проигрывает аналогичной документации Babel. Но если вы захотите окунуться в это, начать можно с пул-реквеста в репозитории в TypeScript, где Transformation API сделали публичной.



Итак, что в итоге делает мой loader? Подгружает TypeScript-код и с помощью TypeScript Transformation API применяет на него кастомную трансформацию. Эмитит уже модифицированный TypeScript-код обратно. Дальше я скармливаю его Babel, который эмитит JavaScript.

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

npm install -D ts-transformer-loader

Всю эту прекрасную конструкцию мы даже покатили в продакшен.



Какой профит дала вся эта возня? Сырой непожатый код нашего бандла уменьшился на 31 килобайт, это почти 5%. Результаты в gzip и brotli не такие классные, потому что код и повторяющиеся идентификаторы там и так хорошо сжимаются. Но выигрыш порядка 2%.

Уменьшение непожатого кода на 5% не очень крутой выигрыш. Но 2% в минифицированном коде, который вы гоняете по сети, можно даже заметить на мониторингах.

Вот ссылка на мои заметки. Спасибо.
Подробнее..

Из песочницы Как я выкинул webpack и написал babel-plugin для транспила scsssass

03.08.2020 12:13:23 | Автор: admin

Предыстория


Как-то субботним вечером я сидел и искал способы сборки UI-Kit с помощью webpack. В качестве демо UI-kit я пользуюсь styleguidst. Конечно же, webpack умный и все файлы, которые есть в рабочем каталоге он запихивает в один бандл и оттуда всё крутится и вертится.

Я создал файл entry.js, импортнул туда все компоненты, затем оттуда же экспортнул. Вроде всё ок.

import Button from 'components/Button'import Dropdown from 'components/Dropdown 'export {  Button,  Dropdown }

И после сборки всего этого, я получил на выходе output.js, в котором как и ожидалось было всё все компоненты в куче в одном файле. Тут возник вопрос:
А как мне собрать все кнопочки, дропдауны и прочее по отдельности, что бы импортировать в других проектах?
А я ведь хочу это ещё и в npm залить как пакет.

Хм Поехали по порядку.

Multiple entries


Конечно, первая идея, которая может прийти в голову спарсить все компоненты в рабочем каталоге. Пришлось немножко погуглить про парсинг файлов, т.к с NodeJS я работаю очень редко. Нашёл такую штуку, как glob.

Погнали писать multiple entries.

const { basename, join, resolve } = require("path");const glob = require("glob");const componentFileRegEx = /\.(j|t)s(x)?$/;const sassFileRegEx = /\s[ac]ss$/;const getComponentsEntries = (pattern) => {  const entries = {};  glob.sync(pattern).forEach(file => {    const outFile = basename (file);    const entryName = outFile.replace(componentFileRegEx, "");    entries[entryName] = join(__dirname, file);  })  return entries;}module.exports = {  entry: getComponentsEntries("./components/**/*.tsx"),  output: {    filename: "[name].js",    path: resolve(__dirname, "build")  },  module: {    rules: [      {        test: componentFileRegEx,        loader: "babel-loader",        exclude: /node_modules/      },      {        test: sassFileRegEx,        use: ["style-loader", "css-loader", "sass-loader"]      }    ]  }  resolve: {    extensions: [".js", ".ts", ".tsx", ".jsx"],    alias: {      components: resolve(__dirname, "components")    }  }}

Готово. Собираем.

После сборки в каталог build упало 2 файла Button.js, Dropdown.js заглядываем внутрь. Внутри лицензии react.production.min.js, тяжелочитаемый минимизированный код, и куча всякой фигни. Окей, попробуем использовать кнопку.

В демо файле кнопки меняем импорт на импорт из каталога build.

Вот так выглядит простая демка кнопки в styleguidist Button.md

```javascriptimport Button from '../../build/Button'<Button>Кнопка</Button>```

Заходим посмотреть на кнопочку иии

Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.


На этом этапе уже отпали идея и желание собирать через webpack.

Ищем другой путь сборки без webpack


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

{  //...package.json всякие штуки-дрюки о которых обычно не паримся  scripts: {    "build": "babel --config-file ./.babelrc --extensions '.jsx, .tsx' ./components --out-dir ./build"  }}

запускаем:

npm run build

Вуаля, у нас в каталоге build появились 2 файла Button.js, Dropdown.js, внутри файлов красиво оформленный ванильный js + некоторые полифилы и одинокий requre(styles.scss). Явно это не сработает в демке, удаляем импорт стилей(в этот момент меня гложила надежда, что я найду плагин для транспила scss), собираем ещё раз.

После сборки у нас остался читсый JS. Повторяем попытку интеграции собранного компонента в styleguidist:

```javascriptimport Button from '../../build/Button'<Button>Кнопка</Button>```

Скомпилировалось работает. Только кнопочка без стилей.

Ищем плагин для транспила scss/sass


Да, сборка компонентов работает, компоненты работают, можно собирать, паблишить в npm или свой рабочий нехус(nexus). Ещё бы только стили сохранить Окей, снова гугл нам поможет (нет).

Гугления плагинов не принесли мне каких-то результатов. Один плагин генерирует строку из стилей, другой вообще не работает да ещё требует импорта вида:import styles from styles.scss (никогда так не импортировал стили и мое личное мнение не надо так делать, но о вкусах не спорят)

Единственная надежда была на этот плагин: babel-plugin-transform-scss-import-to-string, но он просто генерирует строку из стилей (а я уже говорил выше. Блин...). Дальше всё стало ещё хуже, я дошел до 6 страницы в гугле (а на часах уже 3 утра). Да и вариантов особо уже не будет что-то найти. Да и думать то нечего либо webpack + sass-loader, которые хреново это делают и не для моего случая, либо ШТО-ТО ДРУГОЕ. Нервы Я решил немного передохнуть, попить чай, спать всё равно не хочется. Пока делал чай, идея написать плагин для транспила scss/sass все больше и больше влетала в мою голову. Пока мешал сахарочек, редкие звоны ложки в моей голове отдавались эхом: Пиши плааагин. Ок, решено, буду писать плагин.

Плагин не найден. Пишем сами


За основу своего плагина я взял babel-plugin-transform-scss-import-to-string, упомянутый выше. Я прекрасно понимал, что сейчас будет геморрой с AST деревом, и прочими хитростями. Ладно, поехали.

Делаем предварительные подготовочки. Нам нужны node-sass и path, а так же регулярочки для файлов и расширений. Идея такая:

  • Получаем из строки импорта путь до файла со стилями
  • Парсим через node-sass стили в строку (спасибо babel-plugin-transform-scss-import-to-string)
  • Создаем style теги по каждому из импортов (плагин бабеля запускается на каждом импорте)
  • Надо как-то идентифицировать созданный стиль, что бы не накидывать одно и то же на каждый чих hot-reload. Впихнем ему какой-нибудь аттрибут (data-sass-component) со значением текущего файла и названием файла стилей. Будет что-то вроде этого:

          <style data-sass-component="Button_style">         .button {            display: flex;         }      </style>
    

В целях разработки плагина и тестирования на проекте, на уровне с каталогом components я создал babel-plugin-transform-scss каталог, запихнул туда package.json и запихнул туда каталог lib, а в него уже закинул index.js.
Что бы вы были вкурсе конфиг бабеля лезет за плагином, который указан в директиве main в package.json, для этого пришлось его запихать.
Указываем:

{  //...package.json опять всякие штуки-дрюки о которых обычно не паримся, да и кроме main ничего нету  main: "lib/index.js"}

Затем, пихаем в конфиг бабеля (.babelrc) путь до плагина:

{  //Тут всякие пресеты  plugins: [    "./babel-plugin-transform-scss"    //тут остальные плагины для сборки  ]}

А теперь напихиваем в index.js магию.

Первый этап проверка на импорт именно scss или sass файла, получение имени импортируемых файлов, получение имени самого js файла(компонента), транспил в css строку scss или sass. Подрубаемся через WebStorm к npm run build через дебаггер, ставим точки останова, смотрим аргументы path и state и выуживаем имена файлов, обрабатываем руглярочками:

const { resolve, dirname, join } = require("path");const { renderSync } = require("node-sass");const regexps = {  sassFile: /([A-Za-z0-9]+).s[ac]ss/g,  sassExt: /\.s[ac]ss$/,  currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g,  currentFileExt: /.(t|j)s(x)/g};function transformScss(babel) {  const { types: t } = babel;  return {    name: "babel-plugin-transform-scss",    visitor: {      ImportDeclaration(path, state) {        /**         * Проверяем, содержит ли текущий файл scss/sass расширения в импорте         */        if (!regexps.sassExt.test(path.node.source.value)) return;        const sassFileNameMatch = path.node.source.value.match(          regexps.sassFile        );        /**         * Получаем имя текущего scss/sass файла и текущего js файла         */        const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, "");        const file = this.filename.match(regexps.currentFile);        const filename = `${file[0].replace(          regexps.currentFileExt,          ""        )}_${sassFileName}`;        /**         *         * Получаем полный путь до scss/sass файла, транспилим в строку css         */        const scssFileDirectory = resolve(dirname(state.file.opts.filename));        const fullScssFilePath = join(          scssFileDirectory,          path.node.source.value        );        const projectRoot = process.cwd();        const nodeModulesPath = join(projectRoot, "node_modules");        const sassDefaults = {          file: fullScssFilePath,          sourceMap: false,          includePaths: [nodeModulesPath, scssFileDirectory, projectRoot]        };        const sassResult = renderSync({ ...sassDefaults, ...state.opts });        const transpiledContent = sassResult.css.toString() || "";        }    }}

Fire. Первый успех, получена строка css в transpiledContent. Дальше самое страшное лезем в babeljs.io/docs/en/babel-types#api за API по AST дереву. Лезем в astexplorer.net пишем там код запихивания в head документа стилей.

В astexplorer.net пишем Self-Invoking функцию, которая будет вызываться на месте импорта стиля:

(function(){  const styles = "generated transpiledContent" // ".button {/n display: flex; /n}/n"   const fileName = "generated_attributeValue" //Button_style  const element = document.querySelector("style[data-sass-component='fileName']")  if(!element){    const styleBlock = document.createElement("style")    styleBlock.innerHTML = styles    styleBlock.setAttribute("data-sass-component", fileName)    document.head.appendChild(styleBlock)  }})()

В AST explorer тыкаем в левой части на строки, объявления, литералы, справа в дереве смотрим структуру объявлений, по этой структуре лезем в babeljs.io/docs/en/babel-types#api, курим всё это и пишем замену.

A few moments later

Спустя 1-1,5 часа, бегая по вкладкам из ast в babel-types api, затем в код, я написал замену импорта scss/sass. Разбирать отдельно дерево ast и babel-types api я не буду, будет ещё больше буковок. Показываю сразу результат:

const { resolve, dirname, join } = require("path");const { renderSync } = require("node-sass");const regexps = {  sassFile: /([A-Za-z0-9]+).s[ac]ss/g,  sassExt: /\.s[ac]ss$/,  currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g,  currentFileExt: /.(t|j)s(x)/g};function transformScss(babel) {  const { types: t } = babel;  return {    name: "babel-plugin-transform-scss",    visitor: {      ImportDeclaration(path, state) {        /**         * Проверяем, содержит ли текущий файл scss/sass расширения в импорте         */        if (!regexps.sassExt.test(path.node.source.value)) return;        const sassFileNameMatch = path.node.source.value.match(          regexps.sassFile        );        /**         * Получаем имя текущего scss/sass файла и текущего js файла         */        const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, "");        const file = this.filename.match(regexps.currentFile);        const filename = `${file[0].replace(          regexps.currentFileExt,          ""        )}_${sassFileName}`;        /**         *         * Получаем полный путь до scss/sass файла, транспилим в строку css         */        const scssFileDirectory = resolve(dirname(state.file.opts.filename));        const fullScssFilePath = join(          scssFileDirectory,          path.node.source.value        );        const projectRoot = process.cwd();        const nodeModulesPath = join(projectRoot, "node_modules");        const sassDefaults = {          file: fullScssFilePath,          sourceMap: false,          includePaths: [nodeModulesPath, scssFileDirectory, projectRoot]        };        const sassResult = renderSync({ ...sassDefaults, ...state.opts });        const transpiledContent = sassResult.css.toString() || "";        /**         * Имплементируем функцию, написанную в AST Explorer и заменяем импорт методом          * replaceWith аргумента path.         */        path.replaceWith(          t.callExpression(            t.functionExpression(              t.identifier(""),              [],              t.blockStatement(                [                  t.variableDeclaration("const", [                    t.variableDeclarator(                      t.identifier("styles"),                      t.stringLiteral(transpiledContent)                    )                  ]),                  t.variableDeclaration("const", [                    t.variableDeclarator(                      t.identifier("fileName"),                      t.stringLiteral(filename)                    )                  ]),                  t.variableDeclaration("const", [                    t.variableDeclarator(                      t.identifier("element"),                      t.callExpression(                        t.memberExpression(                          t.identifier("document"),                          t.identifier("querySelector")                        ),                        [                          t.stringLiteral(                            `style[data-sass-component='${filename}']`                          )                        ]                      )                    )                  ]),                  t.ifStatement(                    t.unaryExpression("!", t.identifier("element"), true),                    t.blockStatement(                      [                        t.variableDeclaration("const", [                          t.variableDeclarator(                            t.identifier("styleBlock"),                            t.callExpression(                              t.memberExpression(                                t.identifier("document"),                                t.identifier("createElement")                              ),                              [t.stringLiteral("style")]                            )                          )                        ]),                        t.expressionStatement(                          t.assignmentExpression(                            "=",                            t.memberExpression(                              t.identifier("styleBlock"),                              t.identifier("innerHTML")                            ),                            t.identifier("styles")                          )                        ),                        t.expressionStatement(                          t.callExpression(                            t.memberExpression(                              t.identifier("styleBlock"),                              t.identifier("setAttribute")                            ),                            [                              t.stringLiteral("data-sass-component"),                              t.identifier("fileName")                            ]                          )                        ),                        t.expressionStatement(                          t.callExpression(                            t.memberExpression(                              t.memberExpression(                                t.identifier("document"),                                t.identifier("head"),                                false                              ),                              t.identifier("appendChild"),                              false                            ),                            [t.identifier("styleBlock")]                          )                        )                      ],                      []                    ),                    null                  )                ],                []              ),              false,              false            ),            []          )        );        }    }}

Итоговые радости


Ура!!! Импорт заменился на вызов функции, которая напихала в head документа стиль с этой кнопкой. И тут я подумал, а что если я стартану всю эту байдарку через вебпак, выкосив sass-loader? Будет ли оно работать? Окей, выкашиваем и проверяем. Запускаю сборку вебпаком, жду ошибку, что я должен определить loader для этого типа файла А ошибки-то нет, всё собралось. Открываю страницу, смотрю, а стиль воткнулся в head документа. Интересно получилось, я ещё избавился от 3 лоадеров для стилей(очень довольная улыбка).

Если вам была интересна статья поддержите звездочкой на github.

Так же ссылка на npm пакет: www.npmjs.com/package/babel-plugin-transform-scss

Примечание: Вне статьи добавлена проверка на импорт стиля по типу import styles from './styles.scss'
Подробнее..

Clang-Tidy для автоматического рефакторинга кода

09.11.2020 10:07:58 | Автор: admin

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


Представьте, что у вас есть большой проект на С или С++ (или даже С#), который разрабатывался много лет и многими людьми. В результате разные части проекта выглядят по-разному нет единого стиля имен переменных, функций, типов данных. То есть в разных частях проекта использовался разный coding style: где-то имена в верхнем регистре, где-то CamelCase, где-то с префиксами, в других местах без Некрасиво, в общем.


И вот вы собрались навести порядок, начав, например, с имен переменных. Решили, что все имена должны быть в верхнем регистре и с префиксом по типу данных (так называемая Венгерская нотация).


Вот такое, например, переименование:


Тут некоторые наверняка подумали: Ну, и в чем проблема? Автозамена же поможет. В крайнем случае скрипт на Python на коленке запилить


Хорошо, давайте разберем это: простая автозамена заменит всё и везде, не смотря на области видимости. Если у вас есть переменные с одинаковыми именами и в глобальной области, и в локальных, то все будет переименовано единообразно. Но мы-то можем захотеть давать разные имена, в зависимости от контекста.


Значит, автозамена не подходит. Пишем скрипт на коленке. С областями видимости разобраться не сложно. Но, представляете, переменным, функциям и даже типам данных иногда позволено иметь одинаковые имена. То есть реально, вот такая конструкция вполне себе законна (правда, только в GNU C):


typedef int Something;int main(){    int Something(Something Something)    {        return Something + Something;    }    printf("This is Something %d!\n", Something(10));    return 0;}

C:\HelloWorld.exeThis is Something 20!

Итак, наш скрипт должен уметь различать контекст, должен понимать, к какой сущности относится символ, должен находить те места, где на него ссылаются. Ничего не напоминает? Мы собираемся написать компилятор! А точнее, ту его часть, которая строит абстрактное синтаксическое дерево.


Абстрактное Синтаксическое Дерево (Abstract Syntax Tree, AST) это, по сути, ваш исходный код, разобранный на мельчайшие атомы, то есть переменные, константы, функции, типы данных и т.д., которые уложены в направленный граф.


typedef int SomeNumber;int SomeFunction(SomeNumber num){    return num + num;}int main(){       printf("This is some number = %d!\n", SomeFunction(10));    return 0;}



К счастью, строить дерево с нуля нам не нужно некоторые компиляторы настолько добрые, что готовы поделиться своим деревом с нами. Без регистрации и смс!


Где растут деревья?


Поскольку мы рассматриваем проект на С (или С++), то первым делом вспоминается великий и могучий GNU Compiler Collection, он же GCC.


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


Не менее могучий LLVM/clang также умеет экспортировать AST, но этот проект пошел еще дальше и предложил уже готовый инструмент для разбора и анализа дерева Clang-Tidy. Это инструмент 3-в-1 он генерирует дерево, анализирует его и выполняет проверки, а также автоматически вносит исправления в код там, где это нужно.


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


Лес рубят щепки летят


Для того, чтобы исследовать AST дерево своего проекта, нам понадобится Clang. Если у вас его еще нет, то готовую сборку можно скачать на странице проекта LLVM: https://clang.llvm.org/


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


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


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


Эта команда выдаст все AST дерево сразу:


clang -c -Xclang -ast-dump <filename.c>

Вы наверняка удивитесь, насколько оно огромное. Для моего примера Hello World выше, из 7 строк кода, дерево получилось в 6259 строк. Это потому, что в нем будет также все, что было подключено из стандартных заголовочных файлов: типы данных, функции и т.д.


Поиск чего-то нужного в таком огромном массиве информации может вызвать уныние, поэтому удобнее использовать команду clang-query для извлечения только нужной информации. Запросы пишутся с использованием специального синтаксиса AST Matchers, который описывается вот тут


Например, следующий запрос выдаст нам весь внутренний мир функции с именем SomeFunction:


clang-query> set output dumpclang-query> match functionDecl(hasName("SomeFunction"))Match #1:Binding for "root":FunctionDecl 0x195581994f0 <C:\HelloWorld.c:5:1, line:8:1> line:5:5 used SomeFunction 'int (SomeNumber)'|-ParmVarDecl 0x19558199420 <col:18, col:29> col:29 used num 'SomeNumber':'int'`-CompoundStmt 0x19558199638 <line:6:1, line:8:1>  `-ReturnStmt 0x19558199628 <line:7:2, col:15>    `-BinaryOperator 0x19558199608 <col:9, col:15> 'int' '+'      |-ImplicitCastExpr 0x195581995d8 <col:9> 'SomeNumber':'int' <LValueToRValue>      | `-DeclRefExpr 0x19558199598 <col:9> 'SomeNumber':'int' lvalue ParmVar 0x19558199420 'num' 'SomeNumber':'int'      `-ImplicitCastExpr 0x195581995f0 <col:15> 'SomeNumber':'int' <LValueToRValue>        `-DeclRefExpr 0x195581995b8 <col:15> 'SomeNumber':'int' lvalue ParmVar 0x19558199420 'num' 'SomeNumber':'int'1 match.

Ну, и давайте попробуем запустить сам Clang-Tidy, из спортивного интереса:


C:\clang-tidy HelloWorld.c -checks=* --C:\HelloWorld.c:12:53: warning: 10 is a magic number; consider replacing it with a named constant [cppcoreguidelines-avoid-magic-numbers]        printf("This is some number = %d!\n", SomeFunction(10));                                                           ^C:\HelloWorld.c:12:53: warning: 10 is a magic number; consider replacing it with a named constant [readability-magic-numbers]

Работает! И даже встроенные чекеры подают признаки жизни.


Пристегнитесь крепче начинаем кодировать!


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


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


Еще понадобится cmake, вот здесь


Удобство проектов, написанных с использованием cmake, заключается в том, что можно автоматически сгенерировать проект для нескольких разных сред разработки. У меня, например, Visual Studio 2019 для Windows, поэтому мой алгоритм получения рабочего проекта выглядит так:


git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mkdir build
cd build
cmake -DLLVM_ENABLE_PROJECTS='clang;clang-tools-extra' -G 'Visual Studio 16 2019' -A x64 -Thost=x64 ../llvm

После этих шагов будет сгенерирован LLVM.sln, который можно открывать в Visual Studio и собирать нужные компоненты. Минимальный набор: сам clang-tidy и clang-apply-replacements. Если времени не жалко совсем, то можно построить и весь LLVM, но в целом этого не требуется для нашей задачи.


Интересующие нас исходники находятся в llvm\clang-tools-extra\clang-tidy. Здесь можно посмотреть на примеры других чекеров, то есть модулей в Clang-Tidy для выполнения различных проверок. Они сгруппированы по нескольким категориям, таким как readability, portability, performance и т.д. Их назначение, в принципе, понятно из названия. Здесь же есть скрипт, который поможет нам сгенерировать заготовку для своего чекера:


python add_new_check.py misc ultra-cool-variable-renamer

Здесь misc это категория, в которую мы определили наш чекер, а ultra-cool-variable-renamer это имя нашего чекера.


Скрипт-генератор создаст несколько новых файлов, в том числе для документации и тестов. Но нам на данном шаге интересны только два, в папке misc: UltraCoolVariableRenamer.h и UltraCoolVariableRenamer.cpp


Важный момент: поскольку наш проект для Visual Studio был сгенерирован с помощью cmake, то новые файлы сами по себе в проект не попадут. Для этого нужно перезапустить cmake еще раз, и он обновит проект автоматически.


Собираем и запускаем Clang-Tidy. Видим, что наш чекер появился и показывает сообщения из
сгенерированного чекера-заготовки, радуемся этому:


C:\clang-tidy HelloWorld.c -header-filter=.* -checks=-*,misc-ultra-cool-variable-renamer 354 warnings generated.C:\HelloWorld.c:5:5: warning: function 'SomeFunction' is insufficiently awesome [misc-ultra-cool-variable-renamer]int SomeFunction(SomeNumber num)    ^C:\HelloWorld.c:5:5: note: insert 'awesome'int SomeFunction(SomeNumber num)    ^    awesome_C:\HelloWorld.c:10:5: warning: function 'main' is insufficiently awesome [misc-ultra-cool-variable-renamer]int main()    ^C:\HelloWorld.c:10:5: note: insert 'awesome'int main()    ^    awesome_

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


Если посмотреть на код самого чекера, то мы увидим что он состоит из всего двух методов: registerMatchers() и check().


void UltraCoolVariableRenamerCheck::registerMatchers(MatchFinder* Finder) {  // FIXME: Add matchers.  Finder->addMatcher(functionDecl().bind("x"), this);}void UltraCoolVariableRenamerCheck::check(const MatchFinder::MatchResult& Result) {  // FIXME: Add callback implementation.  const auto* MatchedDecl = Result.Nodes.getNodeAs<FunctionDecl>("x");  if (MatchedDecl->getName().startswith("awesome_"))    return;  diag(MatchedDecl->getLocation(), "function %0 is insufficiently awesome")    << MatchedDecl;  diag(MatchedDecl->getLocation(), "insert 'awesome'", DiagnosticIDs::Note)    << FixItHint::CreateInsertion(MatchedDecl->getLocation(), "awesome_");}

Метод registerMatchers()вызывается один раз, при старте Clang-Tidy, и нужен для того, чтобы добавить правила для отлова нужных нам мест в AST дереве. При этом здесь применяется такой же синтаксис AST matchers, как мы использовали раньше в clang-query. Затем для каждого срабатывания зарегистрированного правила будет вызван метод check().


Сгенерированная заготовка чекера регистрирует только одно правило, для поиска объявлений функций. Для каждого такого объявления будет вызван метод check(), который и содержит всю логику для проверки кода. Нам же, для нашей задачи, нужно проверять переменные, поэтому поменяем этот код на:


  auto VariableDeclaration = varDecl();  Finder->addMatcher(VariableDeclaration.bind("variable_declaration"), this);  auto VariableReference = declRefExpr(to(varDecl()));  Finder->addMatcher(VariableReference.bind("variable_reference"), this);

Поскольку мы хотим переименовать переменные не только в месте их объявления, но и везде, где они используются, нам нужно зарегистрировать два правила. Обратите внимание на то, что для регистрации правила нужно использовать тот тип объекта из AST дерева, который мы хотим обработать. То есть varDecl для объявлений переменных и declRefExpr для ссылок на какой-либо объявленный объект. Поскольку объявленным объектом может быть не только переменная, то здесь мы применяем еще дополнительный критерий to(varDecl()), чтобы отфильтровать только ссылки на переменные.


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


void UltraCoolVariableRenamerCheck::check(const MatchFinder::MatchResult& Result) {  const DeclRefExpr* VariableRef = Result.Nodes.getNodeAs<DeclRefExpr>("variable_reference");  const VarDecl* VariableDecl = Result.Nodes.getNodeAs<VarDecl>("variable_declaration");  SourceLocation location;  StringRef name;  StringRef type;  if (VariableDecl) {    location = VariableDecl->getLocation();    name = VariableDecl->getName();    type = StringRef(VariableDecl->getType().getAsString());      } else if (VariableRef) {    location = VariableRef->getLocation();    name = VariableRef->getDecl()->getName();    type = StringRef(VariableRef->getDecl()->getType().getAsString());  } else {    return;  }  if (!checkVarName(name, type)) {       diag(location, "variable '%0' does not comply with the coding style")        << name;  }}

Что здесь происходит: метод check() может быть вызван как для объявления переменной, так и для ее использования. Обрабатываем оба этих варианта. Дальше функция checkVarName() (оставим ее содержимое за кадром) проверяет соответствие имени переменной принятому нами стилю кодирования, и если соответствия нет, то показываем предупреждение.


Убеждаемся, что мы видим это предупреждение для всех трех мест, где встречается переменная в нашем коде:


C:\HelloWorld.c:5:29: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]int SomeFunction(SomeNumber num)                            ^C:\HelloWorld.c:7:9: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]        return num + num;               ^C:\HelloWorld.c:7:15: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]        return num + num;                     ^

Больше трюков добавляем исправление


На самом деле, мы уже почти все сделали. Чтобы Clang-Tidy не только показывал предупреждения, но и сам вносил исправления, нам осталось добавить пару строчек для того, чтобы описать, как именно и что нужно исправить.


Давайте напишем функцию generateVarName(StringRef oldVarName, StringRef varType), которая будет переводить прежнее имя переменной в верхний регистр и, в зависимости от типа данных, добавлять к ней префикс (примитивное содержимое этой функции также оставим за скобками).


Все, что нам осталось, это добавить в вывод diag правило замены, указав, где нужно заменить и на что:


  if (!checkVarName(name, type)) {       diag(location, "variable '%0' does not comply with the coding style")        << name       << type;      diag(location, "replace to '%0'", DiagnosticIDs::Note)        << generateVarName(name, type)        << FixItHint::CreateReplacement(location, generateVarName(name, type));  }

Теперь, если мы запустим Clang-Tidy еще раз, то увидим не только сообщения о неправильных именах, но и предложенные исправления. А если запустим его с ключом -fix, то эти исправления будут автоматически внесены в исходный код.


C:\HelloWorld.c:5:29: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]int SomeFunction(SomeNumber num)                            ^C:\HelloWorld.c:5:29: note: replace to 'snNUM'int SomeFunction(SomeNumber num)                            ^~~                            'snNUM'C:\HelloWorld.c:7:9: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]        return num + num;               ^C:\HelloWorld.c:7:9: note: replace to 'snNUM'        return num + num;               ^~~               'snNUM'C:\HelloWorld.c:7:15: warning: variable 'num' does not comply with the coding style [misc-ultra-cool-variable-renamer]        return num + num;                     ^C:\HelloWorld.c:7:15: note: replace to 'snNUM'        return num + num;                     ^~~                     'snNUM'

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


Бочку мёда видим, где же дёготь?


Что ж, метод хорош, но как же без ограничений? Главное ограничение исходит из самой идеи использования синтаксического дерева: в поле зрения попадет и будет обработано только то, что есть в дереве. То, что в дерево не попало, останется без изменений.


Почему вдруг это может быть плохо?


  • То, что выключено условной компиляцией, в дерево не попадет. Помните про то, что мы строим дерево, используя реальные параметры вашего проекта? Поэтому, если в вашем проекте есть части кода, которые выглядят, например, вот так:


    #if ARCH==ARMdo_something();#elif ARCH==MIPSdo_something_else();#endif
    

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


  • То, что не является исходным кодом, тоже не попадет в дерево. То есть комментарии в исходниках останутся нетронутыми. Если у вас в комментариях упоминаются ваши переменные или функции, то придется вам их обновлять вручную. Еще можно попробовать автоматизировать это внешними средствами, используя информацию о переименованиях, которую Clang-Tidy умеет экспортировать.


  • Не так все просто с макросами. Они обрабатываются предпроцессором еще до того, как запускается сама компиляция. Это означает, что для правильного переименования символов, которые взаимодействуют с макросами, нужно будет предпринимать дополнительные усилия. Также макросы не присутствуют в AST дереве, хотя возможность их обнаружения и переименования имеется для этого нужно использовать PPCallbacks.



Как глубока кроличья нора?


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


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


В качестве отправной точки для изучения того, какие задачи можно решать с помощью Clang-Tidy и как это можно делать, я советую посмотреть на исходный код других чекеров, который находится там же, в папке с исходниками Clang-Tidy. Это отличное дополнение к официальной документации LLVM, которая, честно говоря, не блещет многословием. Особенно интересно подсмотреть, как сделаны чекеры от Google.


Напоследок хочется пожелать никогда не сдаваться, проявлять изобретательность и фантазию, и конечно удачи!

Подробнее..

Bison, dynamic linking и обработка BMP изображений

24.11.2020 02:08:56 | Автор: admin

В этом посте я постараюсь раскрыть тему написания несложного модульного приложения на языке C89 для обработки 24-битных BMP изображений, использующего в своей работе простой скриптовый язык.

Если вы заинтересованы в разработке языков или вам необходимо быстро разобраться как писать приложения, динамически загружающие исполняемый код, хранящийся в разделяемых библиотеках (dll, so), то возможно вам будет интересен данный материал.

Постановка задачи

Задача примерно следующая: разработать инструмент для обработки BMP (как самый простой формат) изображений с 24-битным кодированием цветов с возможностью расширения функционала без перекомпиляции и скриптовым языком для использования инструмента.

Разработка скриптового языка

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

Язык должен позволять в заданном порядке вызывать трансформации для обработки изображения, при этом должна быть возможность передавать трансформации параметры при вызове. Например, для поворота картинки это должен быть угол поворота, и т.п. Намечается следующий синтаксис (РБНФ):

script = { transformation ";" } ;transformation = [ T_IDENTIFIER "." ] T_IDENTIFIER "(" transformation_args ")" ;transformation_args = [ literal { "," literal } ] ;literal = T_INTEGER | T_FLOATING | T_STRING | T_IDENTIFIER ;

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

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

По описанию синтаксиса сразу очевидно, какие типы узлов АСД нам необходимы: script, transformation, transformation_args и literal. Исходный код их определения ниже:

Определение узлов дерева
struct ast_position {    uint32_t row;    uint32_t col;};enum ast_literal_type {    L_INTEGER,    L_FLOATING,    L_STRING,    L_IDENTIFIER};struct ast_literal {    enum ast_literal_type type;    char * value;    struct ast_position pos;};struct ast_transformation_args {    struct ast_literal argument;    struct ast_transformation_args * next;};struct ast_transformation {    char * module;    char * name;    struct ast_transformation_args * args;    struct ast_position pos;};struct ast_script {    struct ast_transformation transformation;    struct ast_script * next;};

Структура ast_position потребуется позже и будет описана ближе к концу. Можно заметить, что структуры ast_script и ast_transformation_args являются односвязными списками, потому что так их удобнее конструировать и работать с ними.

Написание парсера

Писать парсер, как и было упомянуто в заголовке статьи, будем с помощью Bison, использоваться он будет в связке с GNU Flex, потому что они, по понятным причинам, хорошо совместимы друг с другом и их удобно использовать вместе. Несмотря на то, что кажется логичным начать написание парсера с описания лексики для Flex, на самом деле более простым будет начать с описания синтаксиса языка.

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

file    : script YYEOF  { *result = ast_script_reverse($1); }    ;script    : /* empty */               { $$ = NULL; }    | script transformation ';' { $$ = ast_script_new($2, $1); }    ;transformation    : T_IDENTIFIER '(' transformation_args ')'                  {        $$ = ast_transformation_create(NULL, $1, ast_transformation_args_reverse($3),                ast_position_create(@$.first_line, @$.first_column));    }    | T_IDENTIFIER '.' T_IDENTIFIER '(' transformation_args ')' {        $$ = ast_transformation_create($1, $3, ast_transformation_args_reverse($5),                ast_position_create(@$.first_line, @$.first_column));    }    ;transformation_args    : /* empty */               { $$ = NULL; }    | transformation_args_req   { $$ = $1; }    ;transformation_args_req    : literal                               { $$ = ast_transformation_args_new($1, NULL); }    | transformation_args_req ',' literal   { $$ = ast_transformation_args_new($3, $1); }    ;literal    : T_INTEGER     { $$ = ast_literal_create(L_INTEGER, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_FLOATING    { $$ = ast_literal_create(L_FLOATING, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_STRING      { $$ = ast_literal_create(L_STRING, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_IDENTIFIER  { $$ = ast_literal_create(L_IDENTIFIER, $1, ast_position_create(@$.first_line, @$.first_column)); }    ;

Здесь кроме определения правил синтаксиса можно видеть также код на Си, заключённый в фигурные скобки. Для тех, кто не знаком с синтаксисом файла грамматики Bison, поясняю: это действия, которые выполняет сгенерированный парсер, когда сворачивает правило. Свёрткой я здесь и далее называю reduce, операцию объединения нескольких определённых в ходе парсинга символов в один нетерминальный символ, при этом все "семантические значения" символов, которые были свёрнуты доступны по именам $n, где n - это номер символа в описании, начиная с 1. Так, в определении правила script, в его второй строке, под номером 1 будет нетерминал script, использованный при рекурсивной свёртке, а под номером 2, соответственно, трансформация. Здесь же стоит обратить внимание на переменную $$, в которую записывается значение, соответствующее текущему сворачиваемому символу. Из кода можно понять, что переменная @$ означает текущее положение в анализируемом тексте, но об этом позже.

Отдельно стоит отметить наличие здесь правила file, единственным назначением которого является запись в переменную и разворачивание списка script. К сожалению, по умолчанию генерируемый Bison парсер ни в каком виде не возвращает семантическое значение последнего свёртного правила, поэтому приходится явно записывать его в переменную. Для того, чтобы записывать в переменную что-либо, необходимо сначала эту переменную получить, поэтому здесь мы пользуемся возможностями Bison, и указываем в первой секции файла (предполагается, что читатель ознакомлен с общим синтаксисом файла) директиву %parse-param с указанием определения аргумента функции yyparse, генерируемой Bison. Цельный код получившегося парсера будет представлен ниже.

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

Последнее, на чём здесь хотелось бы заострить внимание, это дополнительное правило transformation_args_req, которое необходимо для корректного парсинга списка, разделённого запятыми. Если его не вводить, не будет возможности построить правило таким образом, чтобы нельзя было оставить "висячую" запятую в начале или в конце списка (или я не знаю как, если есть решение, добро пожаловать в комментарии).

На этом этапе кажется, что можно было бы перейти к описанию лексера, однако есть несколько тонкостей в работе Bison, поэтому придётся написать ещё некоторое количество кода, чтобы парсер компилировался и работал. Под спойлером привожу код парсера, получившийся в итоге:

parser.y
%parse-param {struct ast_script ** result} {char ** error}%{#include <string.h>#include <stdlib.h>#include <stdio.h>#include "ast.h"int yylex(void);void yyerror(struct ast_script ** result, char ** error, const char * str);%}%code requires {#include "ast.h"}%union {    struct ast_script * script;    struct ast_transformation transformation;    struct ast_transformation_args * transformation_args;    struct ast_literal literal;    char * token;}%token T_IDENTIFIER T_INTEGER T_FLOATING T_STRING%type<script> script%type<transformation> transformation%type<transformation_args> transformation_args%type<literal> literal%type<token> T_IDENTIFIER T_INTEGER T_FLOATING T_STRING%%file    : script YYEOF  { *result = ast_script_reverse($1); }    ;script    : /* empty */               { $$ = NULL; }    | script transformation ';' { $$ = ast_script_new($2, $1); }    ;transformation    : T_IDENTIFIER '(' transformation_args ')'                  {        $$ = ast_transformation_create(NULL, $1, ast_transformation_args_reverse($3),                ast_position_create(@$.first_line, @$.first_column));    }    | T_IDENTIFIER '.' T_IDENTIFIER '(' transformation_args ')' {        $$ = ast_transformation_create($1, $3, ast_transformation_args_reverse($5),                ast_position_create(@$.first_line, @$.first_column));    }    ;transformation_args    : /* empty */               { $$ = NULL; }    | transformation_args_req   { $$ = $1; }    ;transformation_args_req    : literal                               { $$ = ast_transformation_args_new($1, NULL); }    | transformation_args_req ',' literal   { $$ = ast_transformation_args_new($3, $1); }    ;literal    : T_INTEGER     { $$ = ast_literal_create(L_INTEGER, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_FLOATING    { $$ = ast_literal_create(L_FLOATING, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_STRING      { $$ = ast_literal_create(L_STRING, $1, ast_position_create(@$.first_line, @$.first_column)); }    | T_IDENTIFIER  { $$ = ast_literal_create(L_IDENTIFIER, $1, ast_position_create(@$.first_line, @$.first_column)); }    ;%%void yyerror(struct ast_script ** result, char ** error, const char * str) {    free(*error);    *error = malloc(strlen(str) + 56);    sprintf(*error, "at %d:%d: %s", yylloc.first_line, yylloc.first_column, str);}

В секции объявлений (первой секции) можно заметить уже упомянутую ранее директиву %parse-param, в которой определены два аргумента для будущего парсера: указатель на переменную с результатом и указатель на переменную с ошибкой. Ниже идёт код вставляемый в результирующий файл с парсером до определения функции yyparse, в частности включение заголовка с описанием АСД и объявлений функций, необходимых для работы парсера (подробнее в руководстве пользователя). Ниже идёт снова включение заголовочника с АСД, но которое будет включено в сгенерированный Bison заголовочный файл (подробнее в указанном выше руководстве); директива %union, превращающая тип семантического значения правил в сишный union, определение которого следует за директивой; перечисление терминальных символов (токенов), которые могут быть получены парсером на вход и, в заключение, соответствие каждого из символов полю определённого выше юниона, необходимое для корректного сопоставления полей юниона при подстановке в действиях.

В конце файла, в эпилоге, определение функции yyerror, которая вызывается парсером в случае возникновения ошибки. Здесь данная функция форматирует и передаёт выше, в параметр парсера error, полученную ошибку (внимательный читатель может заметить, что для форматирования используется функция sprintf, а не snprintf, однако её использование невозможно за неимением оной в стандарте C89).

Создание лексера

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

%option noyywrap noinput nounput%{#include <string.h>#include "parser.h"#include "util.h"extern YYLTYPE yylloc;int fileno(FILE *stream);void update_yylloc() {    size_t i;    yylloc.first_line = yylloc.last_line;    yylloc.first_column = yylloc.last_column;    for (i = 0; i < yyleng; ++i) {        switch (yytext[i]) {        case '\n':            ++yylloc.last_line;            yylloc.last_column = 1;            break;        default:            ++yylloc.last_column;        }    }}%}S [ \b\n\t\f\r]W [a-zA-Z_]D [0-9]I {W}({W}|{D})*%%{S}         update_yylloc();#.*$        update_yylloc();{I}         update_yylloc(); yylval.token = strdup(yytext); return T_IDENTIFIER;{D}+                update_yylloc(); yylval.token = strdup(yytext); return T_INTEGER;{D}*\.{D}+          update_yylloc(); yylval.token = strdup(yytext); return T_FLOATING;\"(\\.|[^"\\])*\"   update_yylloc(); yylval.token = strdup(yytext); return T_STRING;.           update_yylloc(); return yytext[0];

Здесь мы видим указание некоторых опций, влияющих на то, как будет генерироваться лексер, подробнее о каждой из них можно узнать в руководстве. Ниже идёт код, необходимый для работы, в частности включение некоторых заголовочных файлов (в том числе файл, генерируемый парсером, для использования имён токенов, определённых в этом файле); объявление глобальной переменной парсера yylloc, использующейся для определения местоположения в файле; объявление функции fileno, потому что у меня не получилось подключить её с помощью заголовочного файла в C89. И определение вспомогательной функции, которая также используется для подсчёта местоположения в файле. Затем идут определения имён, которые могут быть использованы в правилах, в формате имя значение, где значение - это регулярное выражение Flex.

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

Единственное, что, возможно, заслуживает отдельного внимания, это наличие в коде переменной yylval. Эта глобальная переменная генерируется Bison и предназначена для хранения семантического значения текущего символа. В лексере мы задаём её значение, чтобы в парсере иметь возможность сохранить значение токена в АСД.

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

Реализация интерпретатора

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

Я уже упоминал выше, что наше приложение должно иметь возможность работать с внешними библиотеками, поэтому будет удобным ввести для интерпретатора структуру и построить для неё некоторый жизненный цикл:

/* interpreter.h */#include "ast.h"struct interpreter_ids;struct interpreter {    const char * modules_prefix;    const struct ast_script * script;    struct interpreter_ids * identifiers;};struct interpreter interpreter_create(const struct ast_script * script);void interpreter_discard(struct interpreter interpreter);const char * interpreter_process_script(struct interpreter * interpreter);const char * interpreter_run(const struct interpreter interpreter, struct image * image);/* interpreter.c */struct interpreter_ids {    const char * module;    const char * name;    void * handle;    void * symbol;    struct interpreter_ids * next;};

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

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

Динамическая загрузка символов

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

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

const char * interpreter_load_symbol(struct interpreter * interpreter, const char * module, const char * name) {    static char * error = NULL;    void * handle;    void * symbol;    if (interpreter_ids_lookup(interpreter->identifiers, module, name)) {        return NULL;    }    if (strset(&error, interpreter_load_module(*interpreter, &handle, module))) {        return error;    }    if (strset(&error, interpreter_do_load_symbol(&symbol, handle, name))) {        dlclose(handle);        return error;    }    interpreter->identifiers = interpreter_ids_new(module, name, handle, symbol, interpreter->identifiers);    return NULL;}

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

const char * interpreter_do_load_module(void ** handle, const char * filename) {    if (!(*handle = dlopen(filename, RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND))) {        return dlerror();    }    return NULL;}const char * interpreter_load_module(const struct interpreter interpreter, void ** handle, const char * module) {    size_t prefix_length, module_length;    static char * error = NULL;    char * filename;    if (!module) {        return strset(&error, interpreter_do_load_module(handle, NULL));    }    prefix_length = strlen(interpreter.modules_prefix);    module_length = strlen(module);    filename = malloc(sizeof(char) * (prefix_length + module_length + 4));    sprintf(filename, "%s%s.so", interpreter.modules_prefix, module);    if (!strset(&error, interpreter_do_load_module(handle, filename))) {        free(filename);        return NULL;    }    sprintf(filename, "%s%s", interpreter.modules_prefix, module);    if (!interpreter_do_load_module(handle, filename)) {        free(filename);        return NULL;    }    free(filename);    return error;}const char * interpreter_do_load_symbol(void ** symbol, void * handle, const char * name) {    dlerror();    *symbol = dlsym(handle, name);    return dlerror();}

В первой из приведённых функций используется вызов dlopen для открытия файла и получения хендлера, вторая используется для формирования имени файла модуля, а третья - для загрузки непосредственно символа из файла с помощью функции dlsym. На самом деле, об обеих из этих библиотечных функций достаточно обширно написано в man-страницах dlopen(3) и dlsym(3), что может быть особенно удобно если у вас установлена русская версия man. Однако, для полноты статьи я всё же разверну некоторые ньюансы работы.

Вызов dlopen принимает в качестве первого аргумента имя файла для загрузки и набор флагов во втором аргументе. Если имя файла содержит слеш (/), то файл ищется по абсолютному или относительному пути, относительно текущей директории запуска. Иначе используется следующий порядок разрешения имени:

  1. (только в ELF) Если исполняемый файл вызывающей программы содержит метку DT_RPATH, и не содержит метки DT_RUNPATH, то производится поиск в каталогах, описанных в метке DT_RPATH;

  2. Если при запуске программы была определена переменная окружения LD_LIBRARY_PATH, содержащаясписок каталогов через двоеточие,топроизводится поискв этих каталогах (посоображениям безопасности эта переменная игнорируется для программ с установленными битами set-user-ID и set-group-ID);

  3. (только в ELF) Если исполняемый файл вызывающей программы содержит метку DT_RUNPATH, то производится поиск по каталогам, перечисленным в этой метке;

  4. Производится проверка в кэширующем файле /etc/ld.so.cache (обслуживается ldconfig(8)) на предмет наличия записи для filename;

  5. Просматриваются каталоги /lib и /usr/lib (именно в таком порядке).

В качестве флагов необходимо передать один из двух следующих:

  • RTLD_LAZY - выполнять позднее связывание (lazy binding). Выполняется поиск только тех символов, на которые есть ссылки из кода. Если на символ никогда не ссылаются, то он никогда не будет разрешён (позднее связывание выполняется только при ссылке на функции; ссылки на переменные всегда привязываются сразу при загрузке общего объекта). Начиная с libc 2.1.1, этот флаг заменяется на значение переменной окружения LD_BIND_NOW;

  • RTLD_NOW - если указано данное значение или переменная окружения LD_BIND_NOW не пуста, то все неопределённые символы в общем объекте ищутся до возврата из dlopen(). Если этого сделать не удаётся, то возвращается ошибка.

Кроме того, вместе с одним из указанных выше флагов можно указать (используя побитовую операцию ИЛИ) один из следующих флагов:

  • RTLD_LOCAL - противоположность RTLD_GLOBAL, используется по умолчанию, если не задано ни одного флага. Символы, определённые в этом общем объекте, не будут доступны при разрешении ссылок для общих объектов, загружаемых далее;

  • RTLD_NODELETE (начиная с glibc 2.2) - не выгружать общий объект при dlclose(). В результате статические переменные объекта не инициализируются повторно, если объект загружается снова по dlopen();

  • RTLD_NOLOAD (начиная с glibc 2.2) - не загружать общий объект. Это можно использовать для тестирования того, что объект уже загружен (dlopen() возвращает NULL, если нет, или описатель объекта в противном случае). Данный флаг также можно использовать для изменения флагов уже загруженного объекта. Например, общий объект, который был загружен ранее с RTLD_LOCAL, можно открыть повторно с RTLD_NOLOAD | RTLD_GLOBAL;

  • RTLD_DEEPBIND (начиная с glibc 2.3.4) - поиск символов будет осуществляться перед поиском в области глобальных символов. Это означает, что самодостаточный объект будет использовать свои собственные символы вместо глобальных символов с тем же именем, содержащихся в объектах, которые уже были загружены.

Кроме того, если в качестве имени файла для dlopen передать NULL, будет получен хендлер для главной программы. При поиске символов с передачей данного хендлера будет производиться поиск сначала среди символов главной программы, затем во всех объектах, загруженных автоматически при запуске программы, затем в символах, загруженных с использованием флага RTLD_GLOBAL. Из ньюансов работы стоит выделить, что поиск производится только среди общих объектов, поэтому для поиска символов исполняемого файла следует скомпилировать его с указанием опции GNU gcc -rdynamic, что соответствует опции GNU ld --export-dynamic.

Здесь стоит оговориться, что всё выше описанное актуально в первую очередь для ОС GNU/Linux и может отличаться для других ОС, в частности использование опций компилятора и линкера.

Все операции по динамической загрузке в Linux предоставляются библиотеками ld.so (ld-linux.so), о подробностях работы которых можно также прочитать в man на странице ld.so(8). В частности, на указанной man-странице представлена информация о влиянии переменных окружения на процесс загрузки.

Запуск скрипта

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

typedef const char * (* transformation_function)(struct image * image, uint32_t argc, const struct value * argv);

Где структура value определена следующим образом:

struct value {    enum {        V_INTEGER,        V_FLOATING,        V_STRING,        V_IDENTIFIER    } type;    union {        int64_t integer;        double floating;        char * string;        void * identifier;    } value;};struct value value_from_integer(int64_t value);struct value value_from_floating(double value);struct value value_from_string(const char * value);struct value value_from_identifier(void * value);void value_discard(struct value value);bool value_is_integer(struct value value);bool value_is_floating(struct value value);bool value_is_string(struct value value);bool value_is_identifier(struct value value);int64_t value_to_integer(struct value value);double value_to_floating(struct value value);const char * value_to_string(struct value value);void * value_to_identifier(struct value value);

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

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

И, наконец, код вызова функций, которые мы получили из внешних символов:

args = interpreter_collect_args(interpreter, &argc, transformation);*((void **) (&transformation_function)) = interpreter_ids_lookup(interpreter.identifiers,transformation.module,transformation.name)->symbol;transformation_error = transformation_function(image, argc, args);interpreter_delete_args(argc, args);

Присваивание на строке 2 можно записать проще в виде простого присваивания переменной значения, однако стандарт ISO C не позволяет прямо преобразовывать тип из указателя на данные, в указатель на функцию, поэтому gcc с флагом -pedantic будет выдавать предупреждение для этой операции. Поэтому, стандартом POSIX от 2003 и 2008 годов предлагается использовать приведённый выше способ вызова функций, полученных из сырых указателей, который не противоречит стандарту C.

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

Сбор аргументов трансформаций
uint32_t interpreter_count_args(const struct ast_transformation_args * transformation_args) {    uint32_t count = 0;    for (; transformation_args; transformation_args = transformation_args->next) {        ++count;    }    return count;}struct value interpreter_parse_string_value(const char * value) {    size_t value_length = strlen(value);    size_t i, j;    char * result = malloc(sizeof(char) * (value_length - 1));    struct value result_value;    for (i = 1, j = 0; i < value_length - 1; ++i, ++j) {        if (value[i] == '\\') {            ++i;        }        result[j] = value[i];    }    result[j++] = '\0';    result_value = value_from_string(result);    free(result);    return result_value;}struct value *interpreter_collect_args(const struct interpreter interpreter, uint32_t * argc, const struct ast_transformation transformation) {    struct value * args = malloc(sizeof(struct value) * (*argc = interpreter_count_args(transformation.args)));    const struct ast_transformation_args * next = transformation.args;    uint32_t i;    for (i = 0; next; next = next->next, ++i) {        switch (next->argument.type) {        case L_INTEGER:            args[i].type = V_INTEGER;            sscanf(next->argument.value, "%ld", &(args[i].value.integer));            break;        case L_FLOATING:            args[i].type = V_FLOATING;            sscanf(next->argument.value, "%lf", &(args[i].value.floating));            break;        case L_STRING:            args[i] = interpreter_parse_string_value(next->argument.value);            break;        case L_IDENTIFIER:            args[i] = value_from_identifier(interpreter_ids_lookup(                interpreter.identifiers,                transformation.module,                next->argument.value            )->symbol);            break;        default: /* fallback */            args[i].type = V_IDENTIFIER;            args[i].value.identifier = NULL;        }    }    return args;}

Отдельное внимание стоит уделить функции interpreter_parse_string_value, которая формирует из строкового литерала значение типа value. Её назначение в том, чтобы преобразовать экранированные символы (кавычки) в символы и обрезать кавычки в начале и в конце строки.

Работа с BMP изображениями

В изначальной постановке задачи было указано, что необходимо уметь читать и записывать файлы в формате BMP. И хотя на эту тему есть уже порядочное количество статей, я считаю, что стоит посвятить этому отдельный раздел.

struct __attribute__((packed)) bmp_header {    char     bfType[2];    uint32_t bfSize;    uint32_t bfReserved;    uint32_t bfOffBits;    uint32_t biSize;    int32_t  biWidth;    int32_t  biHeight;    uint16_t biPlanes;    uint16_t biBitCount;    uint32_t biCompression;    uint32_t biSizeImage;    int32_t  biXPelsPerMeter;    int32_t  biYPelsPerMeter;    uint32_t biClrUsed;    uint32_t biClrImportant;};struct bmp_pixel;struct bmp_image {    struct bmp_header header;    struct bmp_pixel * bitmap;};

Выше приведено определение структуры BMP изображения, использующееся в моей программе. Оно немного не соответствует спецификации, но его достаточно для работы с 24-битным форматом изображений. Первая структура представляет собой заголовок и предназначена для корректного считывания заголовка файла и валидации формата. Затем идёт неопредённая структура bmp_pixel, использующаяся для хранения цветов в формате BGR, использующемся в 24-битных BMP изображениях. Её определение не приведено в заголовочном файле с целью инкапсулировать работу с BMP внутри одного файла при этом оставив возможность передавать структуру bmp_image по значению. Последняя структура представляет собой всё изображение и хранит в себе заголовок и массив пикселей изображения.

Отдельное внимание стоит уделить атрибуту __attribute__((packed)), который заставляет компилятор упаковывать структуру без выравнивания. Иными словами, между полями структуры в памяти гарантировано не будет неиспользуемого пространства. Я намерено использую здесь слово "заставляет", потому что в случае, если аттрибут не поддерживается компилятором, то код не скомпилируется. В противовес аналогичному механизму #pragma pack, который также позволяет влиять на выравнивание структур данных, однако будет проигнорирован, если компилятор не знает что с ним делать. Здесь это играет важную роль, потому что если структура будет неупакована, программа не сможет работать правильно.

Если с чтением структуры из файлов никаких проблем нет, достаточно одного вызова fread, то с чтением содержимого изображения есть некоторые накладки. Кроме того, что изображение может начинаться не сразу после заголовка (из-за наличия палитры и даже при её отсутствии), сами значения цветов идут не друг за другом слева направо и сверху вниз, а слева направо и снизу вверх и с выравниванием строк изображения по четырём байтам, что означает, что в конце каждой строки необходимо разместить width % 4 байт для (Microsoft и тут палки в колёса ставит).

Код чтения и записи BMP я приведу под спойлером, а подробное описание полей структуры может быть получено в интернете, в частности на этом сайте: https://jenyay.net/Programming/Bmp.

Чтение и запись BMP изображений
const char * bmp_image_read(struct bmp_image * image, FILE * file) {    int32_t row, rowOffset;    size_t read_count = fread(&(image->header), sizeof(struct bmp_header), 1, file);    if (read_count < 1) {        return "cannot read BMP file";    }    /* Check file type signature */    if (image->header.bfType[0] != 'B' || image->header.bfType[1] != 'M') {        return "invalid BMP file";    }    if ((image->header.biSizeImage         /* Check size if biSizeImage != 0 */     && (image->header.bfSize != image->header.bfOffBits + image->header.biSizeImage))     || (image->header.biPlanes != 1)      /* Check biPlanes */     || (image->header.biBitCount != 24)   /* Check pixel bits count, only 24 is supported */     || (image->header.biCompression != 0) /* Check biCompression, only 0 is supported */    ) {        return "invalid BMP file";    }    /* Check file size */    if (fseek(file, 0L, SEEK_END)) {        return strerror(errno);    }    if (ftell(file) != image->header.bfSize) {        return strerror(errno);    }    /* Go to bitmap */    if (fseek(file, image->header.bfOffBits, SEEK_SET)) {        return strerror(errno);    }    image->bitmap = malloc(sizeof(struct bmp_pixel) * image->header.biWidth * image->header.biHeight);    rowOffset = image->header.biWidth % 4;    for (row = image->header.biHeight - 1; row >= 0; --row) {        read_count = fread(image->bitmap + row * image->header.biWidth, sizeof(struct bmp_pixel), image->header.biWidth, file);        if (read_count < image->header.biWidth) {            free(image->bitmap);            return "cannot read BMP file";        }        if (fseek(file, rowOffset, SEEK_CUR)) {            free(image->bitmap);            return strerror(errno);        }    }    return NULL;}const char * bmp_image_write(const struct bmp_image image, FILE * file) {    static uint8_t offsetBuffer[] = { 0, 0, 0 };    int32_t row, rowOffset;    if (fwrite(&(image.header), sizeof(struct bmp_header), 1, file) < 1) {        return "cannot write file";    }    rowOffset = image.header.biWidth % 4;    for (row = image.header.biHeight - 1; row >= 0; --row) {        if (fwrite(image.bitmap + row * image.header.biWidth,            sizeof(struct bmp_pixel), image.header.biWidth, file) < image.header.biWidth) {            return strerror(errno);        }        if (fwrite(offsetBuffer, 1, rowOffset, file) < rowOffset) {            return strerror(errno);        }    }    return NULL;}

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

struct pixel {    uint8_t red;    uint8_t green;    uint8_t blue;};struct image {    uint32_t width;    uint32_t height;    struct pixel * pixels;};

На этом я завершаю обзор работы с изображениями и подхожу к завершению.

Обработка ошибок

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

Определение местоположения синтаксических конструкций в файле

Другой аспект вывода сообщений об ошибках кроется в парсере. Для пользователя программы может быть очень удобно, если программа подскажет, где именно в файле произошла ошибка. Для этого необходимо на этапе лексического анализа отслеживать текущую позицию в файле, а на этапе синтаксического разбора - сохранять эту информацию в узлах дерева. В парсере, генерируемом Bison используется глобальная переменная yylloc, которая хранит в себе информацию о текущем положении парсера в файле и доступ к которой может быть получен внутри действий парсера с помощью переменных $@, для текущего правила, и @n - для сворачиваемых символов. Тип этой переменной определяется таким образом:

typedef struct YYLTYPE YYLTYPE;struct YYLTYPE{  int first_line;  int first_column;  int last_line;  int last_column;};

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

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

struct ast_position {    uint32_t row;    uint32_t col;};struct ast_literal {    enum ast_literal_type type;    char * value;    struct ast_position pos;};struct ast_transformation {    char * module;    char * name;    struct ast_transformation_args * args;    struct ast_position pos;};

Для получения местоположения того или иного символа из действий парсера, используются переменные @$ и @n (по аналогии с $$ и $n для семантических значений). Для того, чтобы нам, как разработчикам парсера, было удобнее использовать эти значения, парсер, генерируемый Bison, автоматически подсчитывает позицию для всех нетерминальных символов, исходя из позиции символов, из которых они состоят (начало конструкции устанавливается равным началу первого символа, а конец - концу последнего). Сохраняя эту информацию, мы получаем возможность выводить ошибки в более приятном для пользователя формате, добавляя в сообщения об ошибках местоположение синтаксической конструкции, которая так или иначе была связана с возникшей ошибкой.

Бонус: пример функции модуля

В качестве дополнения, приведу код интересной функции модуля, которая принимает идентификатор в качестве аргумента:

typedef struct pixel (* blur_function)(uint32_t x, uint32_t y, const struct image image);const char * do_(struct image * image, uint32_t argc, struct value * args) {    blur_function map_function;    if (argc < 1 || !value_is_identifier(args[0])) {        return "blur type (blur, dilate or erode) is required as first argument";    }    *((void **) &map_function) = value_to_identifier(args[0]);    if (map_function != blur && map_function != dilate && map_function != erode) {        return "wrong blur type, only blur, dilate or erode are allowed";    }    do_blur(*image, map_function);    return NULL;}

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

Стоит отметить, что если попробовать слинковать функцию выше в составе разделяемой библиотеки, gcc может выкинуть ошибку и любезно попросить перекомпилировать объектный файл с флагом -fPIC, который заставляет gcc генерировать позиционно-независимый код (публикация на Хабре на тему).

Заключение

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

Полный исходный код рассмотренного приложения можно найти по адресу https://github.com/ProgMiner/image-transformer/.

Ссылки

Подробнее..
Категории: Ast , Программирование , C , *nix , Flex , Gnu , Bison , Bmp , C89 , Dlopen , Dlsym

Как писать кодогенераторы в 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 монолит. Делитесь опытом и мнениями в комментариях.

Подробнее..

Категории

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

  • Имя: Макс
    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