Так же, я постараюсь учесть замечания первой части.
Архитектура Onion
Преположим, что мы проектируем приложение для того, чтобы фиксировать, какие книги мы прочитали, а для точности, то хотим фиксировать даже то, сколько страниц было прочитано. Мы знаем, что это личная программа, которая нам нужна на нашем смартфоне, как бот для телеграмм и, возможно, для декстопа, так что смело выбираем такой вариант архитектуры:
(Tg Bot, Phone App, Desktop) => Asp.net Web Api => Database
Создаем проект в Visual studio типа Asp.net Core, где далее выбираем тип проекта Web Api.
Чем он отличается от обычного?
Во-первых, класс контроллера наследуется от класса ControllerBase, который разработан, как базовый для MVC без поддержки возврата представлений(html-кода).
Во-вторых, он предназначен для реализации REST сервисов с охватом всех видов HTTP запросов, а ответом на запросы Вы получаете json с явным указанием статуса ответа. Так же, Вы увидите, что контроллер, который создастся по-умолчанию, будет помечен атрибутом [ApiController], который имеет полезные опции именно для API.
Теперь нужно решить, как же хранить данные. Так как я знаю, что я читаю не более 12 книг в год, то мне будет достаточно csv-файла, который и будет представлять БД.
Поэтому я создаю класс, который описывает книгу:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WebApiTest
{
public class Book
{
public int id { get; set; }
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}
}
А затем описываю класс работы с БД:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WebApiTest
{
public class CsvDB
{
const string dbPath = @"C:\\csv\books.csv";
private List<Book> books;
private void Init()
{
if (books != null)
return;
string[] lines = File.ReadAllLines(dbPath);
books = new List<Book>();
foreach(var line in lines)
{
string[] cells = line.Split(';');
Book newBook = new Book()
{
id = int.Parse(cells[0]),
name = cells[1],
author = cells[2],
pages = int.Parse(cells[3]),
readedPages = int.Parse(cells[4])
};
books.Add(newBook);
}
}
public int Add(Book item)
{
Init();
int nextId = books.Max(x => x.id) + 1;
item.id = nextId;
books.Add(item);
return nextId;
}
public void Delete(int id)
{
Init();
Book selectedToDelete = books.Where(x => x.id == id).FirstOrDefault();
if(selectedToDelete != null)
{
books.Remove(selectedToDelete);
}
}
public Book Get(int id)
{
Init();
Book book = books.Where(x => x.id == id).FirstOrDefault();
return book;
}
public IEnumerable<Book> GetList()
{
Init();
return books;
}
public void Save()
{
StringBuilder sb = new StringBuilder();
foreach(var book in books)
sb.Append($"{book.id};{book.name};{book.author};{book.pages};{book.readedPages}");
File.WriteAllText(dbPath, sb.ToString());
}
public bool Update(Book item)
{
var selectedBook = books.Where(x => x.id == item.id).FirstOrDefault();
if(selectedBook != null)
{
selectedBook.name = item.name;
selectedBook.author = item.author;
selectedBook.pages = item.pages;
selectedBook.readedPages = item.readedPages;
return true;
}
return false;
}
}
}
Дальше дело за малым, дописать API, чтобы была возможность взаимодействовать с ним:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace WebApiTest.Controllers
{
[ApiController]
[Route("[controller]")]
public class BookController : ControllerBase
{
private CsvDB db;
public BookController()
{
db = new CsvDB();
}
[HttpGet]
public IEnumerable<Book> GetList() => db.GetList();
[HttpGet("{id}")]
public Book Get(int id) => db.Get(id);
[HttpDelete("{id}")]
public void Delete(int id) => db.Delete(id);
[HttpPut]
public bool Put(Book book) => db.Update(book);
}
}
А дальше осталось лишь дописать UI, который был бы удобен. И все работает!
Круто же! Но нет, жена попросила, чтобы у неё тоже был доступ к такой удобной штуке.
Какие же нас ждут трудности? Во-первых, теперь нужно для всех книг добавить столбец, который обозначит айди пользователя. Поверьте, это не будет комфортно в случае с csv-файлом. Так же, теперь нужно и самих юзеров добавить! Да и теперь нужна какая-либо логика, чтобы жена не видела, что я дочитываю третий сборник Донцовой, вместо обещанного Толстого.
Давайте попробуем расширить этот проект к нужным требованиям:
Возможность создать аккаунт юзера, у который сможет хранить список своих книг и дополнять, сколько в какой он прочитал.
Честно, я хотел написать пример, но количество вещей, которые не хотелось бы делать, резко убили желание.
- Создание контроллера, который отвечал бы за авторизацию и отдачу данных именно юзера;
- Создание новой сущности Пользователь, а так же обработчика под неё;
- Впихивание логики или в сам контроллер, из-за чего тот бы раздуло, или же в отдельный класс;
- Переписывания логики работы с «базой данных», ведь теперь или два csv-файла, или же переходить на БД…
В итоге, мы получили большой монолит, который очень «больно» расширять. В нём большой набор тесных связей в приложении. Сильно связанный объект зависит от другого объекта; это означает, что изменение одного объекта в тесно связанном приложении часто требует изменения ряда других объектов. Это несложно, когда приложение небольшое, но в приложении корпоративного уровня слишком сложно внести изменения.
Слабые же связи, подразумевают, что два объекта независимы, и один объект может использовать другой объект, не будучи зависимым от него. Это тип связи направлен ??на уменьшение взаимозависимостей между компонентами системы с целью снижения риска того, что изменения в одном компоненте потребуют изменений в любом другом компоненте.
Термин «луковая архитектура» был введен Джеффри Палермо в 2008 году. Эта архитектура обеспечивает лучший способ создания приложений, обеспечивающих лучшую тестируемость, ремонтопригодность и надежность таких инфраструктур, как базы данных и службы. Основная цель этой архитектуры — решить проблемы, с которыми сталкивается трехуровневая архитектура или многоуровневая архитектура, и предоставить решение общих проблем, таких как объединение и разделение задач.
Вольный перевод отсюда
Поэтому мы попробуем реализовать наше приложение в Onion-стиле, чтобы показать преимущества данного способа.
Onion-архитектура представляет собой разделение приложения на уровни. При чем есть один независимый уровень, который находится в центре архитектуры.
Onion-архитектура во многом опирается на принцип инверсии зависимостей. Пользовательский интерфейс взаимодействует с бизнес-логикой через интерфейсы.
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те и другие должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Классический проект в таком стиле состоит из четырех слоев:
- Уровень объектов домена(Core)
- Уровень репозитория(Repo)
- Уровень обслуживания(Service)
- Уровень внешнего интерфейса (веб / модульный тест)(Api)
Все слои направлены к центру(Core). Центр независим.
Уровень объектов домена
Это центральная часть приложения в которой описываются объекты, которые работают с базой данных.
Создадим новый проект в решении, который будет иметь выходной тип «Библиотека классов». Я назвал его WebApiTest.Core
Создадим класс BaseEntity, который будет будет иметь общие свойства объектов.
public class BaseEntity
{
public int id { get; set; }
}
Далее, создадим класс Book, который наследуется от BaseEntity
{
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}
Для нашего приложения этого будет пока-что достаточно, так что переходим к следующему уровню
Уровень репозитория
Теперь перейдем к реализации уровня репозитория. Создаем проект с типом «Библиотека классов» с названием WebApiTest.Repo.
Мы будем использовать внедрение зависимостей, поэтому мы будем передавать параметры через конструктор, чтобы сделать их более гибкими. Таким образом, мы создаем общий интерфейс репозитория для операций с сущностями, чтобы мы могли разработать слабосвязанное приложение. Приведенный ниже фрагмент кода предназначен для интерфейса IRepository.
public interface IRepository <T> where T : BaseEntity
{
IEnumerable<T> GetAll();
int Add(T item);
T Get(int id);
void Update(T item);
void Delete(T item);
void SaveChanges();
}
Теперь давайте реализуем класс репозитория для выполнения операций с базой данных над сущностью, которая реализует IRepository. Этот репозиторий содержит конструктор с параметром pathToBase, поэтому, когда мы создаем экземпляр репозитория, мы передаем путь к файлу, чтобы класс понимал, откуда забирать данные.
public class CsvRepository<T> : IRepository<T> where T : BaseEntity
{
private List<T> list;
private string dbPath;
private CsvConfiguration cfg = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = false,
Delimiter = ";"
};
public CsvRepository(string pathToBase)
{
dbPath = pathToBase;
using (var reader = new StreamReader(pathToBase)) {
using (var csv = new CsvReader(reader, cfg)) {
list = csv.GetRecords<T>().ToList(); }
}
}
public int Add(T item)
{
if (item == null)
throw new Exception("Item is null");
var maxId = list.Max(x => x.id);
item.id = maxId + 1;
list.Add(item);
return item.id;
}
public void Delete(T item)
{
if (item == null)
throw new Exception("Item is null");
list.Remove(item);
}
public T Get(int id)
{
return list.SingleOrDefault(x => x.id == id);
}
public IEnumerable<T> GetAll()
{
return list;
}
public void SaveChanges()
{
using (TextWriter writer = new StreamWriter(dbPath, false, System.Text.Encoding.UTF8))
{
using (var csv = new CsvWriter(writer, cfg))
{
csv.WriteRecords(list);
}
}
}
public void Update(T item)
{
if(item == null)
throw new Exception("Item is null");
var dbItem = list.SingleOrDefault(x => x.id == item.id);
if (dbItem == null)
throw new Exception("Cant find same item");
dbItem = item;
}
Мы разработали сущность и контекст, которые необходимы для работы с базы данных.
Уровень обслуживания
Теперь мы создаем третий уровень луковой архитектуры, который является уровнем обслуживания. Я назвал его WebApiText.Service. Этот уровень взаимодействует как с веб-приложениями, так и с проектами репозиториев.
Мы создаем интерфейс с именем IBookService. Этот интерфейс содержит сигнатуру всех методов, к которым обращается внешний уровень для объекта Book.
public interface IBookService
{
IEnumerable<Book> GetBooks();
Book GetBook(int id);
void DeleteBook(Book book);
void UpdateBook(Book book);
void DeleteBook(int id);
int AddBook(Book book);
}
Теперь реализуем его в классе BookService
public class BookService : IBookService
{
private IRepository<Book> bookRepository;
public BookService(IRepository<Book> bookRepository)
{
this.bookRepository = bookRepository;
}
public int AddBook(Book book)
{
return bookRepository.Add(book);
}
public void DeleteBook(Book book)
{
bookRepository.Delete(book);
}
public void DeleteBook(int id)
{
var book = bookRepository.Get(id);
bookRepository.Delete(book);
}
public Book GetBook(int id)
{
return bookRepository.Get(id);
}
public IEnumerable<Book> GetBooks()
{
return bookRepository.GetAll();
}
public void UpdateBook(Book book)
{
bookRepository.Update(book);
}
}
Уровень внешнего интерфейса
Теперь мы создаем последний слой луковой архитектуры, который, в нашем случае, внешним интерфейсом, с которым и буду взаимодействовать внешние приложения(бот, десктоп и т.п.). Чтобы создать этот уровень, мы вычищаем наш проект WebApiTest.Api, удаляя класс Book и вычищая BooksController. Этот проект дает возможность для операций с базой данных сущностей, а также контроллер для выполнения этих операций.
Поскольку концепция внедрения зависимостей является центральной для приложения ASP.NET Core, то теперь нам нужно зарегистрировать всё, что мы создали, для использования в приложении.
Внедрение зависимостей
В небольших приложениях на ASP.NET MVC мы относительно легко можем заменить одни классы на другие, вместо одного контекста данных использовать другой. Однако в крупных приложениях это уже будет проблематично сделать, особенно если у нас десятки контроллеров с сотней методов. В этой ситуации нам на помощь может прийти такой механизм как внедрение зависимостей.
И если раньше в ASP.NET 4 и других предыдущих версиях надо было использовать различные внешние IoC-контейнеры для установки зависимостей, такие как Ninject, Autofac, Unity, Windsor Castle, StructureMap, то ASP.NET Core уже имеет встроенный контейнер внедрения зависимостей, который представлен интерфейсом IServiceProvider. А сами зависимости еще называются сервисами, собственно поэтому контейнер можно назвать провайдером сервисов. Этот контейнер отвечает за сопоставление зависимостей с конкретными типами и за внедрение зависимостей в различные объекты.
В самом начале, мы использовали жесткую связь, чтобы использовать CsvDB в контроллере.
private CsvDB db;
public BookController()
{
db = new CsvDB();
}
На первый взгляд ничего плохого здесь нет, но, например, изменилась схема подключения к базе данных: вместо Csv я решил использовать MongoDB или MySql. Кроме того, может потребоваться динамически менять один класс на другой.
В данном случае жесткая связь привязывает контроллер к конкретной реализации репозитория. Такой код по мере расширения приложения сложнее поддерживать и сложнее тестировать. Поэтому рекомендуется уходить от использования жесткосвязанных компонентов к слабосвязанным.
Используя различные методы внедрения зависимостей, можно управлять жизненным циклом создаваемых сервисов. Сервисы, которые создаются механизмом Depedency Injection, могут представлять один из следующих типов:
- Transient: при каждом обращении к сервису создается новый объект сервиса. В течение одного запроса может быть несколько обращений к сервису, соответственно при каждом обращении будет создаваться новый объект. Подобная модель жизненного цикла наиболее подходит для легковесных сервисов, которые не хранят данных о состоянии
- Scoped: для каждого запроса создается свой объект сервиса. То есть если в течение одного запроса есть несколько обращений к одному сервису, то при всех этих обращениях будет использоваться один и тот же объект сервиса.
- Singleton: объект сервиса создается при первом обращении к нему, все последующие запросы используют один и тот же ранее созданный объект сервиса
Для создания каждого типа сервиса во встроенном контейнере .net core предназначен соответствующий метод AddTransient(), AddScoped() и AddSingleton().
Мы могли бы использовать стандартный контейнер(провайдер сервисов), но он не поддерживает передачу параметров, поэтому мне придётся использовать библиотеку Autofac.
Для этого добавим в проект через NuGet два пакета: Autofac и Autofac.Extensions.DependencyInjection.
Теперь изменяем в файле Startup.cs метод ConfigureServices на:
public IServiceProvider ConfigureServices(IServiceCollection services)
{
services.AddMvc();
var builder = new ContainerBuilder();//Создаем контейнер
builder.RegisterType<CsvRepository<Book>>()//Регистрируем CsvRepository
.As<IRepository<Book>>() //Как реализацию IRepository
.WithParameter("pathToBase", @"C:\csv\books.csv")//С параметром pathToBase
.InstancePerLifetimeScope(); //Scope
builder.RegisterType<BookService>()
.As<IBookService>()
.InstancePerDependency(); //Transient
builder.Populate(services); //
var container = builder.Build();
return new AutofacServiceProvider(container);
}
Таким образом, мы связали все реализации с их интерфейсами.
Вернемся к нашему проекту WebApiTest.Api.
Осталось только изменить BooksController.cs
[Route("[controller]")]
[ApiController]
public class BooksController : ControllerBase
{
private IBookService service;
public BooksController(IBookService service)
{
this.service = service;
}
[HttpGet]
public ActionResult<IEnumerable<Book>> Get()
{
return new JsonResult(service.GetBooks());
}
[HttpGet("{id}")]
public ActionResult<Book> Get(int id)
{
return new JsonResult(service.GetBook(id));
}
[HttpPost]
public void Post([FromBody] Book item)
{
service.AddBook(item);
}
[HttpPut("{id}")]
public void Put([FromBody] Book item)
{
service.UpdateBook(item);
}
[HttpDelete("{id}")]
public void Delete(int id)
{
service.DeleteBook(id);
}
}
Жмём F5, ждем открытия браузера, переходим на /books и…
[{"name":"Test","author":"Test","pages":100,"readedPages":0,"id":1}]
Итог
В данном тексте, я хотел обновить все свои знания по архитектурному паттерну Onion, а так же по внедрению зависимостей, обязательно с использованием Autofac.
Цель я считаю выполненной, спасибо, что прочитали ;)
Слои — это способ распределения ответственности и управления зависимостями. Каждый слой несет определенную ответственность. В более высоком слое могут использоваться службы из более низкого слоя, но не наоборот.
Уровни разделяются физически путем запуска на разных компьютерах. С одного уровня можно отправлять вызовы непосредственно на другой уровень или использовать асинхронный обмен сообщениями (очередь сообщений). Каждый слой можно разместить на отдельном уровне, но это не обязательно. Вы можете разместить несколько слоев на одном уровне. Физическое разделение уровней улучшает масштабируемость и устойчивость, но также приводит к увеличению задержки из-за дополнительных операций сетевого взаимодействия.
Matisumi
Так и как с помощью луковой архитектуры вы обработали кейс по добавлению жены в систему?