Некоторое время назад на просторах сети столкнулся с интересной библиотекой Breeze.js. Первая мысль, которая пришла на ум при взгляде на неё: «Да это же как Entity Framework для браузера». В поисках информации и отзывов других пользователей, конечно, первым делом поискал статью на Хабре, но не нашёл, поэтому и решил написать, в надежде, что кому-нибудь это тоже будет полезным. Статья написана в виде tutorial по созданию проекта на основе Breeze.js, Angular.js, ASP.NET Web API и Entity Framework.
Аналогия с Entity Framework возникла потому, что основная работа с библиотекой происходит при помощи класса EntityManager, он хранит модель данных, реализует их получение, следит за изменениями и позволяет их сохранять, чем немного и напоминает DbContext. Помимо этого он предоставляет ещё ряд интересных возможностей, например валидацию на клиентской и серверной стороне, кеширование данных в браузере, экспорт данных для сохранения во временном хранилище (например, при потере связи) и импорт этих данных для последующей синхронизации изменений с сервером.
Клиентская часть библиотеки реализует работу по протоколу OData, поэтому Вы не обязаны использовать какие-то конкретные библиотеки для реализации backend'а. Но команда Breeze.js предоставляет также библиотеки для быстрого создания сервисов на основе следующих технологий:
- ASP.NET Web API + Entity Framework
- ASP.NET Web API + NHibernate (пока beta)
- Node.js + MongoDb (тоже beta)
В этой статье мы будем создавать backend при помощи WebApi + EF.
Для работы EntityManager нуждается в метаданных сущностей, с которыми Вы планируете работать, их связей и правил валидации(если Вы планируете только запрашивать данные, то можно обойтись и без метаданных). Их можно импортировать из специального объекта на клиенте, сформировать посредством вызова соответствующих методов на клиенте, либо использовать наиболее простой способ — это получение метаданных из DbContext или ObjectContext Entity Framework с помощью класса EFContextProvider<>. При этом нужно понимать, что вся схема данных, становится доступна на клиенте, и, если Вы не хотите раскрывать схему полностью и использовать для доступа к данным DTO, то придётся создать специальный контекст, который будет служить только для упрощения формирования нужных Вам метаданных.
Пожалуй, нагляднее будет перейти сразу к практике. В нашем примере будем использовать Entity Framework для доступа к базе данных, WebApi контроллер со специальным атрибутом BreezeController как backend, Angular.js, как основу frontend, ну и конечно же Breeze.js, для доступа к данным из браузера. ASP.NET MVC использовать не будем, так как разметку нам будет строить в браузере Angular.js, он же позаботится и о роутинге. Тем более, не за горами ASP.NET vNext, перейти на который будет значительно проще, если мы используем только WebApi, который не привязан к IIS, в отличие от MVC.
Итак, поехали. Запускаем Visual Studio 2013 любой редакции и создаём новый пустой проект ASP.NET. Заходим в NuGet и устанавливаем следующие пакеты и их зависимости: EntityFramework, Breeze Client and Server — Javascript client with ASP.NET Web API 2 and Entity Framework 6 (это пакет, собранный из нескольких нужных нам + он потянет за собой WebAPI), Breeze Labs: Breeze Angular Service, Angular JS, ну и куда же без Bootstrap, и можно ещё для красоты добавить angular-loading-bar. Далее переходим на вкладку Updates и нажимаем Update All. Так мы получили все свежие версии нужных нам пакетов. Здесь мы установили всё что нужно из NuGet, но, на мой вкус, куда удобнее все frontend библиотеки устанавливать при помощи Bower, если Вам интересно узнать побольше о том, как можно удобно использовать Npm, Bower, Gulp и Grunt в Visual Studio, вот есть перевод статьи на эту тему.
Модель
Наше приложение будет представлять из себя список покупок, где все товары будут разложены по категориям, чтобы рассмотреть работу со связями. Для начала создадим классы моделей данных, используя подход Code First, и будем класть их в папку Models (это имя используется по соглашению, но на практике можно класть куда угодно):
Класс ListItem будет представлять элемент нашего списка
public class ListItem
{
public int Id { get; set; }
public String Name { get; set; }
public Boolean IsBought { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
}
Класс Category — это категория, к которой относится элемент
public class Category
{
public int Id { get; set; }
public String Name { get; set; }
public List<ListItem> ListItems { get; set; }
}
Создаём DbContext
public class ShoppingListDbContext: DbContext
{
public DbSet<ListItem> ListItems { get; set; }
public DbSet<Category> Categories { get; set; }
}
Включим автоматические миграции, чтобы Entity Framework создал нам базу данных, и затем приводил бы её структуру в соответствие с моделью, когда она изменится. Для этого нужно зайти в Tools -> NuGet Package Manager -> Package Manager Console и ввести команду:
Enable-Migrations -EnableAutomaticMigrations
У нас в проекте появилась папка Migrations, а в ней класс Configuration.
Чтобы применить эту конфигурацию к нашему контексту, можно создать для него статический конструктор
using BreezeJsDemo.Migrations;
using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Web;
namespace BreezeJsDemo.Model
{
public class ShoppingListDbContext: DbContext
{
static ShoppingListDbContext()
{
Database.SetInitializer<ShoppingListDbContext>(new MigrateDatabaseToLatestVersion<ShoppingListDbContext, Configuration>());
}
public DbSet<ListItem> ListItems { get; set; }
public DbSet<Category> Categories { get; set; }
}
}
Теперь при первом использовании контекста Entity Framework позаботится о создании базы данный, если её нет, либо о добавлении недостающих таблиц, столбцов, связей, если требуется. В нашем приложении нет ни одной строки подключения, поэтому Entity создаст базу SQL Server Compact в папке App_Data, либо, если у Вас на машине установлен SQL Express, база будет создана на нём.
Контроллер Breeze
Далее создадим WebApi контроллер, который будет отдавать и сохранять наши данные. Для этого создаём папку Controllers (по соглашению, либо в любую другую), нажимаем на ней правой кнопкой Add -> Controller и выбираем Web API Controller — Empty, назовём его, например, DbController.
using Breeze.ContextProvider;
using Breeze.ContextProvider.EF6;
using Breeze.WebApi2;
using BreezeJsDemo.Model;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
namespace BreezeJsDemo.Controllers
{
[BreezeController]
public class DbController : ApiController
{
private EFContextProvider<ShoppingListDbContext> _contextProvider = new EFContextProvider<ShoppingListDbContext>();
public String Metadata () {
return _contextProvider.Metadata();
}
[HttpGet]
public IQueryable<ListItem> ListItems()
{
return _contextProvider.Context.ListItems;
}
[HttpGet]
public IQueryable<Category> Categories()
{
return _contextProvider.Context.Categories;
}
[HttpPost]
public SaveResult SaveChanges(JObject saveBundle)
{
return _contextProvider.SaveChanges(saveBundle);
}
}
}
Разберём этот код поподробнее. Для работы с Breeze его разработчики советуют создавать всего один контроллер на базу данных, как они говорят:
«One controller to rule them all ...»
Это обычный ApiController с атрибутом BreezeControllerAttribute, который выполняет для нас ряд настроек.
В самом начале мы создали экземпляр EFContextProvider, у нас он выполняет три задачи:
- Создаёт экземпляр DbContext
- Помогает нам получить метаданные в требуемом для клиента формате
- Обрабатывает запрос на сохранение данных
Метаданные мы возвращаем методом Metadata, именно по этому пути их будет искать клиент. Все сущности, с которыми мы хотим работать возвращаем, как IQueryable<>, в соответствующих методах, по одному на каждую. Можно ограничить операции с той или иной сущностью при помощи атрибута BreezeQueryableAttribute:
[BreezeQueryable(AllowedQueryOptions= AllowedQueryOptions.Skip | AllowedQueryOptions.Top)]
Он наследуется от QueryableAttribute, и пользоваться и можно абсолютно аналогично.
Так же обратим внимание на единственный метод SaveChanges, он принимает, возможно, уже знакомый Вам JObject, содержащий все текущие изменения, а EFContextProvider выполняет валидацию и сохраняет все изменения в базу, используя наш DbContext.
Клиент
Перед работой с javascript, html, css, less в Visual Studio 2013 очень рекомендую (если Вы ещё этого не сделали) установить Update 4, и после обновления зайти в Tools -> Extensions and Updates -> Online станет доступен Extension Web Essentials 2013 for Update 4, в том числе и в Express версии. Update 4 и этот замечательный плагин, добавляет огромное количество удобных инструментов для веб-разработчика, действительно must-have, подробнее обо всех функциях можно почитать на Официальном сайте разработчиков. Visual Studio поддерживает intellisense и для javascript, для этого всего лишь нужно в папку со скриптами добавить файл _references.js и добавить в него ссылки на ваши .js файлы, чтобы студия смогла их проиндексировать. Так вот Web Essentials может сделать это за Вас, он создаст файл с параметром autosync, и потом студия будет поддерживать его в актуальном состоянии, для этого нужно просто правой кнопкой нажать на папке Scripts -> Add -> _references.js Intellisense file, на мой взгляд, с автодополнением работать намного удобнее
Приступим к созданию клиентской части. Скрипты и разметку приложения будем хранить в папке app. Создаём файл с модулем приложения app.module.js,
(function () {
'use strict';
angular.module('app', ['ngRoute', 'breeze.angular', 'angular-loading-bar']);
})();
Мы объявили модуль нашего приложения, первый параметр — имя модуля, второй — перечисление зависимостей — других модулей, которые используются в нашем. Здесь мы указали зависимость от модуля breeze.angular, в нем есть сервис breeze. В принципе, можно обойтись и без него, и тогда обращаться к объекту window.breeze, он работает абсолютно аналогично, за исключением одного момента: сервис breeze использует $q и $http angular.js, и может инициировать digest, и потому предпочтительнее. angular-loading-bar — симпатичный индикатор загрузки, работающий из коробки без всяких настроек, надо только добавить зависимость, будет наглядно показывать нам, когда breeze подгружает данные с сервера. ngRoute — это стандартный модуль angular.js, отвечающий за роутинг, создадим файл с настройками путей app.config.routes.js.
(function () {
'use strict';
angular.module('app').config(config);
config.$inject = ['$routeProvider'];
function config($routeProvider) {
$routeProvider.
when('/', {
templateUrl: '/app/shoppingList/shoppingList.html'
}).
otherwise({
redirectTo: '/'
});
}
})();
Разберём детально. Здесь в обращении к angular.module нет второго параметра, это значит, что мы не создаём новый модуль, а берём ссылку на уже существующий. Затем вызываем у него метод config, которому передаём функцию конфигурации. Функции config добавляем свойство $inject, ему присвоим массив со списком зависимостей, которые angular передаст функции во входные параметры. Здесь мы запросили провайдер $routeProvider, он используется для конфигурации путей приложения. Метод when задает соответствие между адресом и файлом разметки на сервере. То есть для нашего единственного адреса "/" будет загружена разметка из файла '/app/shoppingList/shoppingList.html' во внутрь тега с директивой ng-view. Метод otherwise позволяет настроить поведение, в случае, если адрес не совпадает ни с одним из роутов, указанных при помощи when, в нашем случаем произойдёт редирект на адрес "/".
Для view с нашим списком покупок создадим в папке /app/shoppingList контроллер shoppingList.controller.js.
(function () {
'use strict';
angular.module('app').controller('ShoppingListController', ShoppingListController);
ShoppingListController.$inject = ['$scope', 'breeze'];
function ShoppingListController($scope, breeze) {
var vm = this;
vm.newItem = {};
vm.refreshData = refreshData;
vm.isItemExists = isItemExists;
vm.saveChanges = saveChanges;
vm.rejectChanges = rejectChanges;
vm.hasChanges = hasChanges;
vm.addNewItem = addNewItem;
vm.deleteItem = deleteItem;
vm.filterByCategory = filterByCategory;
breeze.NamingConvention.camelCase.setAsDefault();
var manager = new breeze.EntityManager("breeze/db");
var categoriesQuery = new breeze.EntityQuery("Categories").using(manager).expand("listItems");
var listItemsQuery = new breeze.EntityQuery("ListItems").using(manager);
activate();
function activate() {
refreshData();
$scope.$watch('vm.filterCategory', function (a, b) {
if (a !== b) {
refreshData();
}
});
}
function refreshData() {
var query = listItemsQuery;
if (vm.filterCategory) {
query = query.where('category.id', breeze.FilterQueryOp.Equals, vm.filterCategory.id);
}
categoriesQuery.execute()
.then(
function (data) {
vm.categories = data.results;
})
.then(
function () {
vm.listItems = query.executeLocally();
}
);
}
function filterByCategory(cat) {
if (vm.filterCategory && vm.filterCategory.name === cat.name) {
vm.filterCategory = undefined;
} else {
vm.filterCategory = cat;
}
}
function saveChanges() {
manager.saveChanges();
}
function rejectChanges() {
manager.rejectChanges();
}
function hasChanges() {
return manager.hasChanges();
}
function addNewItem() {
var category = vm.categories.filter(function (x) { return x.name === vm.newItem.category; });
if (category.length === 0) {
category = manager.createEntity('Category', { name: vm.newItem.category });
vm.categories.push(category);
} else {
category = category[0];
}
var item = manager.createEntity('ListItem', { name: vm.newItem.name, category: category, isBought: false });
vm.listItems.push(item);
vm.newItem = {};
}
function deleteItem(item) {
item.entityAspect.setDeleted();
}
function isItemExists(x) {
return x.entityAspect.entityState.name !== 'Deleted' && x.entityAspect.entityState.name !== 'Detached';
}
}
})();
Здесь мы указали зависимость от сервиса breeze, о котором я упоминал выше, с ним и будем работать. Первым делом обращу внимание на строку breeze.NamingConvention.camelCase.setAsDefault() — так мы заставляем breeze переделывать имена всех свойств объектов в camelCase, ведь так работать в JavaScript намного привычнее. Далее создаём объект EntityManager — он и позволяет нам запрашивать, создавать, удалять сущности, следит за их изменениями и отправляет их на сервер. В конструктор передаём адрес нашего контроллера DbController. Для адресов контроллеров с атрибутом BreezeController по-умолчанию используется префикс "/breeze/". Затем создаем запросы EntityQuery, в конструктор передаём имя метода контроллера, который возвращает нужную сущность. Далее при помощи метода using указываем запросу его EntityManager(вместо этого при запросе можно было использовать метод EntityManager'а executeQuery). Следом мы использовали метод expand, он сообщает серверу о том, что мы хотим загрузить ещё и все ListItem каждой категории, доступ к ним можно будет получить через навигационное свойство listItems.
Функция refreshData использует метод EntityQuery where, чтобы добавить критерий фильтрации запроса listItemsQuery, если выбрана категория vm.filterCategory. Мы получим все ListItem, у которых 'Category.Id' равен vm.filterCategory.id(у нас его устанавливает функция filterByCategory). Вторым параметром where передаётся одно из значений breeze.FilterQueryOp — это перечисление, которое содержит все допустимые операторы фильтрации. Для более сложных условий фильтрации метод where принимает объект класса Predicate, который может содержать в себе несколько условий. Далее для загрузки данных используется метод EntityQuery execute, который выполняет запрос к методу контроллера и возвращает promise. По завершении запроса мы записываем результат в свойство categories контроллера, чтобы затем отобразить в разметке. С помощью expand мы загрузили не только категории но и все элементы списка, соответственно нет нужды запрашивать их снова по сети, поэтому следом мы использовали метод executeLocally, чтобы сделать запрос данных из кэша, результат присваиваем свойству vm.listItems.
EntityQuery содержит еще много полезных методов, помимо where, например:
orderBy/orderByDesc(n) — задаёт сортировку по свойству n
select('a, b, c,… n') — возволяет выбирать не сущность целиком, а проекцию, содержащую только свойства a, b и c… n
take/top — выбирает первые n записей, очень удобно для разбиения на страницы
skip(n) — пропускает n записей, замечательно в сочетании с take
inlineCount — при использовании skip/take(top) возвращает так же общее число записей
executeLocally — делает запрос в кэш, без использования сети
noTracking — заставляет breeze возвращать результат в виде простых javascript объектов, а не сущностей (EntityManager не будет отслеживать их изменения)
и ещё несколько других…
Как уже упоминалось выше, EntityManager отслеживает все изменения, удаление и добавление сущностей. Затем, все изменения можно отправить на сервер для сохранения в базе данных. Для этого используется метод saveChanges, он вызывается асинхронно и возвращает promise. Выяснить, происходили ли какие-нибудь изменения можно при помощи метода hasChanges. Вообще, каждая сущность имеет свойство _backingStore, где содержатся данные, и entityAspect — содержит свойства и методы, которые представляют объект, как сущность Breeze (статус объекта, валидации, оригинальные значения и прочее). В свойстве entityAspect.originalValues мы увидим список исходных значений всех изменившихся свойств. А свойство entityAspect.entityState содержит текущий статус. У сущностей, в которых происходили изменения в entityAspect.entityState будет статус «Modified», а у неизменившихся будет статус «Unchanged». Так же есть статусы: Deleted(объект удалён), Added(новый объект) и Detached(объект «откреплён» от EntityManager, отслеживание изменений не происходит, такой объект можно затем «прикрепить» к любому менеджеру с помощью метода attachEntity). EntityManager так же позволяет отменить все изменения, которые произошли при помощи метода rejectChanges.
Теперь рассмотрим функцию addNewItem, с помошью которой мы добавляем новый элемент списка. Сначала мы ищем по имени категорию нового элемента списка в vm.categories, и, если такой категории у нас ещё нет — создаём её при помощи метода EntityManager — createEntity, первым параметром передаём имя типа создаваемой сущности(либо объект EntityType), вторым — объект, в котором содержатся значения свойств создаваемого объекта. Так же можно указать ещё два параметра: EntityState — задаёт статус созданному объекту и MergeStrategy — стратегию разрешения конфликтов, в случаях, когда сущность с таким ключом уже существует. Затем добавляем таким же образом новый ListItem.
При нажатии на кнопку удаления элемента списка будет вызываться функция deleteItem. В ней мы используем метод сущности entityAspect.setDeleted(), он устанавливает ей статус 'Deleted', и затем, при вызове saveChanges запись в базе будет удалена.
Так же у нас есть функция isItemExists, она будет использоваться для фильтрации списка, чтобы не показывать элементы, котрые уже удалены.
Перейдём к разметке, добавим в корень проекта файл index.html, он будет иметь следующий вид:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Лист покупок</title>
<link href="Content/bootstrap.css" rel="stylesheet" />
<link href="Content/loading-bar.min.css" rel="stylesheet" />
</head>
<body ng-app="app">
<div ng-view></div>
<script src="Scripts/jquery-2.1.1.js"></script>
<script src="Scripts/bootstrap.js"></script>
<script src="Scripts/angular.js"></script>
<script src="Scripts/angular-route.js"></script>
<script src="Scripts/loading-bar.min.js"></script>
<script src="Scripts/breeze.debug.js"></script>
<script src="Scripts/breeze.angular.js"></script>
<script src="app/app.module.js"></script>
<script src="app/app.config.routes.js"></script>
<script src="app/shoppingList/shoppingList.controller.js"></script>
<!--На время разработки, конечно, удобно просто перетаскивать файлы скриптов в разметку,
но на практике, это даст кучу GET запросов к несжатым файлам,
поэтому конечно лучше положить скрипты в единый сжатый bundle, либо использовать библиотеки типа RequireJS.-->
</body>
</html>
Здесь стоит обратить внимание на атрибут ng-app — он используется, чтобы указать Angular корневой элемент для нашего приложения, зачастую, для этого используют элементы html и body. Атрибут ng-view — указывает элемент, в который будет загружена разметка из файла, находящегося по пути templateUrl текущего роута.
Для нашего единственного маршрута '/' это будет файл '/app/shoppingList/shoppingList.html'
<div class="container" ng-controller="ShoppingListController as vm">
<nav class="navbar navbar-default">
<ul class="navbar-nav nav">
<li ng-if="vm.hasChanges()"><a ng-click="vm.saveChanges()"><span class="glyphicon glyphicon-thumbs-up"></span> Сохранить изменения</a></li>
<li ng-if="vm.hasChanges()"><a ng-click="vm.rejectChanges()"><span class="glyphicon glyphicon-thumbs-down"></span> Отменить изменения</a></li>
<li><a ng-click="vm.refreshData()"><span class="glyphicon glyphicon-refresh"></span> Обновить</a></li>
</ul>
</nav>
<h1>Список покупок</h1>
<div ng-if="vm.categories.length>0">
<h4>Фильтр по категориям</h4>
<ul class="nav nav-pills">
<li ng-repeat="cat in vm.categories" ng-class="{active:vm.filterCategory===cat}" ng-if="cat.listItems.length>0">
<a ng-click="vm.filterByCategory(cat)">{{cat.name}} ({{cat.listItems.length}})</a>
</li>
</ul>
</div>
<table class="table table-striped">
<tbody>
<tr>
<td>Добавить</td>
<td><input class="form-control" ng-model="vm.newItem.category" placeholder="Категория" /></td>
<td><input class="form-control" ng-model="vm.newItem.name" placeholder="Название" /></td>
<td><button class="btn btn-success btn-sm" type="button" ng-click="vm.addNewItem()"><span class="glyphicon glyphicon-plus"></span></button></td>
</tr>
<tr ng-repeat="item in vm.listItems | filter: vm.isItemExists | orderBy:'isBought'">
<td><input type="checkbox" ng-model="item.isBought"> Куплено</td>
<td>{{item.category.name}}</td>
<td>{{item.name}}</td>
<td><button class="btn btn-danger" type="button" ng-click="vm.deleteItem(item)"><span class="glyphicon glyphicon-trash"></span></button></td>
</tr>
</tbody>
</table>
</div>
Атрибут ng-controller «присоединяет» контроллер к элементу разметки. Здесь мы использовали синтаксис "controller as", то есть указав «ShoppingListController as vm» мы присвоили контроллеру псевдоним vm, и далее в разметке можем обращаться к свойствам контроллера через точку, например vm.listItems(в коде контроллера мы первой строкой написали var vm = this; — это было сделано для удобства, чтобы в коде конроллера обращаться к свойствам так же). Во многих туториалах по Angular.js используется немного другой подход, значения присваиваются свойствам объекта $scope, а в ng-controller пишется только имя контроллера, и затем в разметке к этим свойствам можно обращаться просто по имени, например так: {{newItem.name}}, но однажды приходит момент, когда нужно использовать контроллер внутри другого контороллера, и они оба имеют свойства с одинаковыми именами, тогда чтобы обратиться к свойству родительского контроллера приходится писать конструкции вроде "$parent.$parent.property", вместо того, чтобы обратиться к нему через псевдоним, поэтому имеет смысл взять себе за правило использование синтаксиса «controller as».
Далее идёт верхнее меню с кнопками «Сохранить изменения», «Отменить изменения», «Обновить», первые две мы прячем при помощи ng-if, если изменений нет, а при помощи ng-click назначаем кнопкам вызов соответствующих функций.
Затем нарисуем список категорий с помощью ng-repeat, директива ng-class="{active:vm.filterCategory===cat}" установит элементу класс active, если выполняется условие vm.filterCategory===cat, то есть подкрасит выбранную категорию. Следом отобразим таблицу с нашим списком покупок, первой строкой будут идти поля ввода названия и категории с кнопкой Добавить, а дальше будет идти непосредственно список ng-repeat=«item in vm.listItems | filter: vm.isItemExists | orderBy:'isBought'», здесь мы использовали фильтр filter, которому указали функцию vm.isItemExists, чтобы отобразить только существующие элементы, фильтр orderBy отсортирует список по значениям свойства isBought, чтобы все купленные перемещались вниз.
Заключение
Пожалуй, это и всё что хотелось рассказать на первый раз (даже немного больше). Статью начал писать ещё летом, но из-за отсутствия времени закончил только сейчас. И за эти пол года мы так и не перестали пользоваться breeze.js. Особенно быстро и удобно реализовывать на нём приложения, где нужен грид с сортировками, поиском и разбиением на страницы. Так же в два счёта делается в них и редактирование, особенно если валидация не выходит за рамки атрибутов валидации на моделях EntityFramework.
Из подводных камней пока заметили только то, что breeze не поддерживает связь моделей many-to-many без промежуточного класса(когда EntityFramework «додумывает» промежуточную таблицу за нас), с промежуточным же классом, естественно, всё хорошо.
Для тех, у кого нет времени создавать новый проект, но есть желание поэкспериментировать с бризом — ссылка на готовый solution.
P.S. Пишу впервые, поэтому прошу читателя уделить минутку и высказать в комментариях свои замечания по содержанию/оформлению/стилю изложения и, если есть, пожелания для будущих статей.
Автор: svekl