Я хочу показать в этой статье как у нас в фирме генерируется бекенд (и немного фронтенд) код, зачем вообще это нужно и как это лучше делать.
Что именно будем генерировать — не так важно.
Важно что мы опишем 3 вида объектов на основе которых сгенерируем взаимодействие фронтенда с бекендом, а кое где и полностью реализацию бекенда
Эти типы объектов:
1. Messages — объекты, которые будучи сериализoванными в JSON участвуют в обмене информации между фронтендом и бекендом
2. Endpoints — URI, который вызывает фронтенд вместе с описанием HTTP метода, параметров запроса, типа Request Body и типа респонса
3. Entities — Это такие messages, для которых них есть стандартные endpoints для Create/Update/List/Delete (может быть не все), они хранятся в базе данных и для них есть Data Access Object, или Spring JPA repository — вообщем зависит от технологии, но какой то доступ к базе данных
Фронтендом я не занимаюсь вообще, но
1) Знаю, что он пишется на Typescript, поэтому мы генерируем и классы тайпскрипта
2) Большая часть требований к бекенду приходит от разработчиков фронтенда.
Итак, какие есть требования со стороны фронтенда?
1. RESTподобный интерфейс взаимодействия
2. Однообразные респонсы — json, полезная нагрузка в поле 'data'
3. Однообразные ошибки если на бекенде случилось исключение, желательно также добавить stack trace
4. «правильные» HTTP коды — 404 если книга не найдена, 400 если плохой запрос (скажем, не валидный json) и т.д.
Добавлю требования к коду бекенда «от себя»:
1. Обработка ошибок в одном месте
2. Возможность в любом месте кода прекратить flow и вернуть нужный HTTP код
3. Некоторую бизнес логику я хочу писать как блокирущую, а некоторую как асинхронную, в зависимости от используемых библиотек. Но всё это должно работать в одном асинхронном фреймворке
4. Желательно, чтобы разработчики бекенда вообще не думали про HTTP реквесты и респонсы, про Vertx роуты и ивент басы, а просто писали свою бизнес логику.
Желательно все вышеупомянутые требование реализовывать наследованием и композицией и только там, где это не получается, использовать генерацию кода
Желательно также генерировать паралельно классы для тайпскрипта и котлина, чтобы всегда фронтенд посылал бекенду то, что надо (а не полагаться на разработчиков, что не забудут добавить в класс новое поле)
Для примера возьмём гипотетическое веб приложение, которое может сохранять и редактировать книги, показывать их список и искать по названию.
С точки зрения технологий на бекенде Котлин, Vert.x, корутины. Что то вроде того, что я показал в статье «Три парадигмы асинхронного программирования в Vertx»
Чтобы было интереснее, доступ к базе сделаем на основе Spring Data JPA.
Я не говорю, что надо смешивать Spring и Vert.x в одном проекте (хотя сам так делаю, признаюсь), а просто беру Spring так как для него проще всего показать генерацию на основе Entities.
Теперь нужно сделать проект для генерации.
Gradle проектов у нас будет много. Сейчас я их сделаю в одном гит репозитории, но в реальной жизни каждый должен сидеть в своём, потому что меняться они будут в разное время, у них будут свои версии.
Итак, первый проект это проект с аннотациями, которые будут обозначать наши раутеры, HTTP методы и т.д. Назовем его metainfo
От него зависят два других проекта:
codegen и api
api содержит описания раутеров и мессаджей — тех классов, который будут ходить туда-сюда между бекендом и фронтендом
codegen — проект кодегенерации (но не проект в котором генерируется код!) — он содержит сбор информации из api классов и собственно генераторы кода.
Генераторы будут получать все детали генерации в аргументах — из какого пакета брать описания раутеров, в какую директорию генерить, какое имя Velocity шаблона для генерации — т.е. metainfo и codegen можно будет вообще испольовать совсем в других проектах
Ну и два проекта в которых собственно будет осуществляться генерация:
frontend-generated в котором мы будем генерировать класс Typescript, которые соответствуют нашим котлин мессаджам
и backend — с собственно Vertx приложением.
Для того, чтобы один проект «увидел» результат компиляции другого, будем использовать плагин для публикации артифактов в локальном репоситории Maven.
Проект metafinfo:
Аннотации, которыми будем помечать источники генерации — описания endpoins, messages, entities:
Для классов Typescript мы определим аннотации, которые можно вешать на поля и которыв попадут в сгенерированный класс Typescript
Исходный код проекта metainfo
Проект api:
Обратите внимание на плагины noArg и jpa в build.gradle для генерации конструкторов без аргументов
Фантазии у меня не хватает, поэтому создадим какие то безумные описания контроллеров и Entities для нашего приложения:
Исходный код проекта api
Проект codegen:
Сначала определим «дескрипторы» — те классы, которые мы заполним, пройдясь рефлекцией по нашему проекту «api»:
Код, который собирает информацию выглядит так:
Ну и есть еще «main» классы, которые получают аргументы — по каким пакетам проходить рефлексией, какие Velocity темплейты использовать и т.д.
Они не так интересы, на всё можно посмотреть в репозитории: Исходный код
В проектах frontend-generated и backend мы делаем похожие вещи:
1. зависимость от api на этапе компиляции
2. зависимость от codegen на этапе билда
3. Шаблоны генерации находятся в директории buildSrc в которую в gradle кладут файлы и код, которые нужны на этапе билда, но не на этапе компиляции или рантайма. Т.е. мы можем менять шаблон генерации, не перекомпилируя проект codegen
4. frontend-generated компилирует сгенерированный Typescript и публикует его в репозиторий npm пакетов
5. В backend генерируются раутеры, которые наследуют от не генерируемого абстрактного раутера, который знает как обрабатывать разные типы запросов. Также генерируются абстрактные Verticles, которые надо наследовать с имплементацией собственно бизнес логики. Кроме того генерируются всякие мелочи, о которых я как программист не хочу думать — регистрация кодеков и константы адресов в ивент басе.
Исходный код frontend-generated и backend
Во frontend-generated надо обратить внимание на плагин, который публикует сгенерированные соурсы в npm репозиторий. Чтобы это работало, надо поставить IP своего репозитория в build.gradle и поставить свой токен аутентификации в .npmrc
Выглядят сгенерированные классы так:
Обратите внимание на class-validator аннотации тс.
В проекте бекенда генерируются также репозитории для Spring Data JPA, есть возможность сказать что проект обработки сообщения в Verticle блокирующий (и запускаться через Vertx.executeBlocking) или асинхронный (с корутинами), есть возможность сказать чтобы Verticle сгенерированный для Entity был абстрактный и тогда есть возможность переопределить хуки, которые вызываются до и после вызова сгенерированных методов. Деплоймент Verticles автоматический по интерфейсу спринг бинов — ну короче много плюшек.
И всё это легко расширить — например навесить на Endpoints список ролей и генерировать проверку роли залогиненого пользователя при вызове ендпоинта и многое другое — на что хватит фантазии.
Так же легко сгенерировать не Vertx, не Spring, а что то другое — хоть akka-http, достаочно только изменить темплейты в проекте backend.
Другое возможное направление развития — генерировать больше фронтенда.
Весь исходный код тут.
Спасибо Ильдару с фронтенда за помощь в создании генерации у нас в проекте и при написании статьи
Что именно будем генерировать — не так важно.
Важно что мы опишем 3 вида объектов на основе которых сгенерируем взаимодействие фронтенда с бекендом, а кое где и полностью реализацию бекенда
Эти типы объектов:
1. Messages — объекты, которые будучи сериализoванными в JSON участвуют в обмене информации между фронтендом и бекендом
2. Endpoints — URI, который вызывает фронтенд вместе с описанием HTTP метода, параметров запроса, типа Request Body и типа респонса
3. Entities — Это такие messages, для которых них есть стандартные endpoints для Create/Update/List/Delete (может быть не все), они хранятся в базе данных и для них есть Data Access Object, или Spring JPA repository — вообщем зависит от технологии, но какой то доступ к базе данных
Фронтендом я не занимаюсь вообще, но
1) Знаю, что он пишется на Typescript, поэтому мы генерируем и классы тайпскрипта
2) Большая часть требований к бекенду приходит от разработчиков фронтенда.
Требования к коду
Итак, какие есть требования со стороны фронтенда?
1. RESTподобный интерфейс взаимодействия
2. Однообразные респонсы — json, полезная нагрузка в поле 'data'
3. Однообразные ошибки если на бекенде случилось исключение, желательно также добавить stack trace
4. «правильные» HTTP коды — 404 если книга не найдена, 400 если плохой запрос (скажем, не валидный json) и т.д.
Добавлю требования к коду бекенда «от себя»:
1. Обработка ошибок в одном месте
2. Возможность в любом месте кода прекратить flow и вернуть нужный HTTP код
3. Некоторую бизнес логику я хочу писать как блокирущую, а некоторую как асинхронную, в зависимости от используемых библиотек. Но всё это должно работать в одном асинхронном фреймворке
4. Желательно, чтобы разработчики бекенда вообще не думали про HTTP реквесты и респонсы, про Vertx роуты и ивент басы, а просто писали свою бизнес логику.
Желательно все вышеупомянутые требование реализовывать наследованием и композицией и только там, где это не получается, использовать генерацию кода
Желательно также генерировать паралельно классы для тайпскрипта и котлина, чтобы всегда фронтенд посылал бекенду то, что надо (а не полагаться на разработчиков, что не забудут добавить в класс новое поле)
Что будем генерировать
Для примера возьмём гипотетическое веб приложение, которое может сохранять и редактировать книги, показывать их список и искать по названию.
С точки зрения технологий на бекенде Котлин, Vert.x, корутины. Что то вроде того, что я показал в статье «Три парадигмы асинхронного программирования в Vertx»
Чтобы было интереснее, доступ к базе сделаем на основе Spring Data JPA.
Я не говорю, что надо смешивать Spring и Vert.x в одном проекте (хотя сам так делаю, признаюсь), а просто беру Spring так как для него проще всего показать генерацию на основе Entities.
Структура проекта с генерацией
Теперь нужно сделать проект для генерации.
Gradle проектов у нас будет много. Сейчас я их сделаю в одном гит репозитории, но в реальной жизни каждый должен сидеть в своём, потому что меняться они будут в разное время, у них будут свои версии.
Итак, первый проект это проект с аннотациями, которые будут обозначать наши раутеры, HTTP методы и т.д. Назовем его metainfo
От него зависят два других проекта:
codegen и api
api содержит описания раутеров и мессаджей — тех классов, который будут ходить туда-сюда между бекендом и фронтендом
codegen — проект кодегенерации (но не проект в котором генерируется код!) — он содержит сбор информации из api классов и собственно генераторы кода.
Генераторы будут получать все детали генерации в аргументах — из какого пакета брать описания раутеров, в какую директорию генерить, какое имя Velocity шаблона для генерации — т.е. metainfo и codegen можно будет вообще испольовать совсем в других проектах
Ну и два проекта в которых собственно будет осуществляться генерация:
frontend-generated в котором мы будем генерировать класс Typescript, которые соответствуют нашим котлин мессаджам
и backend — с собственно Vertx приложением.
Для того, чтобы один проект «увидел» результат компиляции другого, будем использовать плагин для публикации артифактов в локальном репоситории Maven.
Проект metafinfo:
Аннотации, которыми будем помечать источники генерации — описания endpoins, messages, entities:
/* Contains a number of endpoints. We will generate Vert.x router or Spring MVC controller from it*/ annotation class EndpointController(val url:String) /* Endpoint inside a controller. Concrete URI and HTTP method. May be has query param */ annotation class Endpoint(val method: HttpMethodName, val param: String = "") /* For empty constructor generation */ annotation class EmptyConstructorMessage /* Make abstract implementation method for endpoint logic asynchronous */ annotation class AsyncHandler /* All the next annotations are for Entities only:*/ annotation class GenerateCreate annotation class GenerateUpdate annotation class GenerateGetById annotation class GenerateList annotation class GenerateDelete /* Make CRUD implementation abstract, so that we will override it*/ annotation class AbstractImplementation /* Generate search by this field in DAO layer */ annotation class FindBy /* This entity is child of another entity, so generate end point like /parent/$id/child to bring all children of concrete parent instead of /child - bring all entities of this type */ annotation class ChildOf(vararg val parents: KClass<*>) enum class HttpMethodName { POST,PUT,GET,DELETE }
Для классов Typescript мы определим аннотации, которые можно вешать на поля и которыв попадут в сгенерированный класс Typescript
annotation class IsString annotation class IsEmail annotation class IsBoolean annotation class MaxLength(val len:Int)
Исходный код проекта metainfo
Проект api:
Обратите внимание на плагины noArg и jpa в build.gradle для генерации конструкторов без аргументов
Фантазии у меня не хватает, поэтому создадим какие то безумные описания контроллеров и Entities для нашего приложения:
@EndpointController("/util") interface SearchRouter { @Endpoint(HttpMethodName.GET, param = "id") fun search(id: String): String @Endpoint(method = HttpMethodName.POST) @AsyncHandler fun search(searchRequest: SearchRequest) // we have no check or response type } data class SearchRequest( @field:IsString val author: String?, @field:IsEmail val someEmail: String, @field:IsString val title: String? ) @GenerateList @GenerateGetById @GenerateUpdate @Entity @AbstractImplementation data class Book( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long?, @field:IsBoolean @Column(name = "is_deleted") var hardcover: Boolean, @field:IsString @field:MaxLength(128) @Column(nullable = false, length = 128) val title: String, @field:IsString @field:MaxLength(128) @Column(nullable = false, length = 255) val author: String ) @GenerateList @GenerateGetById @GenerateUpdate @GenerateDelete @GenerateCreate @Entity @ChildOf(Book::class) data class Chapter( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long?, @Column(nullable = false, name = "book_id") var bookId: Long?, @field:IsString @field:MaxLength(128) @Column(nullable = false, length = 128) @field:FindBy val name: String, @Column(nullable = false) val page:Int )
Исходный код проекта api
Проект codegen:
Сначала определим «дескрипторы» — те классы, которые мы заполним, пройдясь рефлекцией по нашему проекту «api»:
data class EndPoint( val url: String, val input: String?, val param: String?, val output: String, val method: String, val handler: String, val asyncHandler: Boolean ) data class Router(val name: String, val url: String, val endpoints: List<EndPoint>) class Entity( name: String, val parents: List<String>, val abstractVerticle: Boolean, val crudFeatures: CrudFeatures, fields: List<Field>, var children: List<Entity> ) : Message(name, fields) { fun shouldGenerateRouterAndVerticle(): Boolean { return crudFeatures.generateRouterAndVerticle() } override fun toString(): String { return "Entity(parents=$parents, abstractVerticle=$abstractVerticle, crudFeatures=$crudFeatures, children=$children)" } } data class CrudFeatures( val list: Boolean, val create: Boolean, val update: Boolean, val delete: Boolean, val get: Boolean ) { fun generateRouterAndVerticle(): Boolean { return list || create || update || delete || get } } open class Message(val name: String, val fields: List<Field>) data class Field(val name: String, val type: String, val validators: List<Annotation>, val findBy: Boolean)
Код, который собирает информацию выглядит так:
class EntitiesCreator(typeMapper: TypeMapper, frontendAnnoPackage:String) { private val messagesDescriptor = MessagesCreator(typeMapper, frontendAnnoPackage) fun createEntities(entitiesPackage: String): List<Entity> { val reflections = Reflections(entitiesPackage, SubTypesScanner(false)) val types = reflections.getSubTypesOf(Object::class.java) return types.map { createEntity(it) } } fun createEntityRestEndpoints(entity: Entity): List<EndPoint> { val name = entity.name val url = name.toLowerCase() val endpoints: MutableList<EndPoint> = mutableListOf() if (entity.crudFeatures.create) { endpoints.add( EndPoint(url, name, null, name, "post", "handleNew$name", false) ) } if (entity.crudFeatures.get) { endpoints.add( EndPoint( "$url/:id", null, "id", name, "get", "handleGet$name", false ) ) } if (entity.crudFeatures.update) { endpoints.add( EndPoint(url, name, null, name, "put", "handleUpdate$name", false) ) } if (entity.crudFeatures.delete) { endpoints.add( EndPoint( "$url/:id", null, "id", "", "delete", "handleDelete$name", false ) ) } if (entity.crudFeatures.list) { if (entity.parents.isEmpty()) { endpoints.add( EndPoint( url, null, null, "List<$name>", "get", "handleGetAllFor$name", false ) ) } } entity.children.forEach { endpoints.add( EndPoint( "$url/:id/${it.name.toLowerCase()}", null, "id", "List<$name>", "get", "handleGet${it.name}For$name", false ) ) } return endpoints } private fun createEntity(aClass: Class<*>): Entity { return Entity( aClass.simpleName, getParents(aClass), isVerticleAbstract(aClass), shouldGenerateCrud(aClass), messagesDescriptor.createFields(aClass), listOf() ) } private fun isVerticleAbstract(aClass: Class<*>): Boolean { return aClass.getDeclaredAnnotation(AbstractImplementation::class.java) != null } private fun getParents(aClass: Class<*>): List<String> { return aClass.getDeclaredAnnotation(ChildOf::class.java)?.parents?.map { it.simpleName }?.requireNoNulls() ?: listOf() } private fun shouldGenerateCrud(aClass: Class<*>): CrudFeatures { val listAnno = aClass.getDeclaredAnnotation(GenerateList::class.java) val createAnno = aClass.getDeclaredAnnotation(GenerateCreate::class.java) val getAnno = aClass.getDeclaredAnnotation(GenerateGetById::class.java) val updateAnno = aClass.getDeclaredAnnotation(GenerateUpdate::class.java) val deleteAnno = aClass.getDeclaredAnnotation(GenerateDelete::class.java) return CrudFeatures( list = listAnno != null, create = createAnno != null, update = updateAnno != null, delete = deleteAnno != null, get = getAnno != null ) } } class MessagesCreator(private val typeMapper: TypeMapper, private val frontendAnnotationsPackageName: String) { fun createMessages(packageName: String): List<Message> { val reflections = Reflections(packageName, SubTypesScanner(false)) return reflections.allTypes.map { Class.forName(it) }.map { createMessages(it) } } private fun createMessages(aClass: Class<*>): Message { return Message(aClass.simpleName, createFields(aClass)) } fun createFields(aClass: Class<*>): List<Field> { return ReflectionUtils.getAllFields(aClass).map { createField(it) } } private fun createField(field: java.lang.reflect.Field): Field { val annotations = field.declaredAnnotations return Field( field.name, typeMapper.map(field.type), createConstraints(annotations), annotations.map { anno -> anno::annotationClass.get() }.contains(FindBy::class) ) } private fun createConstraints(annotations: Array<out Annotation>): List<Annotation> { return annotations.filter { it.toString().startsWith("@$frontendAnnotationsPackageName") } } } class RoutersCreator(private val typeMapper: TypeMapper, private val endpointsPackage:String ) { fun createRouters(): List<Router> { val reflections = Reflections(endpointsPackage, SubTypesScanner(false)) return reflections.allTypes.map { createRouter( Class.forName( it ) ) } } private fun createRouter(aClass: Class<*>): Router { return Router(aClass.simpleName, getUrl(aClass), ReflectionUtils.getAllMethods(aClass).map { createEndpoint(it) }) } private fun getUrl(aClass: Class<*>): String { return aClass.getAnnotation(EndpointController::class.java).url } private fun getEndPointMethodName(declaredAnnotation: Endpoint?): String { val httpMethodName = declaredAnnotation?.method return (httpMethodName ?: HttpMethodName.GET).name.toLowerCase() } private fun getParamName(declaredAnnotation: Endpoint?): String { val paramName = declaredAnnotation?.param return (paramName ?: "id") } private fun createEndpoint(method: Method): EndPoint { val types = method.parameterTypes val declaredAnnotation: Endpoint? = method.getDeclaredAnnotation(Endpoint::class.java) val methodName = getEndPointMethodName(declaredAnnotation) var url = method.name var input: String? = null var param: String? = null val hasInput = types.isNotEmpty() val handlerName = "$methodName${StringUtils.capitalize(url)}" if (hasInput) { val inputType = types[0] val inputTypeName = typeMapper.map(inputType) val createUrlParameterName = inputType == java.lang.String::class.java if (createUrlParameterName) { param = getParamName(declaredAnnotation) url += "/:$param" } else { input = simpleName(inputTypeName) } } return EndPoint( url, input, param, method.returnType.toString(), methodName, handlerName, isHandlerAsync(method) ) } private fun isHandlerAsync(method: Method): Boolean { val declaredAnnotation: AsyncHandler? = method.getDeclaredAnnotation(AsyncHandler::class.java) return declaredAnnotation != null } private fun simpleName(name: String): String { val index = name.lastIndexOf(".") return if (index >= 0) name.substring(index + 1) else name } }
Ну и есть еще «main» классы, которые получают аргументы — по каким пакетам проходить рефлексией, какие Velocity темплейты использовать и т.д.
Они не так интересы, на всё можно посмотреть в репозитории: Исходный код
В проектах frontend-generated и backend мы делаем похожие вещи:
1. зависимость от api на этапе компиляции
2. зависимость от codegen на этапе билда
3. Шаблоны генерации находятся в директории buildSrc в которую в gradle кладут файлы и код, которые нужны на этапе билда, но не на этапе компиляции или рантайма. Т.е. мы можем менять шаблон генерации, не перекомпилируя проект codegen
4. frontend-generated компилирует сгенерированный Typescript и публикует его в репозиторий npm пакетов
5. В backend генерируются раутеры, которые наследуют от не генерируемого абстрактного раутера, который знает как обрабатывать разные типы запросов. Также генерируются абстрактные Verticles, которые надо наследовать с имплементацией собственно бизнес логики. Кроме того генерируются всякие мелочи, о которых я как программист не хочу думать — регистрация кодеков и константы адресов в ивент басе.
Исходный код frontend-generated и backend
Во frontend-generated надо обратить внимание на плагин, который публикует сгенерированные соурсы в npm репозиторий. Чтобы это работало, надо поставить IP своего репозитория в build.gradle и поставить свой токен аутентификации в .npmrc
Выглядят сгенерированные классы так:
import { IsString, MaxLength, IsDate, IsArray, } from 'class-validator'; import { Type } from 'class-transformer'; // Entity(parents=[], abstractVerticle=false, crudFeatures=CrudFeatures(list=true, create=true, update=true, delete=true, get=true), children=[]) export class Chapter { // Field(name=bookId, type=number, validators=[], findBy=false) bookId!: number; @IsString() @MaxLength(128) // Field(name=name, type=string, validators=[@com.valapay.test.annotations.frontend.IsString(), @com.valapay.test.annotations.frontend.MaxLength(len=128)], findBy=false) name!: string; // Field(name=id, type=number, validators=[], findBy=false) id!: number; // Field(name=page, type=number, validators=[], findBy=false) page!: number; }
Обратите внимание на class-validator аннотации тс.
В проекте бекенда генерируются также репозитории для Spring Data JPA, есть возможность сказать что проект обработки сообщения в Verticle блокирующий (и запускаться через Vertx.executeBlocking) или асинхронный (с корутинами), есть возможность сказать чтобы Verticle сгенерированный для Entity был абстрактный и тогда есть возможность переопределить хуки, которые вызываются до и после вызова сгенерированных методов. Деплоймент Verticles автоматический по интерфейсу спринг бинов — ну короче много плюшек.
И всё это легко расширить — например навесить на Endpoints список ролей и генерировать проверку роли залогиненого пользователя при вызове ендпоинта и многое другое — на что хватит фантазии.
Так же легко сгенерировать не Vertx, не Spring, а что то другое — хоть akka-http, достаочно только изменить темплейты в проекте backend.
Другое возможное направление развития — генерировать больше фронтенда.
Весь исходный код тут.
Спасибо Ильдару с фронтенда за помощь в создании генерации у нас в проекте и при написании статьи
