Розробка систем, які мають багато завдань зі зберігання та пошуку контенту, може включати в себе багато повторюваних і нудних дій. Припустимо, що ви розробляєте платформу електронної комерції для клієнта, і вас починають просити розробити функцію додавання полів до товарів, або додати нові фото-слайдери на головну сторінку. Якщо ви використовуєте власну CRUD-систему, це вимагатиме від вас більше часу, і ви будете менше зосереджені на моделюванні та реалізації бізнес-логіки.
Припустимо, що з якихось причин вам потрібно змінити певні елементи інтерфейсу в адміністративній панелі, або ви змінили спосіб зберігання фотографій на серверах. Тоді вам доведеться зробити це у всіх модулях, які ви вже розробили.
Наша мета — розробити спосіб автоматизувати генерацію компонентів системи управління контентом у вашій системі, щоб розробники могли більше часу зосередитися на моделюванні бізнес-логіки та скоротити час на внесення модифікацій.
Чому не використовувати CMS?
CMS забезпечують ту ж саму мету, але з огляду на продуктивність. Ці системи використовують ті ж самі таблиці та поля для представлення сутностей. Ми припускаємо, що у нас є власна база даних та окрема таблиця для кожної сутності, а також власна системна архітектура, інфраструктура та шаблони проектування.
Ідея
Припустимо, що у нас є структура системи та фреймворк, на якому ми розробляємо систему (у прикладі цільова система розробляється на Laravel).
Нам знадобляться:
- Моделювання наших сутностей (таблиць, стовпців та їх обмежень) та їх дій і зв’язків (у прикладі ми будемо використовувати JSON файли).
- Створення шаблонів для наших цільових згенерованих файлів (компоненти Laravel: Моделі, Контролери, Запити).
- Парсинг описів сутностей та генерація модулів на основі шаблонів.
Приклад:
Простий пакет блогу з сутністю 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;
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();
}
}
Результат буде таким (після заміни полів сутності в шаблоні):
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');
}
}
І контролер:
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 {
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);
});
}
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!
Переваги
- У нас є загальна архітектура для нашої CMS і ми хочемо застосувати її в кожному модулі.
- Ми можемо використовувати наш власний фреймворк та інфраструктуру, а не універсальну CMS.
- Обслуговування: оновлення одного компонента вимагає оновлення лише шаблону та регенерації всіх модулів.
- Повторне використання та легка міграція: ми можемо змінити цільовий фреймворк, лише змінивши шаблон, модель сутностей може залишатися незмінною.
- Один вхід — кілька виходів: один опис, кілька модулів для кількох проектів (наприклад, внутрішні та зовнішні модулі).
- Більше часу на бізнес-логіку і менше часу на CRUD-модулі.
Повний код тут.
Цей текст взято з особистого блогу після отримання дозволу автора.
Favbet Tech – це ІТ-компанія зі 100% українською ДНК, що створює досконалі сервіси для iGaming і Betting з використанням передових технологій та надає доступ до них. Favbet Tech розробляє інноваційне програмне забезпечення через складну багатокомпонентну платформу, яка здатна витримувати величезні навантаження та створювати унікальний досвід для гравців.
Цей матеріал – не редакційний, це – особиста думка його автора. Редакція може не поділяти цю думку.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: