Как стать автором
Обновить

Генерация кода для бекенда. Что генерировать, как и зачем?

Время на прочтение 11 мин
Количество просмотров 8.7K
Я хочу показать в этой статье как у нас в фирме генерируется бекенд (и немного фронтенд) код, зачем вообще это нужно и как это лучше делать.

Что именно будем генерировать — не так важно.
Важно что мы опишем 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.

Другое возможное направление развития — генерировать больше фронтенда.

Весь исходный код тут.

Спасибо Ильдару с фронтенда за помощь в создании генерации у нас в проекте и при написании статьи
Теги:
Хабы:
+12
Комментарии 8
Комментарии Комментарии 8

Публикации

Истории

Работа

Java разработчик
356 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн