Содержание
В предыдущей части мы написали небольшой компонент 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. В следующей части мы добавим новые возможности в наш проект и сделаем его более интересным.