Codegenerierung für das Backend. Was zu generieren, wie und warum?

In diesem Artikel möchte ich zeigen, wie ein Backend-Code (und ein bisschen Front-End-Code) in unserem Unternehmen generiert wird, warum er überhaupt benötigt wird und wie er besser funktioniert.

Was genau wir generieren werden, ist nicht so wichtig.
Es ist wichtig, dass wir drei Arten von Objekten beschreiben, auf deren Grundlage wir eine Frontend-Interaktion mit dem Backend generieren. An einigen Stellen erfolgt die Backend-Implementierung vollständig

Diese Arten von Objekten:
1. Nachrichten - Objekte, die in JSON serialisiert werden und am Informationsaustausch zwischen Frontend und Backend teilnehmen
2. Endpunkte - URI, der das Front-End zusammen mit einer Beschreibung der HTTP-Methode, der Anforderungsparameter, des Anforderungskörpertyps und des Antwortertyps aufruft
3. Entitäten - Dies sind Nachrichten, für die sie Standardendpunkte für Erstellen / Aktualisieren / Auflisten / Löschen haben (möglicherweise nicht alle), die in der Datenbank gespeichert sind und für die es ein Datenzugriffsobjekt oder ein Spring JPA-Repository gibt - abhängig von Technologie, aber eine Art Zugriff auf die Datenbank

Ich mache überhaupt kein Frontend, aber
1) Ich weiß, dass es in Typescript geschrieben ist, daher generieren wir auch Typoskriptklassen
2) Die meisten Backend-Anforderungen stammen von Frontend-Entwicklern.

Code-Anforderungen



Also, was sind die Anforderungen vom Frontend?

1. REST-ähnliche Interaktionsschnittstelle
2. Monotone Antworten - json, Nutzlast im Feld 'Daten'
3. Monotone Fehler Wenn im Backend eine Ausnahme aufgetreten ist, empfiehlt es sich, auch einen Stack-Trace hinzuzufügen
4. "korrekte" HTTP-Codes - 404, wenn das Buch nicht gefunden wird, 400, wenn die Anforderung fehlerhaft ist (z. B. ungültiger JSON) usw.

Ich werde dem Backend-Code "auf eigene Faust" Anforderungen hinzufügen:
1. Fehlerbehandlung an einer Stelle
2. Möglichkeit, den Fluss an einer beliebigen Stelle im Code zu stoppen und den gewünschten HTTP-Code zurückzugeben
3. Ich möchte eine Geschäftslogik als blockierend und einige als asynchron schreiben, abhängig von den verwendeten Bibliotheken. All dies sollte jedoch in einem asynchronen Framework funktionieren
4. Es ist ratsam, dass die Backend-Entwickler überhaupt nicht an HTTP-Anforderungen und -Antworten, an Vertx-Routen und Ereignisbässe denken, sondern einfach ihre Geschäftslogik schreiben.

Es ist ratsam, alle oben genannten Anforderungen mit Vererbung und Zusammensetzung zu implementieren und nur dort, wo dies fehlschlägt, die Codegenerierung zu verwenden

Es ist auch ratsam, parallele Klassen für das Skript und Kotlin zu generieren, damit das Front-End dem Backend immer das sendet, was benötigt wird (und sich nicht darauf verlassen, dass die Entwickler nicht vergessen, der Klasse ein neues Feld hinzuzufügen).

Was werden wir generieren



Nehmen Sie zum Beispiel eine hypothetische Webanwendung, die Bücher speichern und bearbeiten, ihre Liste anzeigen und nach Namen suchen kann.

In technologischer Hinsicht ist das Kotlin-Backend Vert.x eine Coroutine. So etwas wie das, was ich im Artikel „Drei Paradigmen der asynchronen Programmierung in Vertx“ gezeigt habe.

Um es interessanter zu machen, werden wir auf die Datenbank zugreifen, die auf Spring Data JPA basiert.
Ich sage nicht, dass Sie Spring und Vert.x in einem Projekt mischen müssen (obwohl ich es selbst mache, gebe ich zu), aber ich nehme nur Spring, da es für ihn am einfachsten ist, eine Generation basierend auf Entitäten zu zeigen.

Projektstruktur mit Generierung



Jetzt müssen Sie ein Projekt zur Generierung erstellen.
Wir werden viele Gradle-Projekte haben. Jetzt werde ich sie in einem Git-Repository erstellen, aber im wirklichen Leben sollte jeder in seinem eigenen sitzen, da sie sich zu unterschiedlichen Zeiten ändern und ihre eigenen Versionen haben.

Das erste Projekt ist also ein Projekt mit Anmerkungen, die auf unsere Router, HTTP-Methoden usw. hinweisen. Nennen wir es metainfo
Zwei weitere Projekte hängen davon ab:
Codegen und API

Die API enthält Beschreibungen von Routern und Nachrichten - die Klassen, die zwischen dem Backend und dem Frontend hin und her gehen
codegen ist ein Codegenerierungsprojekt (aber kein Projekt, in dem der Code generiert wird!) - es enthält die Sammlung von Informationen aus den API- Klassen und den eigentlichen Codegeneratoren.
Die Generatoren erhalten alle Details der Generierung in Argumenten - von welchem ​​Paket die Beschreibung der Router übernommen werden soll, in welches Verzeichnis generiert werden soll, welcher Name der Velocity-Vorlage für die Generierung - d. H. metainfo und codegen können in der Regel in völlig unterschiedlichen Projekten eingesetzt werden

