Розробка систем, які мають багато завдань зі зберігання та пошуку контенту, може включати в себе багато повторюваних і нудних дій. Припустимо, що ви розробляєте платформу електронної комерції для клієнта, і вас починають просити розробити функцію додавання полів до товарів, або додати нові фото-слайдери на головну сторінку. Якщо ви використовуєте власну CRUD-систему, це вимагатиме від вас більше часу, і ви будете менше зосереджені на моделюванні та реалізації бізнес-логіки.
Припустимо, що з якихось причин вам потрібно змінити певні елементи інтерфейсу в адміністративній панелі, або ви змінили спосіб зберігання фотографій на серверах. Тоді вам доведеться зробити це у всіх модулях, які ви вже розробили.
Наша мета — розробити спосіб автоматизувати генерацію компонентів системи управління контентом у вашій системі, щоб розробники могли більше часу зосередитися на моделюванні бізнес-логіки та скоротити час на внесення модифікацій.
CMS забезпечують ту ж саму мету, але з огляду на продуктивність. Ці системи використовують ті ж самі таблиці та поля для представлення сутностей. Ми припускаємо, що у нас є власна база даних та окрема таблиця для кожної сутності, а також власна системна архітектура, інфраструктура та шаблони проектування.
Припустимо, що у нас є структура системи та фреймворк, на якому ми розробляємо систему (у прикладі цільова система розробляється на 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!
Повний код тут.
Цей текст взято з особистого блогу після отримання дозволу автора.
Днями я завзято нила про щось ChatGPT (експериментую між сеансами з живим терапевтом). І от…
«Крутіть колесо, щоб отримати знижку до 50%!» «Натисніть тут, щоб відкрити таємничу пропозицію!» «Зареєструйтесь зараз,…
Дуже хочеться робити якісь десктопні апки. Сумую за часами коли всі програми були offline-first, і…
Надсилаючи криптовалюту, багато новачків ставлять запитання: як працюють комісії та чому вони відрізняються в різних…
Нова афера набирає обертів — ось детальний розбір того, як фальшиві потенційні роботодавці намагаються вкрасти…
Соцмережа з можливістю вбудовувати повноцінні додатки прямо в пости — звучить як фантастика, але Farcaster…