Генерація коду для CRUD-компонентів на основі файлів опису

Самер Алсайдалі

Розробка систем, які мають багато завдань зі зберігання та пошуку контенту, може включати в себе багато повторюваних і нудних дій. Припустимо, що ви розробляєте платформу електронної комерції для клієнта, і вас починають просити розробити функцію додавання полів до товарів, або додати нові фото-слайдери на головну сторінку. Якщо ви використовуєте власну CRUD-систему, це вимагатиме від вас більше часу, і ви будете менше зосереджені на моделюванні та реалізації бізнес-логіки.

Припустимо, що з якихось причин вам потрібно змінити певні елементи інтерфейсу в адміністративній панелі, або ви змінили спосіб зберігання фотографій на серверах. Тоді вам доведеться зробити це у всіх модулях, які ви вже розробили.

Наша мета — розробити спосіб автоматизувати генерацію компонентів системи управління контентом у вашій системі, щоб розробники могли більше часу зосередитися на моделюванні бізнес-логіки та скоротити час на внесення модифікацій.

Чому не використовувати CMS?

CMS забезпечують ту ж саму мету, але з огляду на продуктивність. Ці системи використовують ті ж самі таблиці та поля для представлення сутностей. Ми припускаємо, що у нас є власна база даних та окрема таблиця для кожної сутності, а також власна системна архітектура, інфраструктура та шаблони проектування.

Ідея

Припустимо, що у нас є структура системи та фреймворк, на якому ми розробляємо систему (у прикладі цільова система розробляється на Laravel).

Нам знадобляться:

  1. Моделювання наших сутностей (таблиць, стовпців та їх обмежень) та їх дій і зв’язків (у прикладі ми будемо використовувати JSON файли).
  2. Створення шаблонів для наших цільових згенерованих файлів (компоненти Laravel: Моделі, Контролери, Запити).
  3. Парсинг описів сутностей та генерація модулів на основі шаблонів.

Приклад:

Простий пакет блогу з сутністю Author і сутністю Post. У автора є багато постів.

Моделювання сутностей:

Ми можемо представити сутність, її поля, зв’язки та дії за допомогою простих файлів JSON.

author.json

{
"entity": "author",
"id": true,
"id_field": "id",
"id_generation": "SEQUENCE",
"date_modified": true,
"date_created": true,
"fields": [
{
"name": "name",
"type": "TEXT",
"input_type": "TEXT",
"nullable": false,
"pattern": null,
"default_value": null,
"fillable": true,
"unique": false,
"hidden": false,
"index": false,
"meta": {},
"translatable": false
}
],
"belongs_to": [],
"has_many": [
{
"entity": "post",
"name": "posts",
"foreign_key": "author_id",
"is_many_to_many": false,
"many_to_many_table": null,
"other_foreign_key": null,
"attach_on_create": false,
"create_on_create": false,
"sync": false,
"detachable": false
}
],
"requests": [
{
"type": "GET",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": []
},
{
"type": "GET_ONE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": []
},
{
"type": "CREATE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": []
},
{
"type": "UPDATE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": []
},
{
"type": "DELETE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": []
}
]
}

posts.json

{
"entity": "post",
"id": true,
"id_field": "id",
"id_generation": "SEQUENCE",
"date_modified": true,
"date_created": true,
"fields": [
{
"name": "title",
"type": "MEDIUM_TEXT",
"input_type": "TEXT",
"nullable": false,
"pattern": null,
"default_value": null,
"fillable": true,
"unique": false,
"hidden": false,
"index": false,
"meta": {},
"translatable": false
}, {
"name": "body",
"type": "LONG_TEXT",
"input_type": "WYSIWYG",
"nullable": false,
"pattern": null,
"default_value": null,
"fillable": true,
"unique": false,
"hidden": false,
"index": false,
"meta": {},
"translatable": false
}
],
"belongs_to": [
{
"entity": "author",
"name": "author",
"foreign_key": "author_id",
"foreign_key_nullable": false,
"attach_on_create": true,
"on_delete": "CASCADE"
}
],
"has_many": [],
"requests": [
{
"type": "GET",
"required_permissions": [],
"required_auth": false,
"meta": {
"paginated": true,
"per_page": 10
},
"with": ["author"]
},
{
"type": "GET_ONE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": ["author"]
},
{
"type": "CREATE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": ["author"]
},
{
"type": "UPDATE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": ["author"]
},
{
"type": "DELETE",
"required_permissions": [],
"required_auth": false,
"meta": {},
"with": ["author"]
}
]
}

