Рассмотрим с точки зрения производительности варианты размещения логики по заполнению модели для трёх-уровневой и четырёх-уровневой архитектур при использовании различных технологий взаимодействия между уровнями на стеке .NET (Web API, Web API OData, WCF net.tcp, WCF Data Services).
Преамбула
Часто при автоматизировании чего-либо применяется подход к проектированию ПО на основе предметной области, так называемый Domain Driven Design. Сутью этого подхода является создание программной модели предметной области, содержащей описание объектов предметной области, их отношений и возможных операций с ними. Наличие такой модели делает удобным автоматизацию сложных бизнес-процессов.
Как писал Фаулер в «Архитектуре корпоративных программных приложений»
Ценность модели предметной области состоит в том, что, освоившись с подходом, вы получаете в свое распоряжение множество приемов, позволяющих поладить с возрастающей сложностью бизнес-логики «цивилизованным» путем.
Такой подход к проектированию является объектно-ориентированным, поэтому в результате мы получаем набор классов-репозиториев для совершения CRUD и прочих операций над объектами предметной области и DTO для передачи данных этих самых объектов.
Частенько эти классы реализуют как веб сервиса и выделают на отдельный уровень (назовём его AppServer), с которым взаимодействуют различные уровни отображения – вебсервера, мобильные клиенты и т.д. Годную реализацию такого подхода можно посмотреть в курсе Pluralsight Building End-toEnd Multi-Client Service Oriented Applications.
Ремарка
Называние курса как-бы намекает, что они реализуют SOA архитектуру. Мне кажется, они реализуют её с большой натяжкой, т.к., например, в книжке «Руководство Microsoft по проектированию архитектуры приложений» чётко отмечен основной признак SOA архитектуры – автономность сервисов, возможность их распределенного развертывания, независимого обслуживания и развития. ИМХО сервис должен обладать своим независимым хранилищем данных, чтобы являться частью SOA. У ребят из Pluralsight просто фасад доступа к модели предметной области реализован как набор WCF сервисов, не более того.
В других случаях эти классы являются просто отдельным слоем, с которым взаимодействует слой представления.
Однако почти всегда DTO, возвращаемые такими классами не могут служить моделью для View UI, т.к. содержит или слишком много или слишком мало данных. Частично это обусловлено тем, что уровни отображения существуют разные (мобильные клиенты, вебсервера) и для них нужен разный UI, хотя работают они с одной моделью предметной области. Частично тем, что просто по требованиям на конкретном экране надо показать данные нескольких объектов предметной области.
Таким образом, очевидно, что модели для UI не отображаются 1 в 1 на DTO, используемые для хранения данных объектов предметной области, и поэтому их надо где-то формировать.
А где их формировать и почему это важно?
Как оказывается, место их формирования, а также применяемые при этом технологии взаимодействия между уровнями могут легко сделать из хорошей системы плохую в глазах пользователей.
Ремарка
Есть мнение, что хорошая система автоматизации отличается от плохой тем, что позволяет пользователям выполнять свою работу быстрее, чем раньше.
Рассмотрим с точки зрения производительности следующие варианты формирования модели для отображения в UI:
Трёхуровневая архитектура (браузер + вебсервер + сервер БД)
- Модель формируется на клиенте
- Модель формируется на вебсервере
Четырёхуровневая архитектура (браузер + вебсервер + аппсервер + сервере БД)
- Модель формируется на вебсервере
- Модель формируется на аппсервере
Итак, у нас 4 базовых варианта.
Для того, чтобы было интереснее – будем использовать разные технологии из стека .NET для взаимодействия между уровнями.
А для того, чтобы было совсем интересно – в виде модели будем передавать «реальный» объём данных (~150кб в несжатом виде), а браузер разместим подальше от основного стенда (благо, размеры нашей Родины это позволяют).
Используемые технологии и инструментарий
Браузер – Chrome 48, модель с вебсервера будем получать в JSON с помощью JQuery 2.
Вебсервер – IIS 6.1, модель будет отдавать с помощью Web API 2 (в одном из сценариев WebAPI OData v3) с использованием gzip компрессии. JSON сериализатор стандартный. Данные будет получать либо от Аппсервера (в четырёх-уровневой архитектуре), либо напрямую из БД с помощью EF 6 (в трёхуровневой).
Аппсервер – IIS 6.1, данные будет отдавать либо с помощью WCF Service (Net.TCP binding), либо c помощью WCF DataService v3 (без компрессии). C БД будет общаться с помощью EF 6.
Сервер БД — MS SQL 2014 standart.
Распределение уровней по железу
Браузер, вебсервер, аппсервер, сервер БД – все на разных физических машинах.
Конфигурация железа:
- Браузер – обычный офисный компьютер на базе Core i5
- Вебсервер – виртуальный серверок на базе Xeon E5504, 12 GB
- Аппсервер – виртуальный серверок на базе Xeon E5504, 16 GB
- Сервер БД – виртуальный сервер на базе Xeon E5-2620, 32 GB, Raid 10 SAS
Вебсервер, аппсервер, сервер БД – в одной сети, территориально в Москве. Соединены 10ТБ сетью. Вебсервер торчит в интернеты за Microsoft Forefront TMG.
Браузер – снаружи, территориально в Москве и Уфе (будем рассматривать два варианта). Соединены с вебсервером обычными «хорошими» интернетами через WIFI.
Данные
В БД содержится простая табличка (Guid, Name) с 10 тыс. строк данных.
Для формирования модели из этой таблички с помощью EF берутся три набора данных по 1000 строк начиная с произвольного значения (Skip, Take). Первый набор оставляется без изменений, второй и третий фильтруются в коде заполнения модели. Суммарный размер строк трёх наборов равен 2000 объектов или ~150 кб.
Каждый набор данных возвращается как проперть модели. При этом, первый набор получается синхронно, а второй и третий – асинхронно.
Таким образом и БД и EF у нас присутствуют, но вносят свой вклад во время получения данных по-минимому (т.к. мы тестируем не EF и не БД, а архитектуру в целом), но размер модели большой.
Что и как меряем
Замеряем время в браузере между началом запроса модели и получением результата в виде объектов javascript. Рендеринг результатов в браузере присутствует, но не замеряется.
После рендеринга модели после 1с ожидания всё повторяем заново.
Таким образом делаем ~500 измерений и строим график распределения результатов в процентном соотношении в зависимости от времени.
Будем проводить две серии измерений одновременно, разместив браузеры территориально в разных местах (Москва и Уфа)
Измерения будем проводить в рабочее время, когда каналы и сервера штатно загружены.
Тестовые стенды
Таким образом формируем следующие тестовые стенды
- Четырёхуровневая архитектура, модель формируется на вебсервере, аппсервер доступен через WCF DataService, результат в браузер передаётся через Web API
- Четырёхуровневая архитектура, модель формируется на вебсервере, аппсервер доступен через WCF сервис по net.tcp протоколу, результат в браузер передаётся через Web API
- Четырёхуровневая архитектура, модель формируется на аппсервере, который доступен через WCF сервис по net.tcp протоколу, результат в браузер передаётся через Web API
- Трёхуровневая архитектура, модель формируется на вебсервере, результат в браузер передаётся через Web API
- Трёхуровневая архитектура, модель формируется в браузере с помощью вебсервера, доступного через Web API Odata
Дальше описание каждой модели и результаты, идём от самой тормозной к самой быстрой
Четырёхуровневая архитектура, модель формируется на вебсервере, аппсервер доступен через WCF DataService, результат в браузер передаётся через Web API
Особенности
Поскольку для доступа к серверу приложений используется WCF DataService (который работает поверх модели EF) – для реализации различных запросов на получения данных не надо писать методы в этом самом сервисе. Запросы можно составлять через URL, либо используя LINQ
Код получения данных модели
public class DocumentListController : ApiController
{
// GET: api/DocumentList/
public async Task<DocumentListViewModel> Get()
{
var result = new DocumentListViewModel();
var appServer = new DocumentsServiceHelper();
result.AllDocuments = await Task.Run(() => appServer.GetAllDocuments());
var data = await Task.WhenAll(
Task.Run(() => appServer.GetEvenDocuments()),
Task.Run(() => appServer.GetOddDocuments()));
result.EvenDocuments = data[0];
result.OddDocuments = data[1];
return result;
}
}
public class DocumentsServiceHelper
{
private const string DocumentsServiceUrl = @"http://xxx/appdataserver/documentsservice.svc/";
public DocumentItem[] GetAllDocuments()
{
var context = new Documents(new Uri(DocumentsServiceUrl));
var rnd = new Random();
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToArray();
}
public DocumentItem[] GetEvenDocuments()
{
var context = new Documents(new Uri(DocumentsServiceUrl));
var rnd = new Random();
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 != 0).ToArray();
}
public DocumentItem[] GetOddDocuments()
{
var context = new Documents(new Uri(DocumentsServiceUrl));
var rnd = new Random();
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 == 0).ToArray();
}
}
}
Код сервиса
[System.ServiceModel.ServiceBehavior(IncludeExceptionDetailInFaults = true)]
public class DocumentsService : EntityFrameworkDataService<Documents>
{
public static void InitializeService(DataServiceConfiguration config)
{
config.SetEntitySetAccessRule("*", EntitySetRights.AllRead);
config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3;
}
}
Результаты
Как видим, почти все запросы попадают в интервал 850-1100 мс, а примерно 5% выполняются больше 1.1с. Это для Москвы. Для Уфы картинка хуже.
Вывод
WCF Data Services — не наш вариант
Трёхуровневая архитектура, модель формируется в браузере с помощью вебсервера, доступного через Web API Odata
Описание
Поскольку модель формируется с помощью Web API OData (который также работает поверх модели EF) – для реализации различных запросов на получения данных не надо писать методы в Web API контроллере. Запросы можно составлять через URL.
Поскольку модель формируется в браузере – на вебсервер едет три Web API запроса в отличии от всех остальных сценариев, где таких запросов одна штука.
Код формирования модели (на Type Script)
class DocumentItem {
Id: string;
Name: string;
constructor(_id: string, _name: string) {
this.Id = _id;
this.Name = _name;
}
}
class DocumentsController {
DocumentItems: DocumentItem[];
OddDocumentItems: DocumentItem[];
EvenDocumentItems: DocumentItem[];
constructor() {
this.DocumentItems = [];
this.EvenDocumentItems = [];
this.OddDocumentItems = [];
}
public FillData(): Promise<any> {
var p = new Promise<any>((resolve, reject) => {
var queryOne = this.ExecuteQuery(this.GetQuery())
.then((data: DocumentItem[]) => {
this.DocumentItems = data;
var queryTwo = this.ExecuteQuery(this.GetQuery());
var queryThree = this.ExecuteQuery(this.GetQuery());
var result = Promise.all([queryTwo, queryThree]);
result
.then((data: DocumentItem[][]) => {
this.EvenDocumentItems = data[0].filter((x, i, all) => i % 2 != 0);
this.OddDocumentItems = data[1].filter((x, i, all) => i % 2 == 0);
resolve();
}, reject);
}, reject);
});
return p;
}
private GetQuery(): string {
var random = Math.floor(Math.random() * 9000);
return "$orderby=Id desc&$skip=" + random + "&$top=1000";
}
private ExecuteQuery(query: string): Promise<DocumentItem[]> {
var uri = "odata/Documents?";
var p = new Promise((resolve, reject) => {
$.getJSON(uri + query)
.done((data: any) => {
var realData = $.map<any, DocumentItem>(data.value, (x: any, i: number) => new DocumentItem(x.Id, x.Name));
resolve(realData);
})
.fail((jqXHR: JQueryXHR, textStatus: string, err: string) => reject(err));
});
return p;
}
}
На сервере код контроллера – шаблонный (Web API 2 OData v3 Controller with actions using Entity Framework), не буду его приводить.
Результаты
Как видим, почти все запросы попадают в интервал 200-350 мс для Москвы и 250-400мс для Уфы, что не так плохо. НО, присутствуют и медленные запросы, больше 800мс (для Уфы таких примерно 5%). Для юзера это будет означать, что UI системы работает почти всегда быстро, НО иногда будет подтормаживать. Это очень раздражает.
Вывод
Такая архитектура является нынче модной. И не сказать, чтобы она сильно тормозит. Но ИМХО она годится только для интранета, только для мощных офисных машин и только для UI десктопных приложений. Версия, тип браузера – сильно влияют на результат. Под IE всё это работает существенно тормознее, я уж не говорю про мобильные браузеры.
Четырёхуровневая архитектура, модель формируется на вебсервере, аппсервер доступен через WCF сервис по net.tcp протоколу, результат в браузер передаётся через Web API
Код Web API контроллера
// GET: api/DocumentList/
public async Task<DocumentListViewModel> Get()
{
var result = new DocumentListViewModel();
var appServer = new DocumentsService.DocumentsServiceClient();
appServer.Open();
result.AllDocuments = await appServer.GetAllDocumentsAsync();
var even = appServer.GetEvenDocumentsAsync();
var odd = appServer.GetOddDocumentsAsync();
var data = await Task.WhenAll(even, odd);
result.EvenDocuments = data[0];
result.OddDocuments = data[1];
appServer.Close();
return result;
}
Код сервиса
public class DocumentsService : IDocumentsService
{
public DocumentItem[] GetAllDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToArray();
}
}
public DocumentItem[] GetEvenDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 != 0).ToArray();
}
}
public DocumentItem[] GetOddDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 == 0).ToArray();
}
}
}
Результат
Как видим, почти все запросы попадают в интервал 100-200 мс для Москвы и 150-250мс для Уфы. И, главное, медленных запросов для Москвы нет, для Уфы – почти нет.
Вывод
С т.з. производительности — однозначно годная архитектура.
Четырёхуровневая архитектура, модель формируется на аппсервере, который доступен через WCF сервис по net.tcp протоколу, результат в браузер передаётся через Web AP
Особенности
Многие скажут, что модель для UI формировать на аппсервере – моветон. Так оно, по сути и есть.
Чтобы сгладить ситуацию, не будем расширять фасад DD модели методами по возврату модели для UI, а создадим на аппсервере новый сервис, который будет использовать фасад DD модели и формировать модель для UI. Вебсервер будет использовать именно этот сервис. Если потребуется реализовать новый UI – на аппсервер должен быть добавлен новый сервис.
В этом сценарии вебсервер шлёт 1 WCF запрос для формирования модели, вместо трёх для всех остальных рассмотренных четырёхуровневых сценариев.
Код формирования модели
public class UIService : IUIService
{
public async Task<DocumentListViewModel> GetModel()
{
var result = new DocumentListViewModel();
var docService = new DocumentsService();
result.AllDocuments = await Task.Run(() => docService.GetAllDocuments());
var even = Task.Run(() => docService.GetEvenDocuments());
var odd = Task.Run(() => docService.GetOddDocuments());
var evenOdd = await Task.WhenAll(even, odd);
result.EvenDocuments = evenOdd[0];
result.OddDocuments = evenOdd[1];
return result;
}
}
}
Код Web API контроллера
// GET: api/DocumentList/
public DocumentListViewModel Get()
{
var appServer = new UIServiceClient();
appServer.Open();
var data = appServer.GetModel();
appServer.Close();
return data;
}
Результаты
Как видим, почти все запросы попадают в интервал 100-150 мс для Москвы и 150-250мс для Уфы.
Вывод
Cамая быстрая четырёхуровневая архитектура. И самая надёжная, т.к. минимизируется обмен между вебсервером и аппсервером как по количеству данных, так и по частоте вызова.
Трёхуровневая архитектура, модель формируется на вебсервере, результат в браузер передаётся через Web API
Код контроллера
public class DocumentListController : ApiController
{
// GET: api/DocumentList/
public async Task<DocumentListViewModel> Get()
{
var result = new DocumentListViewModel();
var watch = new Stopwatch();
watch.Start();
var docService = new DocumentsService();
result.AllDocuments = await Task.Run(() => docService.GetAllDocuments());
var even = Task.Run(() => docService.GetEvenDocuments());
var odd = Task.Run(() => docService.GetOddDocuments());
var evenOdd = await Task.WhenAll(even, odd);
result.EvenDocuments = evenOdd[0];
result.OddDocuments = evenOdd[1];
watch.Stop();
result.Log = watch.ElapsedMilliseconds.ToString();
return result;
}
}
Код локального репозитория (обёртка над EF)
public class DocumentsService
{
public DocumentItem[] GetAllDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToArray();
}
}
public DocumentItem[] GetEvenDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 != 0).ToArray();
}
}
public DocumentItem[] GetOddDocuments()
{
var rnd = new Random();
using (var context = new Documents())
{
return context.DocumentItems.OrderBy(x => x.Id).Skip(rnd.Next(0, 9000)).Take(1000).ToList().Where((x, i) => i % 2 == 0).ToArray();
}
}
}
Результат
Как видим, почти все запросы попадают в интервал 100-150 мс для Москвы и 100-200мс для Уфы.
Вывод
Если не нужна четырёхуровневая архитектура, то это – самый быстрый вариант.