Dans cet article, je veux montrer comment un code backend (et un peu de front-end) est généré dans notre entreprise, pourquoi est-il nécessaire et comment le faire mieux.
Ce que nous générerons exactement n'est pas si important.
Il est important de décrire 3 types d'objets sur la base desquels nous allons générer une interaction frontend avec le backend, et à certains endroits, l'implémentation du backend sera complètement
Ces types d'objets:
1.
Messages - objets qui, étant sérialisés en JSON, participent à l'échange d'informations entre le frontend et le backend
2.
Points de terminaison - URI qui appelle le frontal avec une description de la méthode HTTP, des paramètres de demande, du type de corps de demande et du type de répondeur
3.
Entités - Ce sont des messages pour lesquels ils ont des points de terminaison standard pour créer / mettre à jour / lister / supprimer (peut-être pas tous), ils sont stockés dans la base de données et pour eux il y a un objet d'accès aux données ou un référentiel Spring JPA - dépend généralement de la technologie, mais une sorte d'accès à la base de données
Je ne fais pas du tout de front, mais
1) Je sais qu'il est écrit en Typescript, donc nous générons également des classes de typescript
2) La plupart des exigences du backend proviennent des développeurs frontend.
Exigences du code
Alors, quelles sont les exigences du front-end?
1. Interface d'interaction de type REST
2. Réponses monotones - json, charge utile dans le champ «données»
3. Erreurs monotones si une exception s'est produite sur le backend, il est conseillé d'ajouter également une trace de pile
4. Codes HTTP «corrects» - 404 si le livre n'est pas trouvé, 400 si la demande est mauvaise (disons, json non valide), etc.
J'ajouterai des exigences au code principal "par moi-même":
1. Gestion des erreurs en un seul endroit
2. Possibilité d'arrêter le flux n'importe où dans le code et de renvoyer le code HTTP souhaité
3. Je veux écrire une logique métier comme bloquante et une autre asynchrone, selon les bibliothèques utilisées. Mais tout cela devrait fonctionner dans un cadre asynchrone
4. Il est conseillé aux développeurs de backend de ne pas penser aux requêtes et réponses HTTP, aux routes Vertx et aux basses d'événements, mais d'écrire simplement leur logique métier.
Il est conseillé de mettre en œuvre toutes les exigences ci-dessus avec héritage et composition, et uniquement en cas d'échec, utilisez la génération de code
Il est également conseillé de générer des classes en parallèle pour typscript et kotlin afin que le frontend envoie toujours le backend ce qui est nécessaire (et ne pas compter sur les développeurs qui n'oublieront pas d'ajouter un nouveau champ à la classe)
Que générerons-nous
Par exemple, prenez une application Web hypothétique qui peut enregistrer et modifier des livres, afficher leur liste et effectuer une recherche par nom.
En termes de technologie, le backend Kotlin, Vert.x, coroutines. Quelque chose comme ce que j'ai montré dans l'article
«Trois paradigmes de programmation asynchrone dans Vertx»Pour le rendre plus intéressant, nous allons rendre l'accès à la base de données basé sur Spring Data JPA.
Je ne dis pas que vous devez mélanger Spring et Vert.x dans un projet (bien que je le fasse moi-même, je l'admets), mais je prends juste Spring car il est plus facile pour lui de montrer la génération basée sur Entities.
Structure du projet avec génération
Vous devez maintenant créer un projet de génération.
Nous aurons de nombreux projets gradle. Maintenant, je vais les faire dans un référentiel git, mais dans la vraie vie, tout le monde devrait s'asseoir, car ils changeront à différents moments, ils auront leurs propres versions.
Ainsi, le premier projet est un projet avec des annotations qui indiqueront nos routeurs, nos méthodes HTTP, etc. Appelez-le
metainfoDeux autres projets en dépendent:
codegen et
apiL'API contient des descriptions de routeurs et de messages - ces classes qui vont et viennent entre le backend et le frontend
codegen est un projet de génération de code (mais pas un projet dans lequel le code est généré!) - il contient la collecte d'informations des classes
api et les générateurs de code réels.
Les générateurs recevront tous les détails de la génération en arguments - à partir de quel package prendre les descriptions des routeurs, dans quel répertoire générer, quel nom du modèle Velocity générer - c'est-à-dire
metainfo et
codegen peuvent généralement être utilisés dans des projets complètement différents
Eh bien, deux projets dans lesquels la génération sera effectivement réalisée:
généré par le
frontend dans lequel nous générerons la classe Typescript qui correspond à nos messages kotlin
et
backend - avec l'application Vertx réelle.
Pour qu'un projet puisse «voir» le résultat de la compilation d'un autre, nous utiliserons le plugin pour publier des artefacts dans le référentiel Maven local.
Projet Metafinfo :
Annotations avec lesquelles nous marquerons les sources de génération - descriptions des extrémités, messages, entités:
annotation class EndpointController(val url:String) annotation class Endpoint(val method: HttpMethodName, val param: String = "") 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 }
Pour les classes Typescript, nous définirons des annotations qui peuvent être accrochées aux champs et qui tomberont dans la classe Typescript générée
annotation class IsString annotation class IsEmail annotation class IsBoolean annotation class MaxLength(val len:Int)
Le code source du projet metainfoProjet Api :Notez les plugins noArg et jpa dans build.gradle pour générer des constructeurs sans arguments
Je n'ai pas assez de fantaisie, nous allons donc créer des descriptions folles de contrôleurs et d'entités pour notre application:
@EndpointController("/util") interface SearchRouter { @Endpoint(HttpMethodName.GET, param = "id") fun search(id: String): String @Endpoint(method = HttpMethodName.POST) @AsyncHandler fun search(searchRequest: SearchRequest)
Code source du projet ApiProjet Codegen :Nous définissons d'abord les «descripteurs» - ces classes que nous compléterons en parcourant la réflexion sur notre projet 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)
Le code qui recueille les informations ressemble à ceci:
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 } }
Eh bien, il y a aussi des classes «principales» qui reçoivent des arguments - sur quels paquets devraient être réfléchis, sur quels modèles Velocity utiliser, etc.
Ils ne sont pas si intéressants, vous pouvez tout regarder dans le référentiel:
Code sourceDans les projets générés par le frontend et le backend , nous faisons des choses similaires:
1. dépendance vis-à-vis de l'
api au moment de la compilation
2. dépendance à l'égard de
codegen au
stade de la construction
3. Les modèles de génération se trouvent dans le répertoire
buildSrc dans lequel les fichiers et le code sont nécessaires dans gradle, qui sont nécessaires au stade de la construction, mais pas au stade de la compilation ou de l'exécution. C'est-à-dire nous pouvons changer le modèle de génération sans recompiler le projet
codegen4.
généré par frontend compile le TypeScript généré et le publie dans le référentiel de packages npm
5. Dans le
backend , des routeurs sont générés qui héritent d'un routeur abstrait non généré qui sait comment gérer différents types de demandes. Des verticles abstraits sont également générés et doivent être hérités lors de la mise en œuvre de la logique métier elle-même. De plus, toutes sortes de petites choses sont générées auxquelles je ne veux pas penser en tant que programmeur - enregistrer des codecs et des constantes d'adresse dans la basse de l'événement.
Code source
généré par le frontend et
backendDans le
frontend, vous devez faire attention au plugin qui publie les sources générées dans le référentiel npm. Pour que cela fonctionne, vous devez mettre l'IP de votre référentiel dans build.gradle et mettre votre jeton d'authentification dans .npmrc
Les classes générées ressemblent à ceci:
import { IsString, MaxLength, IsDate, IsArray, } from 'class-validator'; import { Type } from 'class-transformer';
Faites attention aux annotations de classe-validateur tc.
Dans le projet backend, des référentiels pour Spring Data JPA sont également générés, il est possible de dire que le projet de traitement des messages dans Verticle est bloquant (et exécuté via Vertx.executeBlocking) ou asynchrone (avec des coroutines), il est possible de dire que le Verticle généré pour Entity est abstrait et qu'il y a alors une possibilité remplacer les hooks qui sont appelés avant et après l'appel des méthodes générées. Le déploiement des verticules est automatique sur l'interface des haricots de printemps - enfin, beaucoup de goodies.
Et tout cela est facile à développer - par exemple, suspendre une liste de rôles sur les points de terminaison et générer une vérification du rôle de l'utilisateur connecté lors de l'appel du point de terminaison et bien plus encore - c'est suffisant pour cela.
Il est également facile de générer non pas Vertx, ni Spring, mais autre chose - même akka-http, il suffit de modifier uniquement les modèles dans le projet backend.
Une autre direction de développement possible est de générer plus de front-end.
Tout le code source est
ici .
Merci à Ildar du front-end pour son aide dans la création de la génération dans notre projet et dans la rédaction de l'article