Я хочу показать в этой статье как у нас в фирме генерируется бекенд (и немного фронтенд) код, зачем вообще это нужно и как это лучше делать.
Что именно будем генерировать — не так важно.
Важно что мы опишем 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)
Проект 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
)
Проект 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.
Другое возможное направление развития — генерировать больше фронтенда.
Весь исходный код тут.
Спасибо Ильдару с фронтенда за помощь в создании генерации у нас в проекте и при написании статьи
Автор: javax