Содержание
В предыдущей части мы написали небольшой компонент Razor для парсинга открытых данных Росприроднадзора. При этом, сложность заключалась в двух моментах: 1) сам по себе файл имел большой размер (более 600 Мб), а структура данных в файле, с точки зрения десериализации, оказалась неверной (отсутствие запятых, разделяющих json-объекты, сами объекты не заключены в массив и т.д.). В итоге нам удалось разобрать этот файл достаточно большой ценой в плане времени — более 316 000 записей десериализуются очень и очень долго. Сегодня мы продолжим дорабатывать наш проект «Open Ecology» и запишем все полученные данные из файла в базу данных MySql.
Blazor WebAssembly и базы данных
В отличие от Blazor Server, Blazor WASM имеет серьезные ограничения по работе с базами данных. Если быть точнее, то на сайте Microsoft дословно сказано следующее:
Следовательно, если нашему приложению Blazor WASM необходимо работать с данными, содержащимися, например, в БД MySQL, то наиболее простой способ это сделать — организовать взаимодействие нашего приложения с каким-либо сервером, который в ответ на запрос Blazor WASM будет возвращать готовый набор данных, например, в формате JSON. То есть, организовать вот такую схему взаимодействия:
Таким образом, нам необходимо:
- Разработать приложение-сервер для непосредственной работы с БД MySQL
- Перенести код чтения JSON-файла из приложения Blazor WASM в приложение-сервер
- Учитывая большой объем данных (более 316 000 записей объемом свыше 600 Мб), обеспечить по-страничный вывод результатов.
Приложение ASP.NET Core WebAPI для работы с БД MySQL
Основные задачи приложения — это:
- Разобрать JSON-файл (при необходимости)
- Провести обновление (наполнение) базы данных информацией из этого файла
- По запросы пользователя выдавать постранично данные по источникам загрязнения из БД
Так как и это приложение и наш проект Blazor WebAssembly будут по сути использовать одни и те же классы для работы с данными, то имеет смысл создать ещё один проект на который будут ссылаться оба наших приложения — библиотеку классов .NET. Это позволит нам избежать создания лишних копий файлов в обоих проектах.
Библиотека классов .NET
Добавляем в наше решение в Visual Studio проект типа «Библиотека классов»
Я назвал этот проект «OELibrary». В итоге, решение в Visual Studio должно выглядеть вот так:
Теперь в проекте OELibrary создаем новую папку и добавляем в неё файл с классами, которые мы получили в предыдущей статье.
Открываем файл Pollutant.cs и изменяем название пространства имен на OELibrary.Models. Должно получиться следующим образом:
namespace OELibrary.Models
{
public class Pollutant
{
public long Id { get; set; }
public string name { get; set; }
public string number { get; set; }
<--прочие поля и свойства класса-->
}
<--прочие классы-->
}
Теперь, чтобы любой проект из решение мог использовать классы из нашей библиотеки, необходимо будет указать ссылку на библиотеку в настройках нового проекта. Как это делается — рассмотрим далее.
Проект ASP.NET Core WebAPI
Добавим в решение новый проект ASP.NET Core WebAPI и назовем его OpenEcologyAPI:
Настройки проекта можно оставить по умолчанию. В случае необходимости мы всегда сможем их изменить:
Теперь наше решение должно выглядеть следующим образом:
Чтобы приложение OpenEcologyAPI могло использовать классы из библиотеки классов OELibrary, необходимо добавить в проект ссылку. Нажимаем правой кнопкой мыши на названии проекта OpenEcologyAPI и выбираем в меню «Добавить —> Ссылку на проект»
Теперь мы можем в любом месте нашего проекта подключить пространство имен OELibrary.Models и использовать классы из библиотеки.
Добавление в проект Entity Framework Core для MySQL
Возможно, логичнее и правильнее было бы выбрать «родную» Microsoft SQL Server, но я сделал выбор в сторону MySQL по следующим причинам:
- Возможно, что полученная в результате работы над проектом база данных мне потребуется для других целей где как раз-таки используется MySQL и не используется EF Core и не хотелось бы делать лишних «телодвижений» по переносу БД
- Желание посмотреть работу EF Core именно с этой БД
В любом случае, если вы захотите повторить мой опыт и использовать другую БД, то c EF Core, я думаю, вы сделаете это достаточно просто. Теперь рассмотрим, что необходимо сделать, чтобы использовать MySQL в проекте ASP.NET Core WebAPI.
Устанавливаем необходимые пакеты
Для работы с MySQL в Entity Framework Core нам потребуется пакет от стороннего разработчика под названием Pomelo.EntityFrameworkCore.MySql. Открываем менеджер пакетов NuGet:
Ищем и устанавливаем пакет Pomelo.EntityFrameworkCore.MySql
Также, Visual Studio установить дополнительно необходимые пакеты для работы с EF Core. После установки пакета список зависимостей проекта будет выглядеть следующим образом:
Вносим изменения в классы библиотеки OELibrary
Чтобы мы могли использовать классы из OELibrary для работы с Entity Framework Core, необходимо внести некоторые изменения в уже созданные классы, а именно:
- определить необходимые ключи
- внести изменения в типы данных
Начнем с ключей. На данный момент у нас в библиотеке определены следующие классы:
Pollutant— основной класс, содержащий информацию о объекту (источнику загрязнения)Fact— класс, содержащий информацию по определенным воздействиям на окружающую среду. Например, мы сейчас работаем только с одним воздействием — загрязнением атмосферного воздуха, но в этом же классе может содержаться информация по отходам, сбросам и т.д.Air— информация о конкретном воздействии на атмосферный воздух (выбросе конкретного загрязняющего вещества)
Таким образом, Pollutant — это главная сущность для Fact, а сам Fact — это главная сущность для Air. Соответственно, определим в классах следующие ключи:
public class Pollutant
{
public long Id { get; set; } //ключ
<--другие свойства-->
}
public class Fact
{
public long Id { get; set; } //ключ
public long PollutantId { get; set; } //внешний ключ
<--другие свойства-->
}
public class Air
{
public long Id { get; set; } //ключ
public long FactId { get; set; } //внешний ключ
<--другие свойства-->
}
Теперь, чтобы EF Core смог работать с наборами данных (массивами facts и air у классов Pollutant и Fact) необходимо заменить их интерфейсами ICollection. Таким образом, окончательный вид классов для работы у нас будет следующий:
namespace OELibrary.Models
{
public class Pollutant
{
public long Id { get; set; }
public string name { get; set; }
public string number { get; set; }
public string? registry_type { get; set; }
public string? registry_category { get; set; }
public DateTime? inclusion_date { get; set; }
public string org_full_name { get; set; }
public float? value_co2 { get; set; }
public ICollection<Fact> facts { get; set; }
}
public class Fact
{
public long Id { get; set; }
public long PollutantId { get; set; }
public ICollection<Air> air { get; set; }
}
public class Air
{
public long Id { get; set; }
public long FactId { get; set; }
public float? annual_value { get; set; }
public string? code { get; set; }
public string? name { get; set; }
}
}
Создаем контекст данных
Всё взаимодействие с базой данных в Entity Framework Core происходит с использованием специального класса, который называется контекст данных. Добавим в наш проект папку Data и создадим в этой папке новый класс, который назовем PollutantContext и который будет иметь следующий код:
using OELibrary.Models;
using Microsoft.EntityFrameworkCore;
namespace OpenEcologyApi.Data
{
public class PollutantContext: DbContext
{
public PollutantContext(DbContextOptions<PollutantContext> options) : base(options)
{
Database.EnsureCreated();
}
public DbSet<Pollutant> Pollutants { get; set; }
}
}
Наш класс является наследником класса DbContext, который располагается в пространстве имен Microsoft.EntityFrameworkCore и определяет контекст данных, используемый для взаимодействия с базой данных. В свою очередь DbSet<Pollutant> — это набор объектов, которые хранятся в базе данных.
Теперь создадим новую строку подключения к БД MySQL. Для этого откроем файл appsettings.json и добавим в него следующую строку:
{
"ConnectionStrings": {
"DefaultConnection": "server=localhost;user=login;password=123456;database=dbName;"
},
<--Прочие настройки -->
}
здесь, в DefaultConnection содержится строка подключения к БД MySQL. В этой строке:
server— адрес сервера MySQL (обычно это localhost)user— имя пользователя БДpassword— пароль пользователяdatabase— имя БД к которой будет осуществляться подключение.
Теперь откроем файл Program.cs нашего проекта и подключим сервис для работы с БД:
<--Прочие настройки приложения-->
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
<--Подключаем сервис для работы с БД-->
builder.Services.AddDbContext<PollutantContext>(options =>
options.UseMySql(builder.Configuration.GetConnectionString("DefaultConnection"), new MySqlServerVersion("8.0.26") ));
<------------------------------------->
<--Прочие настройки приложения-->
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
Как видите, для подключения сервиса для работы с БД мы указали контекст данных и передали в метод UseMySql строку подключения из настроек. Что касается второго параметра метода, то в него передается версия сервера MySQL и на момент написания этой статьи у меня был установлен сервер версии 8.0.26.
На данном этапе, работы по подключению MySQL к нашему проекту можно считать завершенными. Осталось написать контроллер с помощью которого мы будем получить и возвращать данные об источниках загрязнения.
Пишем контроллер
Добавим в проект папку Controllers и создадим в этой папке класс UpdateController. Используя этот контроллер, мы будем обновлять нашу базу данных сведениями из JSON-файла. Содержимое классе будет следующим:
using Microsoft.AspNetCore.Mvc;
using OELibrary.Models;
using OpenEcologyApi.Data;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace OpenEcologyAPI.Controllers
{
[ApiController]
[Route("Update")]
public class UpdateController : Controller
{
PollutantContext _context;
ILogger<UpdateController> _logger;
IWebHostEnvironment _appEnvironment;
public UpdateController(PollutantContext context, ILogger<UpdateController> logger, IWebHostEnvironment appEnvironment)
{
_context = context;
_logger = logger;
_appEnvironment = appEnvironment;
}
[HttpGet]
public async Task<IActionResult> Index()
{
int count = 0;
string path = Path.Combine(new string[] { _appEnvironment.ContentRootPath, @"onv_pub.json" });
FileStream data = new(path, FileMode.Open, FileAccess.Read);
List<Pollutant> list = new();
using (StreamReader streamReader = new(data))
{
while (true)
{
string temp = await streamReader.ReadLineAsync();
if (list.Count > 0)
if ((list.Count % 100 == 0) || ((temp == null)))
{
await _context.Pollutants.AddRangeAsync(list);
count += _context.SaveChanges();
list.Clear();
_logger.Log(logLevel: LogLevel.Information, $"Добавили запись в БД. Всего записей добавлено {count}");
}
if (temp == null)
break;
Pollutant rootobject = JsonSerializer.Deserialize<Pollutant>(Regex.Unescape(temp));
if (_context.Pollutants.FirstOrDefault(p => p.number == rootobject.number) != null)
{
continue;
}
list.Add(rootobject);
}
}
return Ok();
}
}
}
По сути, в метод Index() мы перенесли тот код, который разработали в прошлой части. За некоторомы исключениями, касающимися работы с БД. Кратко рассмотрим их.
Во-первых, мы проверяем есть ли запись с определенным значением поля number в БД:
Pollutant rootobject = JsonSerializer.Deserialize<Pollutant>(Regex.Unescape(temp));
if (_context.Pollutants.FirstOrDefault(p => p.number == rootobject.number) != null)
{
continue;
}
На момент наполнения БД данными всего из одного файла такой проверки будет достаточно. Впоследствии эту часть метода необходимо будет доработать и добавить сравнение не только по полю number, но и по значениям других полей. При проверке мы использовали возможности LINQ.
Если запись в БД не найдена, то объект, полученный из JSON добавляется в список list.
Во-вторых, как только количество элементов в списке list достигнет 100 или же мы дойдем до конца файла, объекты из списка записываются в БД:
string temp = await streamReader.ReadLineAsync();
if (list.Count > 0)
if ((list.Count % 100 == 0) || ((temp == null)))
{
await _context.Pollutants.AddRangeAsync(list);
count += _context.SaveChanges();
list.Clear();
_logger.Log(logLevel: LogLevel.Information, $"Добавили запись в БД. Всего записей добавлено {count}");
}
а сам список очищается и добавление новых объектов в него начинается заново. Процесс добавления записей в БД досточно продолжительный, поэтому дополнительно мы будем выводить в лог сообщение о том сколько записей было добавлено.
Теперь можем запустить проект и, так как мы запустим его в режиме разработки (Development), то откроется окно браузера со Swagger’ом где мы и увидим созданный нами методи и даже, при необходимости, сможем его протестировать.
Сам файл с данными JSON необходимо положить в папку к проекту, чтобы приложение могло его обнаружить.
Организация постраничной навигации по набору данных в Entity Framework
Так как у нас в БД планируется хранение даже не одно, а сотен тысяч записей, то, естественно, выдать за один запрос такой набор данных пользователю — смерти подобно. Поэтому, удобнее всего, в этом случае организовать постраничную навигацию по набору данных. Чтобы это сделать, нам потребуется разработать ещё два класса:
- класс для хранения информации о том на какой странице находится пользователь и сопутствующей информации (например, есть ли ещё страницы в наборе данных)
- класс, содержащий данные по полученному для очередной страницы набору данных
Так как эти классы мы будем сериализовать в итоге в JSON и передавать клиенту, то разместим их в нашей библиотеке OELibrary. Создадим в папке Models проекта OELibrary класс PageViewModel следующего вида:
using System.Text.Json.Serialization;
namespace OpenEcologyAPI.Models
{
public class PageViewModel
{
public int CurrentPage { get; set; } //текущая страница на которой находится пользователь
public int PageCount { get; set; } //общее количество страниц
public PageViewModel(int count, int pageNumber, int pageSize)
{
CurrentPage = pageNumber;
PageCount = (int)Math.Ceiling(count / (double)pageSize);
}
public PageViewModel()
{
CurrentPage = 0;
PageCount = 0;
}
//true - если пользователь может запросить предыдущую страницу
[JsonIgnore]
public bool HasPreviousPage
{
get
{
return (CurrentPage > 1);
}
}
//true - если пользователь может запросить следующую страницу
[JsonIgnore]
public bool HasNextPage
{
get
{
return (CurrentPage < PageCount);
}
}
}
}
В этом классе мы определили атрибуты сериализации, а именно, исключили из сериализации свойства HasNextPage и HasPreviousPage так как оба эти свойства, по сути, определяются в зависимости от значений других свойств. Также мы добавили конструктор без параметров, так как это является одним из условий сериализации/десериализации JSON в C#.
Всё в той же папке Models создадим ещё один класс — IndexViewModel. Этот класс будет содержать набор данных по источникам загрязнения из БД и объект класса PageViewModel
using OELibrary.Models;
namespace OpenEcologyAPI.Models
{
public class IndexViewModel
{
public IEnumerable<Pollutant> Pollutants { get; set; }
public PageViewModel ViewModel { get; set; }
}
}
Вернемся в проект OpenEcologyApi. Теперь создадим в папке Controllers ещё один контроллер — PollutantController и напишем для него метод Index, который на запрос пользователя будет возвращать JSON, содержащий объект класса IndexViewModel:
using Microsoft.AspNetCore.Mvc;
using OpenEcologyAPI.Data;
using OpenEcologyAPI.Models;
using OELibrary.Models;
using Microsoft.EntityFrameworkCore;
namespace OpenEcologyAPI.Controllers
{
[ApiController]
[Route("pollutants")]
public class PollutantController : Controller
{
PollutantContext _context;
public PollutantController(PollutantContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> Index(int page = 1, int pageSize = 10)
{
int elementCount = pageSize;
if (elementCount>50)
elementCount = 50;
var count = await _context.Pollutants.CountAsync();
var items = await _context.Pollutants.Skip((page - 1) * elementCount).Take(elementCount).Include(x=>x.facts).ToListAsync();
foreach (Pollutant pollutant in items)
foreach (Fact fact in pollutant.facts)
{
IQueryable<Air>? airs = _context.Air.Where(x => x.FactId == fact.Id);
fact.air = airs.ToArray();
}
PageViewModel pageViewModel = new PageViewModel(count, page, pageSize);
IndexViewModel viewModel = new IndexViewModel
{
ViewModel = pageViewModel,
Pollutants = items
};
return new JsonResult(viewModel);
}
}
}
В параметрах метода мы получаем номер очередной страницы и количество элементов, которые необходимо вернуть пользователю. Если пользователь задает количество элементов на странице более 50, то сервер вернет только 50 записей. Наполнение страницы данными происходит с использованием методов Skip и Take LINQ, о которых вы можете более подробнее узнать в этой статье.
Теперь можно запустить приложение и протестировать работу с БД (конечно, предварительно наполнив её данными с использованием ранее написанного контролера UpdateController) . Например, я выполнил вот такой запрос:
в ответ я получил следующий JSON:
{
"pollutants": [
{
"id": 199,
"name": "пилорама ИП Триллер Ж.В.",
"number": "04-0224-002089-П",
"registry_type": "Regional",
"registry_category": "3",
"inclusion_date": "2018-12-07T13:57:17",
"org_full_name": "Индивидуальный предприниматель Триллер Жанна Валерьевна",
"value_co2": 0,
"facts": [
{
"id": 207,
"pollutantId": 199,
"air": [
{
"id": 2223,
"factId": 207,
"annual_value": 0.100037,
"code": "2908",
"name": "Пыль неорганическая, содержащая 70-20% двуокиси кремния (шамот, цемент, пыль цементного производства - глина, глинистый сланец, доменный шлак, песок, клинкер, зола кремнезем и др.)"
},
{
"id": 2224,
"factId": 207,
"annual_value": 0.011731,
"code": "2732",
"name": "Керосин"
},
{
"id": 2225,
"factId": 207,
"annual_value": 0.005549,
"code": "0304",
"name": "Азот (II) оксид (Азота оксид)"
},
{
"id": 2226,
"factId": 207,
"annual_value": 0.114658,
"code": "0328",
"name": "Углерод (Сажа)"
},
{
"id": 2227,
"factId": 207,
"annual_value": 0.03415,
"code": "0301",
"name": "Азота диоксид (Азот (IV) оксид)"
},
{
"id": 2228,
"factId": 207,
"annual_value": 0.032792,
"code": "0330",
"name": "Сера диоксид (Ангидрид сернистый)"
},
{
"id": 2229,
"factId": 207,
"annual_value": 0.026578,
"code": "2936",
"name": "Пыль древесная"
},
{
"id": 2230,
"factId": 207,
"annual_value": 0.000521,
"code": "2909",
"name": "Пыль неорганическая, содержащая двуокись кремния менее 20% двуокиси кремния (доломит, пыль цементного производства - известняк, мел, огарки, сырьевая смесь, пыль вращающихся печей, боксит и др.)"
},
{
"id": 2231,
"factId": 207,
"annual_value": 2e-7,
"code": "0703",
"name": "Бенз/а/пирен (3,4-Бензпирен)"
},
{
"id": 2232,
"factId": 207,
"annual_value": 0.304296,
"code": "0337",
"name": "Углерод оксид"
}
]
}
]
},
{
"id": 200,
"name": "Обособленное подразделение \"Централизованная сервисная служба\"",
"number": "14-0131-000502-П",
"registry_type": "Federal",
"registry_category": "3",
"inclusion_date": "2016-12-20T10:48:05",
"org_full_name": "ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ \"БИЗНЕС ФУД СФЕРА\"",
"value_co2": 0,
"facts": [
{
"id": 208,
"pollutantId": 200,
"air": [
{
"id": 2233,
"factId": 208,
"annual_value": 0.143323,
"code": "0301",
"name": "Азота диоксид (Азот (IV) оксид)"
},
{
"id": 2234,
"factId": 208,
"annual_value": 0.06048,
"code": "0123",
"name": "диЖелезо триоксид /в пересчете на железо/ (Железа оксид)"
},
{
"id": 2235,
"factId": 208,
"annual_value": 0.023289,
"code": "0304",
"name": "Азот (II) оксид (Азота оксид)"
},
{
"id": 2236,
"factId": 208,
"annual_value": 0.441468,
"code": "0337",
"name": "Углерод оксид"
},
{
"id": 2237,
"factId": 208,
"annual_value": 0.04032,
"code": "2930",
"name": "Пыль абразивная (Корунд белый; Монокорунд)"
}
]
}
]
}
],
"viewModel": {
"currentPage": 100,
"pageCount": 13127,
"hasPreviousPage": true,
"hasNextPage": true
}
Ответ, как и ожидалось, содержит данные по различным объектам. Теперь нам осталось только настроить наш сервер, а именно, разрешить кроссдоменные запросы — включить CORS. Перейдем в файл Program.cs и добавим в него следующие строки:
<--Код сокращен для экономии места--> builder.Services.AddCors(); <--Код сокращен для экономии места--> var app = builder.Build(); <--Код сокращен для экономии места--> app.UseCors(builder => builder.AllowAnyOrigin()); <--Код сокращен для экономии места-->
На данном этапе нам достаточно тех возможностей сервера, которые мы в него добавили, а именно: наполнение БД из файла JSON и постраничная навигация по набору данных. Теперь можно вернуться к основному приложения Blazor WASM и организовать получение данных от сервера.
Blazor WebAssembly. Получение и отображение данных из БД MySQL
В предыдущей части для работы с данными о загрязнителях мы создали отдельный компонент, который назвали AirData. С ним мы и продолжим работу и добавим в файл AirData.razor следующий код:
@page "/Air_data"
@inject HttpClient Http
@using OELibrary.Models
<h3>Данные об объектах негативного воздействия</h3>
<table class="table">
<thead>
<tr>
<th scope="col">Код</th>
<th scope="col">Название</th>
<th scope="col">Категория</th>
<th scope="col">Организация</th>
</tr>
</thead>
<tbody>
@{
if ((model != null)&&(model.Pollutants !=null))
{
foreach (Pollutant item in model.Pollutants)
{
<tr>
<td>@item.number</td>
<td>@item.name</td>
<td>@item.registry_category</td>
<td>@item.org_full_name</td>
</tr>
}
}
}
</tbody>
</table>
@if (prevPage > 0)
{
<button @onclick="(()=>GetData(prevPage))">Назад</button>
}
@if (nextPage > 0)
{
<button @onclick="(() => GetData(nextPage))">Вперед</button>
}
@if ((model != null)&&(model.Pollutants !=null))
{
<p>Страница @model.ViewModel.CurrentPage из @model.ViewModel.PageCount</p>
}
@code {
public int prevPage = 0;
public int nextPage = 0;
public int currentPage = 1;
public IndexViewModel model { get; internal set; } = new();
protected override async Task OnInitializedAsync()
{
await GetData();
}
public async Task GetData(int page = 1)
{
model = await Http.GetFromJsonAsync<IndexViewModel>($"https://localhost:5001/pollutants?page={page}");
if (model.ViewModel.HasPreviousPage)
prevPage = page-1;
if (model.ViewModel.HasNextPage)
nextPage = page+1;
currentPage = page;
await InvokeAsync(StateHasChanged);
}
}
Рассмотрим, что происходит теперь в компоненте AirData. В методе GetData мы запрашиваем очередную страницу с данными с сервера и запоминаем текущую, следующую и предыдущие страницы (их номера). В визуальной части компонента мы выводим таблицу с полученными данными, а также, в зависимости от того на какой странице мы находимся — показываем кнопки «Назад» и «Вперед», а также информацию на какой странице мы находимся. Визуально это выглядит следующим образом:
На сегодня, думаю, достаточно. В следующий раз продолжим работу с набором данных и сделаем наш компонент ещё удобнее.
Итого
Сегодня наш проект пополнился новыми возможностями — мы начали разработку библиотеку классов, написали (пусть ещё и не полностью) небольшой сервер для работы с базой данных MySQL и научились запрашивать данные с сервера из нашего приложения Blazor WASM. В следующей части мы добавим новые возможности в наш проект и сделаем его более интересным.