Синтаксичний аналіз сутностей

Ми можемо використати наступний пакет jackson-databind. Нам потрібно створити модель для нашої JSON схеми, прочитати JSON файл і зіставити його з об’єктом Entity.

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.0</version>
</dependency>

Нижче представлено схему сутності.

package model.entities;

import freemarker.template.utility.StringUtil;
import lombok.*;
import model.entities.fields.Field;
import model.entities.relations.BelongsTo;
import model.entities.relations.HasMany;
import model.requests.Request;
import org.atteo.evo.inflector.English;

import java.util.List;

@Setter @Getter @Builder @NoArgsConstructor @AllArgsConstructor
public class Entity {

private String entity;
private Boolean id;
private String id_field;
private IdGeneration id_generation;
private Boolean date_modified;
private Boolean date_created;
private List<Field> fields;
private List<BelongsTo> belongs_to;
private List<HasMany> has_many;
private List<Request> requests;

public String getEntityPlural() {
return English.plural(entity);
}

public String getEntityClass() {
return StringUtil.capitalize(entity);
}
}

Парсер

EntityParser використовується для читання файлу з шляху до ресурсів і розбору його на об’єкт Entity.

package parsers;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import model.entities.Entity;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;

public class EntityParser {

public Entity parseJson(String json) throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(json, Entity.class);
}

public Entity parseFileFor(String module, String entity) throws IOException {
return parseFileForPath(Paths.get("src","main","resources", module, entity.concat(".json")));
}

private Entity parseFileForPath(Path path) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(path.toFile(), Entity.class);
}

}

Шаблони

Наш приклад тут — створення модуля Laravel Blog, який містить міграції БД, моделі, контролери тощо.

Ми використовуємо FreeMarker Java Template Engine для написання шаблонів для компонентів у .ftlh файлах. Ознайомлення з керівництвом та документацією може допомогти зрозуміти, як це працює.

Додавання залежності:

<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>

migration.ftlh

<#noautoesc>${"<?php"}</#noautoesc>

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{

public function up()
{

Schema::create('${entity.getEntityPlural()}', function (Blueprint $table) {
<#if entity.id>
$table->id();
</#if>
<#list entity.fields as field>
$table->
<#if field.type == 'TEXT' >
string
</#if>
<#if field.type == 'MEDIUM_TEXT' >
mediumText
</#if>
<#if field.type == 'LONG_TEXT' >
longText
</#if>
('${field.name}');
</#list>

<#list entity.belongs_to as master>
$table->unsignedBigInteger('${master.foreign_key}');
$table->foreign('${master.foreign_key}')->references('id')->on('${master.getEntityPlural()}')
<#if master.on_delete == 'CASCADE'>->onDelete('cascade')</#if>
;

</#list>

<#if entity.date_modified || entity.date_created>$table->timestamps();</#if>
});
}

public function down()
{

Schema::dropIfExists('${entity.getEntityPlural()}');
}
};

model.ftlh

<#noautoesc>${"<?php"}</#noautoesc>

namespace App\${packageName}\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ${entity.getEntityClass()} extends Model
{
protected $fillable = [${fillable?map(f -> "'${f}'")?join(',')?no_esc}];
protected $hidden = [${entity.fields?filter(f -> f.hidden)?map(f -> "'${f.name}'")?join(',')?no_esc}];
protected $table = '${entity.getEntityPlural()}';

use HasFactory;

<#list entity.belongs_to as master>
public function ${master.name}() {
return $this->belongsTo(${master.getEntityClass()}::class, '${master.foreign_key}');
}
</#list>

<#list entity.has_many?filter(c -> !c.is_many_to_many) as child>
public function ${child.name}() {
return $this->hasMany(${child.getEntityClass()}::class, '${child.foreign_key}');
}
</#list>
}