Nun, zwei Projekte, in denen die Generation tatsächlich durchgeführt wird:
Frontend-generiert, in dem wir die Typescript-Klasse generieren, die unseren Kotlin-Nachrichten entspricht
und Backend - mit der eigentlichen Vertx-Anwendung.

Damit ein Projekt das Ergebnis der Kompilierung eines anderen "sehen" kann, werden wir das Plugin verwenden, um Artefakte im lokalen Maven-Repository zu veröffentlichen.

Metafinfo- Projekt :

Anmerkungen, mit denen wir Generierungsquellen markieren - Beschreibungen von Endpunkten, Nachrichten, Entitäten:
/* 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 } 

Für Typescript-Klassen definieren wir Anmerkungen, die an die Felder gehängt werden können und in die generierte Typescript-Klasse fallen
 annotation class IsString annotation class IsEmail annotation class IsBoolean annotation class MaxLength(val len:Int) 


Der Quellcode des Metainfo-Projekts

API- Projekt:

Beachten Sie die Plugins noArg und jpa in build.gradle zum Generieren von Konstruktoren ohne Argumente

Ich habe nicht genug Fantasie, daher erstellen wir einige verrückte Beschreibungen von Controllern und Entitäten für unsere Anwendung:

 @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 ) 


Quellcode des API-Projekts

Codegen- Projekt:

Zuerst definieren wir die „Deskriptoren“ - jene Klassen, die wir ausfüllen werden, indem wir die Reflexion über unser API-Projekt durchgehen:

 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) 


Der Code, der die Informationen sammelt, sieht folgendermaßen aus:
 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 } } 


Nun, es gibt auch Hauptklassen, die Argumente erhalten - welche Pakete sollten reflektiert werden, welche Velocity-Vorlagen verwendet werden sollen usw.
Sie sind nicht so interessiert, man kann sich alles im Repository ansehen: Quellcode

In Frontend-generierten und Backend- Projekten machen wir ähnliche Dinge:
1. Abhängigkeit von der API zur Kompilierungszeit
2. Abhängigkeit von Codegen in der Build-Phase
3. Generierungsvorlagen befinden sich im Verzeichnis buildSrc, in das Dateien und Code im Gradle benötigt werden, die in der Build-Phase, jedoch nicht in der Kompilierungs- oder Laufzeitphase benötigt werden. Das heißt, Wir können das Generierungsmuster ändern, ohne das Codegen- Projekt neu zu kompilieren
4. Frontend-generiert kompiliert das generierte Typescript und veröffentlicht es im npm-Paket-Repository
5. Im Backend werden Router generiert, die von einem nicht generierten abstrakten Router erben, der weiß, wie verschiedene Arten von Anforderungen behandelt werden. Es werden auch vertikale Verticles generiert, die mit der Implementierung der Geschäftslogik selbst vererbt werden müssen. Außerdem werden alle möglichen kleinen Dinge generiert, über die ich als Programmierer nicht nachdenken möchte - das Registrieren von Codecs und Adresskonstanten im Event-Bass.
Quellcode Frontend generiert und Backend

Beim Frontend-Generieren müssen Sie auf das Plugin achten, das die generierten Quellen im npm-Repository veröffentlicht. Damit dies funktioniert, müssen Sie die IP Ihres Repositorys in build.gradle und Ihr Authentifizierungstoken in .npmrc ablegen

Die generierten Klassen sehen folgendermaßen aus:
 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; } 


Beachten Sie die Anmerkungen zum Klassenvalidator tc.

Im Backend-Projekt werden auch Repositorys für Spring Data JPA generiert. Es ist möglich zu sagen, dass das Nachrichtenverarbeitungsprojekt in Verticle blockiert (und über Vertx.executeBlocking ausgeführt wird) oder asynchron (mit Coroutinen). Es ist möglich zu sagen, dass der für Entity generierte Verticle abstrakt ist, und dann besteht die Möglichkeit Überschreiben Sie Hooks, die vor und nach dem Aufruf der generierten Methoden aufgerufen werden. Die Verticles-Bereitstellung erfolgt automatisch an der Schnittstelle von Spring Beans - kurz gesagt, viele Extras.

Und all dies ist einfach zu erweitern - zum Beispiel das Aufhängen einer Liste von Rollen an Endpunkten und das Generieren einer Überprüfung der Rolle des angemeldeten Benutzers beim Aufrufen des Endpunkts und vieles mehr - was für die Vorstellungskraft ausreicht.

Es ist auch einfach, nicht Vertx, nicht Spring, sondern etwas anderes zu generieren - selbst bei akka-http reicht es aus, nur die Vorlagen im Backend-Projekt zu ändern.

Eine andere mögliche Entwicklungsrichtung besteht darin, mehr Front-End zu generieren.

Der gesamte Quellcode ist hier .

Vielen Dank an Ildar vom Frontend für die Hilfe bei der Erstellung der Generation in unserem Projekt und beim Schreiben des Artikels

Source: https://habr.com/ru/post/de450100/


All Articles