#EntityFrameworkCore #DotNet #CSharp #PostgreSQL
Сутності та Параметри (також відомі як "атрибути сутностей" або "атрибути об'єктів") є важливою частиною будь-якої програмної системи. У цій статті я поясню, що це таке та як їх реалізувати в .NET та Entity Framework Core, з використанням PostgreSQL як бази даних.
Що таке Сутності та Параметри?
Системи Сутність-Параметр — це системи, які допомагають нам керувати даними, пов'язаними з Сутностями (Облікові записи, Інформація про продукти, Послуги тощо), а також їхніми відповідними Параметрами та значеннями.
Система Сутність-Параметр
Наверху — спрощене графічне зображення такої системи.
Нижче наводиться більш конкретний приклад.
Система Сутність-Параметр для сутності Продукт
Тут ми бачимо, що програмна система може визначити сутність "Продукт", з основними даними (id, назва, опис тощо) та набором категоризованих параметрів, що містять інформацію про різні дані, що мають відношення до цієї сутності.
Приклад використання — під час розробки програмного забезпечення
Уявіть, що ви створюєте програмний додаток для управління продажами та інвентарем в магазині.
Під час розробки додатку вимоги до даних, які повинні зберігатися та оброблятися для конкретної сутності, змінюватимуться в залежності від потреб клієнта, змін в специфікаціях тощо.
Маючи систему Сутність-Параметр, інтегровану на ранньому етапі розробки, ви можете налаштувати систему та дані, зосередитися на бізнес-логіці, що дозволить швидше здійснювати ітерації та створювати структурований процес розробки.
Приклад використання — після розгортання
Дозволяючи кінцевим користувачам (наприклад, адміністраторам) змінювати Сутності та Параметри, ви даєте їм більш точний контроль над системою, і це може бути реалізовано таким чином, щоб дрібні та великі зміни можна було б вирішувати лише на основі Сутностей, Параметрів та значень Параметрів через вже створений інтерфейс користувача.
Розглянемо той самий приклад системи управління продажами та інвентарем.
Приклад 1
Якщо є категорія під назвою "Пропозиції", в якій є параметр "ОФЕРТА", що фіксує знижки на варіанти продукту, контролювати різні деталі пропозиції можна через простий інтерфейс користувача, який надає адміністраторам легкий доступ до керування.
Звісно, розробники можуть обмежити типи та категорії даних, що можуть бути зафіксовані, кількість параметрів або заздалегідь встановити типи параметрів для певної сутності чи за допомогою багатьох інших способів налаштувати систему.
Реалізація
Давайте розглянемо, що включає спрощена реалізація такої системи.
Ми будемо використовувати C#, .NET, Entity Framework Core та PostgreSQL як основний стек.
Моделі
namespace EP.Core.Models;
public class EntityModel
{
public Guid id { get; set; } // Система генерує ідентифікатор
public string entitytype { get; set; } // Тип сутності (Продукт, Послуга тощо)
public string name { get; set; }
public string description { get; set; }
public string entitykey { get; set; }
public string dircode { get; set; } // Назва каталогу для збереження даних на диску
}
public class EntityParameterModel
{
public Guid id { get; set; } // Відрізняється від ідентифікатора об'єкта
public Guid entityid { get; set; }
public string category { get; set; }
public string parameterkey { get; set; }
public string name { get; set; }
public string description { get; set; }
public string valuetype { get; set; }
public string units { get; set; }
public int parameterindex { get; set; }
// Це представлення значення параметра.
public ParameterValueBase? parametervalue { get; set; }
public JsonDocument jsonvalue { get; set; }
}
Примітка: Властивість "jsonvalue" моделі EntityParameterModel — це та, що повинна зберігатися в базі даних. Властивість "parametervalue" типу ParameterValueBase є лише внутрішнім типізованим представленням значення параметра. Це необхідно, оскільки тип параметра може бути будь-яким складним об'єктом, який не можна безпосередньо зберегти в базі даних. Отже, під час зберігання в базу даних, ми повинні серіалізувати його в JSON або іншу еквівалентну форму (YAML, XML, рядки таблиць тощо). Під час отримання з бази даних система повинна конвертувати з серіалізованої форми в об'єктну. Ми побачимо, як це зробити пізніше в статті.
Також я вибрав формат JSON для зберігання, оскільки PostgreSQL має відмінну підтримку цього формату.
Типи параметрів
Ці класи відображають типи параметрів, які користувачі / розробники можуть додавати / змінювати в системі.
Зверніть увагу, що значення параметра має поліформну (polymorphic) природу. Базова змінна є лише контейнером.
"Строка “valuetype” вище представляє тип значення параметра.
namespace EP.Core.Models.Parameters;
public class ParameterValueBase
{
// Базовий клас для всіх значень параметрів
}
public class StringParameterValue : ParameterValueBase
{
public string value = "";
}
public class StringListParameterValue : ParameterValueBase
{
public List<string> value = new List<string>();
}
public class NumberParameterValue : ParameterValueBase
{
public double value = 0.0;
}
public class NumberListParameterValue : ParameterValueBase
{
public List<double> value = new List<double>();
}
public class BooleanParameterValue : ParameterValueBase
{
public bool value = false;
}
public class TableParameterValue : ParameterValueBase
{
public int columns = 0;
public List<string> columnnames = new List<string>();
public List<string> columnnunits = new List<string>();
public List<string> columntypes = new List<string>();
public Dictionary<string, List<object>> values = new Dictionary<string, List<object>>();
}
Репозиторії
Ентіті (Entities) та параметри повинні бути збережені.
Тут я використовую SQL базу даних. Однак те ж саме можна реалізувати й в інших системах збереження даних (хмара, диск, база даних NoSQL тощо).
namespace EP.Core.Interfaces;
public interface IEntityRepo
{
public Task<EntityModel> GetEntityModelAsync(Guid id);
public Task<List<EntityModel>> GetAllEntitiesAsync(string entityTypeFilter = "*");
public Task CreateEntityModelAsync(EntityModel entityModel);
public Task UpdateEntityModelAsync(EntityModel entityModel);
}
public interface IEntityParameterRepo
{
public Task<List<EntityParameterModel>> GetEntityParametersAsync(Guid entityid);
public Task<List<EntityParameterModel>> UpdateEntityParametersAsync(List<EntityParameterModel> updatedParameters);
public Task<List<EntityParameterModel>> CreateEntityParametersAsync(List<EntityParameterModel> parameters);
}
Сервіси
Сервіси складають основну бізнес-логіку всього системи.
Тут ми визначаємо прості сервіси для додавання/зміни параметрів системи.
У цьому прикладі припускається, що є сутність "plant" (рослина), тому
namespace EP.Core.Services;
public class PlantService
{
readonly IEntityRepo entityRepo;
readonly IEntityParameterRepo entityParameterRepo;
public PlantService(IEntityRepo entityRepo, IEntityParameterRepo entityParameterRepo)
{
this.entityRepo = entityRepo;
this.entityParameterRepo = entityParameterRepo;
}
public async Task CreatePlant(EntityModel model)
{
model.id = Guid.NewGuid();
model.entitytype = EntityType.Plant;
return await this.entityRepo.CreateEntityModelAsync(model);
}
public async Task GetPlant(Guid id)
=>await this.entityRepo.GetEntityModelAsync(id);
public async Task> GetAllPlants()
=> await this.entityRepo.GetAllEntitiesAsync(entityTypeFilter: EntityType.Plant );
public async Task UpdatePlant(EntityModel entityModel)
=> await this.entityRepo.UpdateEntityModelAsync(entityModel);
public async Task> GetPlantParameters(Guid id)
{
List parameters = new List();
EntityModel? plant = await this.entityRepo.GetEntityModelAsync(id);
if (plant == null) throw new Exception("PlantService.GetPlantParameters => No Entity exists with given id : "+id.ToString());
parameters = await this.entityParameterRepo.GetEntityParametersAsync(id);
// Перетворення даних всередині параметрів.
foreach (EntityParameterModel parameter in parameters)
parameter.parametervalue = ParameterValueConverter.Deserialize(parameter.valuetype,parameter.jsonvalue);
return parameters;
}
public async Task> AddParametersToPlant(Guid plantid, List parameters)
{
List convertedParameters = new List();
foreach (EntityParameterModel p in parameters)
{
p.entityid = plantid;
p.id = Guid.NewGuid();
p.jsonvalue = ParameterValueConverter.Serialize(p.valuetype, p.parametervalue);
convertedParameters.Add(p);
}
return await this.entityParameterRepo.CreateEntityParametersAsync(convertedParameters);
}
}
public class ParameterValueConverter
{
public static JsonDocument Serialize(string paramType, ParameterValueBase parametervalue)
{
JsonSerializerOptions options = new JsonSerializerOptions();
options.IncludeFields = true;
switch (paramType)
{
case SupportedParameterTypes.STRING:
return JsonDocument.Parse(JsonSerializer.Serialize ((StringParameterValue)parametervalue,options));
case SupportedParameterTypes.STRINGLIST:
return JsonDocument.Parse(JsonSerializer.Serialize((StringListParameterValue)parametervalue, options));
case SupportedParameterTypes.NUMBER:
return JsonDocument.Parse(JsonSerializer.Serialize((NumberParameterValue)parametervalue, options));
case SupportedParameterTypes.NUMBERLIST:
return JsonDocument.Parse(JsonSerializer.Serialize((NumberListParameterValue)parametervalue, options));
case SupportedParameterTypes.BOOLEAN:
return JsonDocument.Parse(JsonSerializer.Serialize((BooleanParameterValue)parametervalue, options));
case SupportedParameterTypes.TABLE:
return JsonDocument.Parse(JsonSerializer.Serialize((TableParameterValue)parametervalue, options));
default:
return JsonDocument.Parse("{}");
}
}
public static ParameterValueBase? Deserialize(string valtype, JsonDocument pvalue)
{
ParameterValueBase result = null;
JsonSerializerOptions options = new JsonSerializerOptions();
options.IncludeFields = true;
switch (valtype)
{
case SupportedParameterTypes.STRING:
result = JsonSerializer.Deserialize(pvalue,options);
break;
case SupportedParameterTypes.STRINGLIST:
result = JsonSerializer.Deserialize(pvalue, options);
break;
case SupportedParameterTypes.NUMBER:
result = JsonSerializer.Deserialize(pvalue, options);
break;
case SupportedParameterTypes.NUMBERLIST:
result = JsonSerializer.Deserialize(pvalue, options);
break;
case SupportedParameterTypes.BOOLEAN:
result = JsonSerializer.Deserialize(pvalue, options);
break;
case SupportedParameterTypes.TABLE:
result = JsonSerializer.Deserialize(pvalue, options);
break;
default:
break;
}
return result;
}
}
Примітка: Сервіси містять всю бізнес-логіку застосунку.
Сервіси відповідають за серіалізацію значень параметрів з об'єктів у JSON перед збереженням у базу даних та десеріалізацію з JSON у об'єкти після отримання. Репозиторії та контексти баз даних є лише простими механізмами зберігання даних.
Для конвертації використовується допоміжний клас “ParameterValueConverter”.
Підтримувані класи
EntityType, SupportedParameterTypes та SupportedColumnTypes для типу параметра “Table” виглядають наступним чином:
namespace EP.Core.Models;
public class EntityType
{
public static string Plant = "PLANT";
public static string TestBed = "TESTBED";
public static string MachineType = "MACHINETYPE";
}
public class SupportedParameterTypes
{
public const string STRING = "string";
public const string BOOLEAN = "boolean";
public const string NUMBER = "number";
public const string STRINGLIST = "stringlist";
public const string NUMBERLIST = "numberlist";
public const string TABLE = "table";
}
public class SupportedTableColumnTypes
{
public const string STRING = "colstring";
public const string BOOLEAN = "colboolean";
public const string NUMBER = "colnumber";
public const string STRINGLIST = "colstringlist";
public const string NUMBERLIST = "colnumberlist";
}
Інфраструктура
Усі класи, описані до цього, є частиною модуля “Core”.
Модуль інфраструктури містить класи для роботи з фактичним зберіганням даних. Нижче наведено реалізацію репозиторіїв, згаданих вище.
namespace EP.Infrastructure.Repos;
public class EntityRepo : IEntityRepo
{
readonly PGSQLDbContext context;
public EntityRepo(PGSQLDbContext dbcontext)
{
this.context = dbcontext;
}
public async Task CreateEntityModelAsync(EntityModel entityModel)
{
// Очищаємо трекер змін.
context.ChangeTracker.Clear();
context.Add(entityModel);
await context.SaveChangesAsync();
return entityModel;
}
public async Task> GetAllEntitiesAsync(string entityTypeFilter = "*")
=> await context.Entities.AsNoTracking().ToListAsync();
public async Task GetEntityModelAsync(Guid id)
=> await context.Entities.AsNoTracking().Where(p => p.id == id).FirstOrDefaultAsync();
public async Task UpdateEntityModelAsync(EntityModel entityModel)
{
context.ChangeTracker.Clear();
EntityModel? dbModel = await context.Entities.Where(p=> p.id == entityModel.id).FirstOrDefaultAsync();
if (dbModel == null) throw new Exception("EntityRepo.UpdateEntityModelAsync : Model not found");
dbModel = EntityModel.CopyModelDataForUpdate(entityModel, dbModel);
await context.SaveChangesAsync();
return dbModel;
}
}
public class EntityParameterRepo : IEntityParameterRepo
{
readonly PGSQLDbContext context;
public EntityParameterRepo( PGSQLDbContext context)
{
this.context = context;
}
public async Task> CreateEntityParametersAsync(List parameters)
{
context.ChangeTracker.Clear();
foreach (var parameter in parameters)
context.Add(parameter);
await context.SaveChangesAsync();
return parameters;
}
public async Task> GetEntityParametersAsync(Guid entityid)
{
return await context.EntityParameters.Where(p => p.entityid == entityid).ToListAsync();
}
public async Task> UpdateEntityParametersAsync(List updatedParameters)
{
throw new NotImplementedException();
}
}
Ми також маємо реалізацію контексту даних.
Зверніть увагу, що ми ігноруємо властивість “parametervalue” в “EntityParameterModel”, оскільки не хочемо, щоб вона зберігалася в базі даних.
namespace EP.Infrastructure.Database;
public class PGSQLDbContext : DbContext
{
private readonly string connectionString;
public PGSQLDbContext(DbContextOptions options): base(options){}
public DbSet Entities { get; set; }
public DbSet EntityParameters { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new EntityModelConfiguration());
modelBuilder.ApplyConfiguration(new EntityParameterModelConfiguration());
}
}
public class EntityParameterModelConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("entityparameters");
builder.HasKey(p=> p.id);
builder.Ignore(p => p.parametervalue);
}
}
public class EntityModelConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder builder)
{
builder.ToTable("entity");
builder.HasKey(p => p.id);
}
}
Структура бази даних
Я використовую підхід Database-first і створив приклади таблиць у локальній базі даних PostgreSQL.
Таблиця Entity
Таблиця Entity Parameter
Program.cs
З’єднання всього цього здійснюється через просту консольну програму, щоб продемонструвати зберігання та отримання сутностей і параметрів.
namespace EP.ConsoleApp;
internal class Program
{
private static IServiceCollection appServices;
private static IServiceProvider serviceProvider;
static void Main(string[] args)
{
appServices = new ServiceCollection();
string pgsqlconnstring = "";
appServices.AddDbContext(options =>
{
options.UseNpgsql(pgsqlconnstring);
});
appServices.AddSingleton();
appServices.AddSingleton();
appServices.AddSingleton();
serviceProvider = appServices.BuildServiceProvider();
MainAsync().Wait();
}
static async Task MainAsync()
{
// Головна асинхронна функція.
PlantService pservice = serviceProvider.GetService();
EntityModel plantModel = GetRandomPlantModel();
// Створюємо рослину в базі даних.
await pservice.CreatePlant(plantModel);
int paramcount = 100;
List plantParameters = new List();
for (int i = 0; i < paramcount; i++)
{
plantParameters.Add(GetRandomEntityParameterModel());
}
await pservice.AddParametersToPlant(plantModel.id, plantParameters);
// Після завершення, отримуємо рослину та параметри назад.
Guid plantid = plantModel.id;
EntityModel dbPlant = await pservice.GetPlant(plantid);
List dbPlantParameters = await pservice.GetPlantParameters(plantid);
dbPlantParameters = dbPlantParameters;
}
static EntityModel GetRandomPlantModel()
{
Random r = new Random();
string code = r.Next(999).ToString();
EntityModel plantModel = new EntityModel();
plantModel.name = "name" + code;
plantModel.description = "description" + code;
plantModel.dircode = "dircode" + code;
plantModel.entitykey = "key" + code;
return plantModel;
}
static EntityParameterModel GetRandomEntityParameterModel()
{
EntityParameterModel resultmodel = new EntityParameterModel();
Random r = new Random();
string code = r.Next(999).ToString();
string categorycode = r.Next(5).ToString();
List etypes = new List();
etypes.Add(SupportedParameterTypes.STRING);
etypes.Add(SupportedParameterTypes.STRINGLIST);
etypes.Add(SupportedParameterTypes.NUMBER);
etypes.Add(SupportedParameterTypes.NUMBERLIST);
etypes.Add(SupportedParameterTypes.BOOLEAN);
etypes.Add(SupportedParameterTypes.TABLE);
resultmodel.name = "parameter name" + code;
resultmodel.description = "parameter description" + code;
resultmodel.units = "parameter units" + code;
resultmodel.category = "category" + categorycode;
resultmodel.parameterindex = 0;
resultmodel.parameterkey = "key" + code;
string etype = etypes[r.Next(0, etypes.Count)];
switch (etype)
{
case SupportedParameterTypes.STRING:
resultmodel.valuetype = SupportedParameterTypes.STRING;
StringParameterValue stringval = new StringParameterValue();
stringval.value = "parametervalue" + code;
resultmodel.parametervalue = stringval;
break;
case SupportedParameterTypes.STRINGLIST:
resultmodel.valuetype = SupportedParameterTypes.STRINGLIST;
StringListParameterValue stringlistval = new();
stringlistval.value = new List() { "a", "b", "c" };
resultmodel.parametervalue = stringlistval;
break;
case SupportedParameterTypes.NUMBER:
resultmodel.valuetype = SupportedParameterTypes.NUMBER;
NumberParameterValue numval = new();
numval.value = r.NextDouble();
resultmodel.parametervalue = numval;
break;
case SupportedParameterTypes.NUMBERLIST:
resultmodel.valuetype = SupportedParameterTypes.NUMBERLIST;
NumberListParameterValue numlistval = new();
numlistval.value = new() { 1, 2, 3 };
resultmodel.parametervalue = numlistval;
break;
case SupportedParameterTypes.BOOLEAN:
resultmodel.valuetype = SupportedParameterTypes.BOOLEAN;
BooleanParameterValue boolval = new();
boolval.value = true;
resultmodel.parametervalue = boolval;
break;
case SupportedParameterTypes.TABLE:
resultmodel.valuetype = SupportedParameterTypes.TABLE;
TableParameterValue tableval = new();
tableval.columns = 2;
tableval.columnnames = new() { "temperature", "specificweight" };
tableval.columntypes = new() { SupportedParameterTypes.NUMBER, SupportedParameterTypes.NUMBER };
tableval.columnnunits = new() { "Deg C", "--" };
Dictionary> rows = new();
rows[1] = new() { "100.0", "0.98556" };
rows[2] = new() { "100.1", "0.5588" };
tableval.values = rows;
resultmodel.parametervalue = tableval;
break;
default:
break;
}
return resultmodel;
}
}
Примітка: Усі дії щодо параметрів:
- Скільки параметрів додавати.
- Які типи параметрів додавати.
- Дані всередині параметрів.
визначаються в консолі, але те ж саме можна було б реалізувати за допомогою простого користувацького інтерфейсу (далі буде описано в наступній статті).
Найбільша перевага цього підходу саме в цьому.
Це дозволяє розробнику або кінцевому користувачеві створювати та модифікувати параметри і типи в межах більшої програмної системи.
Можна заперечити, що те ж саме можна зробити за допомогою конфігураційних файлів або подібних механізмів. Однак варто зауважити, що з цією системою розробник знаходиться вже на 90% шляху до визначення мовою, що є специфічною для домену (Domain-Specific Language), що дає потужні можливості кінцевим користувачам.
Резюме
Системи параметрів сутностей є потужними інструментами в розробці програмного забезпечення та можуть спростити розробку і прискорити ітерації.
Будь ласка, залишайте коментарі і діліться своїми думками.
Перекладено з: Entities, Parameters, .NET & PostgreSQL