controller.ftlh

<#noautoesc>${"<?php"}</#noautoesc>

namespace App\${packageName}\Controllers;

use App\${packageName}\Models\${entity.getEntityClass()};
use App\${packageName}\Requests\${entity.getEntityClass()}Request;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class ${entity.getEntityClass()}Controller extends Controller
{

<#list entity.requests as request>

<#if request.type == "GET">
public function index() {
$data = ${entity.getEntityClass()}::with([${request.with?map(f -> "'${f}'")?join(',')?no_esc}])
<#if request.meta.paginated == 'true'>->paginate(${request.meta.per_page})</#if>
;
return response()->json($data);
}
</#if>

<#if request.type == "GET_ONE">
public function get($id) {
$data = ${entity.getEntityClass()}::query()->with([${request.with?map(f -> "'${f}'")?join(',')?no_esc}])->findOrFail($id);
return response()->json($data);
}
</#if>

<#if request.type == "CREATE">
public function create(${entity.getEntityClass()}Request $request) {
$data = new ${entity.getEntityClass()}($request->all());
$data->save();
$data = ${entity.getEntityClass()}::query()->with([${request.with?map(f -> "'${f}'")?join(',')?no_esc}])->findOrFail($data->id);
return response()->json($data);
}
</#if>

<#if request.type == "UPDATE">
public function update($id, ${entity.getEntityClass()}Request $request) {
$data = ${entity.getEntityClass()}::query()->findOrFail($id);
$data->update($request->all());
$data = ${entity.getEntityClass()}::query()->with([${request.with?map(f -> "'${f}'")?join(',')?no_esc}])->findOrFail($data->id);
return response()->json($data);
}
</#if>

<#if request.type == "DELETE">
public function delete($id) {
$data = ${entity.getEntityClass()}::query()->with([${request.with?map(f -> "'${f}'")?join(',')?no_esc}])->findOrFail($id);
$data->delete();
return response()->json($data);
}
</#if>

</#list>
}

У повному коді ви можете перевірити решту шаблонів для differnet-компонентів.

Генерація файлу за допомогою шаблонів

Наступною метою є налаштування моделі генерації файлу міграції БД.

package generator;

import freemarker.template.Template;
import freemarker.template.TemplateException;
import model.entities.Entity;

import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.HashMap;
import java.util.Map;

public class MigrationGenerator {

private static final String template = "migration.ftlh";

public String generate(Entity entity) throws IOException, TemplateException {

/* Create a data-model */
Map root = new HashMap();
root.put("entity", entity);

/* Get the template (uses cache internally) */
Template temp = TemplateConfig.getConfig().getTemplate(template);

/* Merge data-model with template */
Writer out = new StringWriter();
temp.process(root, out);
out.close();

return out.toString();
}
}

Результат буде таким (після заміни полів сутності в шаблоні):

<?php

namespace App\Blog\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{

protected $fillable = ['title', 'body', 'author_id'];
protected $hidden = [];
protected $table = 'posts';

use HasFactory;

public function author() {
return $this->belongsTo(Author::class, 'author_id');
}
}

І контролер:

<?php

namespace App\Blog\Controllers;

use App\Blog\Models\Post;
use App\Blog\Requests\PostRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class PostController extends Controller
{

public function index() {
$data = Post::with(['author'])->paginate(10);
return response()->json($data);
}

public function get($id) {
$data = Post::query()->with(['author'])->findOrFail($id);
return response()->json($data);
}

public function create(PostRequest $request) {
$data = new Post($request->all());
$data->save();
$data = Post::query()->with(['author'])->findOrFail($data->id);
return response()->json($data);
}

public function update($id, PostRequest $request) {
$data = Post::query()->findOrFail($id);
$data->update($request->all());
$data = Post::query()->with(['author'])->findOrFail($data->id);
return response()->json($data);
}

public function delete($id) {
$data = Post::query()->with(['author'])->findOrFail($id);
$data->delete();
return response()->json($data);
}
}

Тестування

Ми запишемо повністю робочий файл коду в тестовий файл як очікуваний результат, прочитаємо файл очікуваного результату, потім згенеруємо код після розбору і використання шаблону, порівняємо обидва, щоб перевірити правильність шаблону і логіку генерації.

package generator;

import model.entities.Entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import parsers.EntityParser;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import static org.junit.jupiter.api.Assertions.*;

class ModelGeneratorTest {

@Test
@DisplayName("Model generated correctly for posts")
void generateForPosts() {
ModelGenerator generator = new ModelGenerator();

assertAll(() -> {

String expected = getExpected("post.txt");

EntityParser parser = new EntityParser();
Entity e = parser.parseFileFor("blog", "posts");

String generated = generator.generate(e, "Blog").replaceAll("\\s", "");

assertEquals(expected, generated);
});
}

@Test
@DisplayName("Model generated correctly for authors")
void generateForAuthors() {
ModelGenerator generator = new ModelGenerator();

assertAll(() -> {

String expected = getExpected("author.txt");

EntityParser parser = new EntityParser();
Entity e = parser.parseFileFor("blog", "author");

String generated = generator.generate(e, "Blog").replaceAll("\\s", "");

assertEquals(expected, generated);
});
}

private String getExpected(String file) throws IOException {
return new String(
Files.readAllBytes(
Paths.get("src","test", "resources", "templates", "models", file)
))
.replaceAll("\\s", "");
}
}

Примітка

Ми можемо застосувати ті ж самі кроки для створення інтерфейсу користувача, не обов’язково для того ж самого проекту Laravel, ми можемо створити Angular Components!

Переваги

  1. У нас є загальна архітектура для нашої CMS і ми хочемо застосувати її в кожному модулі.
  2. Ми можемо використовувати наш власний фреймворк та інфраструктуру, а не універсальну CMS.
  3. Обслуговування: оновлення одного компонента вимагає оновлення лише шаблону та регенерації всіх модулів.
  4. Повторне використання та легка міграція: ми можемо змінити цільовий фреймворк, лише змінивши шаблон, модель сутностей може залишатися незмінною.
  5. Один вхід — кілька виходів: один опис, кілька модулів для кількох проектів (наприклад, внутрішні та зовнішні модулі).
  6. Більше часу на бізнес-логіку і менше часу на CRUD-модулі.

Повний код тут.

Цей текст взято з особистого блогу після отримання дозволу автора.

Якщо ви знайшли помилку, будь ласка, виділіть фрагмент тексту та натисніть Ctrl+Enter.

Останні статті

ChatGPT, моторошна долина та трохи Фройда

Днями я завзято нила про щось ChatGPT (експериментую між сеансами з живим терапевтом). І от…

17.04.2025

Я прийшла за покупками, а не крутити колесо

«Крутіть колесо, щоб отримати знижку до 50%!» «Натисніть тут, щоб відкрити таємничу пропозицію!» «Зареєструйтесь зараз,…

16.04.2025

Майже навайбкодив десктопний монітор CI пайплайнів

Дуже хочеться робити якісь десктопні апки. Сумую за часами коли всі програми були offline-first, і…

15.04.2025

Як працюють транзакційні комісії в мережах Bitcoin і Ethereum

Надсилаючи криптовалюту, багато новачків ставлять запитання: як працюють комісії та чому вони відрізняються в різних…

14.04.2025

Обережно, тепер вас можуть обдурити на співбесіді з роботодавцем

Нова афера набирає обертів — ось детальний розбір того, як фальшиві потенційні роботодавці намагаються вкрасти…

11.04.2025

Цілі застосунки в соцмережі? На останньому ETHKyiv Impulse довели, що це можливо

Соцмережа з можливістю вбудовувати повноцінні додатки прямо в пости — звучить як фантастика, але Farcaster…

10.04.2025