RPM-пакет

Пакет RPM - это просто файл, содержащий другие файлы и информацию о них, необходимую для развёртывания файлов в операционной системе. Для сборки RPM-пакета мною использовалась операционная система CentOS 7.

На систему требуется установить следующие пакеты:

1
yum install rpm-build rpm-devel rpmlint rpmdevtools

Основные команды для сборщика RPM-пакетам содержатся в SPEC-файле RPM-пакета.

Для начала работы из домашней директории текущего пользователя выполните команду в терминале:

1
rpmdev-setuptree

В результате выполнения команды в домашней директории текущего пользователя появится папка rpmbuild следующей структуры:

  • BUILD
  • RPMS
  • SOURCES
  • SPECS
  • SRPMS

Сборка RPM-пакета

Для сборки потребуется:

  1. SPEC-файл (в нашем случае LinuxInstaller-2.15.2.4.spec), который нужно расположить в папке SPECS
  2. Архив tar.gz (в нашем случае LinuxInstaller-2.15.2.4.tar.gz) с бинарниками, и в нашем случае bash-скриптом install, который нужно расположить в папке SOURCES

Важно, чтобы имя архива (в нашем случае LinuxInstaller-2.15.2.4.tar.gz) и указанные в SPEC-файле параметры Name, Version и Release соответствовали друг другу!

Перед сборкой RPM-пакета рекомендуется применить линтер к SPEC-файлу командой в терминале:

1
rpmlint rpmbuild/SPECS/LinuxInstaller-2.15.2.4.spec

и поправить ошибки и критические замечания обнаруженные линтером в указанном файле.

Для сборки RPM-пакета из домашней директории текущего пользователя выполните следующую команду в терминале:

1
rpmbuild -bb rpmbuild/SPECS/LinuxInstaller-2.15.2.4.spec

Ключ -bb означает, что RPM-пакет будет собираться из готовых бинарников. Также RPM-пакет можно собрать из исходников. SPEC-файл разворачивает бинарники из папки SOURCES в папку BUILD, копирует их в новую папку BUILDROOT и упаковывает их в RPM-пакет, который появится в папке RPMS (подпапке x86_64) в rpm-файле LinuxInstaller-2.15.2-4.x86_64.rpm. При установке rpm-пакет разворачивает содержимое во временную папку /tmp/LinuxInstaller-2.15.2.4/, запускает bash-скрипт install командой /bin/bash /tmp/%{name}-%{version}.%{release}/install и после установки удаляет временную папку. Основная логика развёртывания файлов вынесена в bash-скрипт install, несущественного для рассматриваемого здесь вопроса.

Развёртывание RPM-пакета

Развернуть RPM-пакет можно как из терминала под root-пользователем, так и из специальной программы в графической оболочке операционной системы.

Для развёртывания RPM-пакета из терминала выполните под root-пользователем следующую команду:

1
rpm -i LinuxPrintClientInstaller-2.15.2-4.x86_64.rpm

Для удаления RPM-пакета из терминала выполните под root-пользователем следующую команду:

1
rpm -ev LinuxPrintClientInstaller-2.15.2-4

Обратите внимание, что при удалении x86_64.rpm указывать в конце пакета не надо!

Обновление RPM-пакета

Обновление пакета требует собрать версию с большими значениями Version и Release. Обратите внимание, что имя и содержимое архива tar.gz также должены соответствовать этим Version и Release.

Если для обновления использовать GUI операционной системы, то RPM-пакет предыдущей версии будет удалён автоматически в случае успешного развертывания пакета обновления. При установке обновления из командной строки предыдущую версию придётся удалять из операционной системы вручную также из командной строки.

Хорошая Инструкция по RPM-пакетам.

Выполнение Left Join запросов в LINQ

Всем хорош подход FluentApi при выполнении запросов в LINQ, но одна вещь ему не по зубам. Это выполнение удобных Left Join запросов. Но SQL-style запросы в LINQ позволяют решить данную проблему.

Конструкция join в LINQ даёт INNER JOIN соединение таблиц (если проводить аналогию с SQL). А для LEFT JOIN рассмотрим код, показывающий как решить данную проблему в LINQ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var salesProducts = 
(
from product in context.Products
join person in context.SalesPeople on
(product.Region, product.Type)
equals
(person.Region, person.ProductType)
into productPeople
from productPerson in productPeople.DefaultIfEmpty()
select new
{
Person = productPerson?.Name ?? string.Empty,
Product = product.Name,
product.Region,
product.Type
}
).ToList();

DefaultIfEmpty() как раз и осуществляет LEFT JOIN в случае, если productPerson не имеет соответствующей сущности в таблице продаванов SalesPeople. Результат join запроса, соединяющего сущности таблицы Products с сущностями таблицы SalesPeople помещается во временную переменную productPersons с помощью синтаксической конструкции into productPersons и затем оператор DefaultIfEmpty() в конструкции from productPerson in productPersons.DefaultIfEmpty() помещает null в качестве productPerson, если для указаного продукта не имеется соответствующего продавца. Затем мы формируем с помощью проекции select экземпляр анонимного класса, содержащего допустимые значения в случае отсутствия продавца person у требуемого продукта product. Это достигается с помощью конструкции Person = productPerson?.Name ?? string.Empty, которая позволяет избавиться от последующей проверки значения на null.

Шаблон увеличивающегося интервала повторения запроса

Когда в приложении прогнозируется присутствие проблем при запросах по сети и не требуется сдаваться в получении ответа при появлении первой же проблемы, я использую шаблон увеличивающегося интервала повторения запроса. Суть шаблона такова: в случае отсутствия ответа (от МФУ например) в отведённый таймаут приложение ожидает определённый интервал времени и затем пытается отправить запрос снова. Если и во второй раз нет ответа, то ожидаем в два раза дольший интервал времени и т.д. (в три, четыре… Вид прогрессии можно выбрать самостоятельно). Получается, что запросы идут всё реже и реже не перегружая устройство (или сайт) в случае сетевых проблем на его стороне. В случае отсутствия ответов заданное число раз, можно полностью прекратить отправку запросов.

Ниже приводится код условного метода Method. База интервала повторения запроса составляет одну секунду и задана арифметическая прогрессия увеличения интервала. Можно задать геометрическую прогрессию, умножая текущий интервал повторения на 2 каждый раз или даже степенную, возводя базу в степень текущего числа повторений (var currentDelay = Math.Pow(DelayInMilliseconds, tryCounter);). Ожидание реализовано синхронно, а асинхронный вариант ожидания (требующий изменения сигнатуры метода на Task Method()) показан в комментарии.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static int Method() // public static Task<int> Method()
{
const int DelayInMilliseconds = 1000;
const int RetryCount = 3;

private bool success = false;
private int tryCounter = 0;
private int response;

try
{
do
{
try
{
response = // Http запрос к сайту, устройству или т.п.
success = true;
}
catch(HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.RequestTimeout)
{
tryCounter += 1;
var currentDelay = DelayInMilliseconds * tryCounter;
Thread.Sleep(currentDelay); // await Task.Delay(currentDelay);
}
} while(tryCounter < RetryCount);
}
finally
{
if (success)
return response;
else
throw new HttpRequestException("Request failed");
}
}

Обратите внимание, на двойное обёртывание блоками try-catch-finally самого сетевого запроса. Именно во внешнем блоке finally формируется возвращаемый методом ответ return response;. Иначе он выкидывает наружу исключение HttpRequestException.

Пагинация с помощью курсора в Entity Framework Core и ASP.NET Core

Перевод статьи Халида Абухакмеха (Khalid Abuhakmeh) Cursor Paging With Entity Framework Core and ASP.NET Core.

При создании API-интерфейсов, которые возвращают значительный объем данных, нам необходимо принимать проектные решения, основываясь на нижележащей базе данных. Например, разработчик может реализовать специальный endpoint в API, который позволит клиентам получать наборы значений из заданного диапазона. Как разработчики API, мы можем выбирать между постраничной пагинацией и пагинацией с помощью курсора.

В этом посте показано, как реализовать оба подхода и почему разбиение по страницам с помощью курсора - это более предпочтительный по производительности вариант.

Что такое пагинация?

Для разработчиков, только начинающих создавать управляемые данными API, понятие пагинации, как разбиения на страницы, становится важной для понимания концепцией. Набор значений может быть как ограниченным, так и безграничным.

Ограниченный набор значений имеет предел. Например, семья будет иметь верхнюю границу количества детей. В большинстве случаев в семье бывает 2-4 ребенка. Таким образом, мы, скорее всего, вернем всех детей в едином ответе при построении API на базе семьи.

Безграничный набор значений не имеет предела. Обычно это коллекции, связанные с вводом пользователя, данными временных рядов или другими механизмами. Например, все платформы социальных сетей работают с безграничным ресурсом. Например, в Твиттере твиты отправляют миллионы людей по всей планете одновременно. В этих случаях невозможно вернуть полный набор данных любому клиенту, но вместо этого API создает фрагмент на основе запрашиваемового критерия.

Что касается API, то пагинация - это попытка разработчика предоставить клиенту частичный набор результатов из огромного источника данных, с которым невозможно взаимодействовать как то иначе.

Выбор правильного подхода к пагинации

Существует два подхода к разбиению коллекций на страницы:

  1. Пагинация по номеру и размеру страницы
  2. Пагинация с использованием курсора и размера страницы

Термин курсор переопределён. Его не следует путать с понятием курсора из реляционных баз данных. Хотя идея имеет сходство с реализацией в базе данных, они не связаны между собой.

Курсор - это идентификатор, который извлекает следующий элемент в наших последовательных запросах пагинации. Таким образом, пользователи могут размышлять об этом отвечая на вопрос: «Что последует за этим идентификатором курсора?»

Давайте рассмотрим на два подхода в формате запроса:

1
2
3
4
5
6
7
### Пагинация по номеру и размеру страницы
GET https://localhost:5001/pictures/paging?page=100000&size=10
Accept: application/json

### Пагинация с использованием курсора и размера страницы
GET https://localhost:5001/pictures/cursor?after=999990&size=10
Accept: application/json

Два HTTP-запроса выглядят очень похоже с точки зрения пользователя, но работают принципиально по-разному.

Для запроса пагинации мы вычисляем диапазон в нашем хранилище данных и извлекаем эти элементы. Здесь мы разместим коллекцию изображений, чтобы получить результаты на определенной странице:

1
2
3
4
5
var query = db
.Pictures
.OrderBy(x => x.Id)
.Skip((page - 1) * size)
.Take(size);

Хотя данный подход работает, разработчики должны помнить о предостережениях, самым большим из которых является смещение диапазона по мере поступления новых данных. Другая проблема в данном подходе зависит от нижележащей базы данных. При разбиении на страницы с помощью диапазонов база данных должна сканировать всё множество результатов, чтобы вернуть клиенту страницу. Таким образом, когда мы перемещаемся всё ближе к концу коллекции, производительность начинает ухудшаться.

А как работает пагинация с помощью курсора? В отличие от создания диапазона для получения множества результатов, мы используем предыдущий результат для получения следующего. Курсором может быть любое значение, гарантирующее следующий диапазон. Поля, такие как авто-инкрементируемые идентификаторы или отметки времени, идеально подходят для пагинации с помощью курсора. Пагинация с помощью курсора не страдает от проблемы, связанной с тем, что входящие данные искажают наши результаты разбиения по страницам, т.к. наш порядок является детерминированным.

Давайте рассмотрим, как реализовать пагинацию с помощью курсора в виде запроса LINQ:

1
2
3
4
5
6
var query = db
.Pictures
.OrderBy(x => x.Id)
// будет задействован индекс
.Where(x => x.Id > after)
.Take(size);

Пагинация достаточно дорогостоящая операция, какой бы подход мы не использовали. Вы когда-нибудь задумывались, почему Google перестает показывать страницы результатов поиска после максимум 25 страниц? Что ж, существует способ уменьшения этой стоимости и повышения производительности.

Сравнивая производительность

Один из наиболее заметных недостатков пагинации по номеру и размеру страницы по сравнению с пагинацией с помощью курсора - это влияние на производительность. Давайте сравним две реализации веб-приложения в ASP.NET Core.

Примечание: В этом примере используются минималистические ASP.NET Core API-интерфейсы.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
using System.Linq;
using CursorPaging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<Database>();

var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetService<Database>();
var logger = scope.ServiceProvider.GetService<ILogger<WebApplication>>();
var result = await Database.SeedPictures(db);
logger.LogInformation($"Seed operation returned with code {result}");
}

if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app
.UseDefaultFiles()
.UseStaticFiles();

app.MapGet("/pictures/paging", async http =>
{
var page = http.Request.Query.TryGetValue("page", out var pages)
? int.Parse(pages.FirstOrDefault() ?? string.Empty)
: 1;

var size = http.Request.Query.TryGetValue("size", out var sizes)
? int.Parse(sizes.FirstOrDefault() ?? string.Empty)
: 10;

await using var db = http.RequestServices.GetRequiredService<Database>();
var total = await db.Pictures.CountAsync();
var query = db
.Pictures
.OrderBy(x => x.Id)
.Skip((page - 1) * size)
.Take(size);

var logger = http.RequestServices.GetRequiredService<ILogger<Database>>();
logger.LogInformation($"Using Paging:\n{query.ToQueryString()}");

var results = await query.ToListAsync();
await http.Response.WriteAsJsonAsync(new PagingResult
{
Page = page,
Size = size,
Pictures = results.ToList(),
TotalCount = total,
Sql = query.ToQueryString()
});
});

app.MapGet("/pictures/cursor", async http =>
{
var after = http.Request.Query.TryGetValue("after", out var afters)
? int.Parse(afters.FirstOrDefault() ?? string.Empty)
: 0;

var size = http.Request.Query.TryGetValue("size", out var sizes)
? int.Parse(sizes.FirstOrDefault() ?? string.Empty)
: 10;

await using var db = http.RequestServices.GetRequiredService<Database>();
var logger = http.RequestServices.GetRequiredService<ILogger<Database>>();

var total = await db.Pictures.CountAsync();
var query = db
.Pictures
.OrderBy(x => x.Id)
// will use the index
.Where(x => x.Id > after)
.Take(size);

logger.LogInformation($"Using Cursor:\n{query.ToQueryString()}");

var results = await query.ToListAsync();

await http.Response.WriteAsJsonAsync(new CursorResult
{
TotalCount = total,
Pictures = results,
Cursor = new CursorResult.CursorItems
{
After = results.Select(x => (int?) x.Id).LastOrDefault(),
Before = results.Select(x => (int?) x.Id).FirstOrDefault()
},
Sql = query.ToQueryString()
});
});

app.Run();

Разместим в нашей базе данных SQLite миллион строк и попытаемся постранично продвинуться по коллекции как можно глубже.

1
2
3
4
5
6
7
### Пагинация по номеру и размеру страницы
GET https://localhost:5001/pictures/paging?page=100000&size=10
Accept: application/json

### Пагинация с использованием курсора и размера страницы
GET https://localhost:5001/pictures/cursor?after=999990&size=10
Accept: application/json

Каковы результаты этого эксперимента?

Для нашего запроса страница/размер мы видим общее время ответа 225 мс.

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Jun 2021 12:46:27 GMT
Server: Kestrel
Transfer-Encoding: chunked

> 2021-06-23T084627.200.json

Response code: 200 (OK); Time: 225ms; Content length: 1328 bytes

Посмотрим, как курсор поведёт себя в подобной ситуации?

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Jun 2021 12:46:27 GMT
Server: Kestrel
Transfer-Encoding: chunked

> 2021-06-23T084627-1.200.json

Response code: 200 (OK); Time: 52ms; Content length: 1370 bytes

Ого! 52 мс против 225 мс! Это существенная разница при том же результате. Откуда же возникло это улучшение производительности? Давайте рассмотрим SQL запросы, сгенерированные EF Core, и сравним их.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Использование страницы/размера
.param set @__p_1 10
.param set @__p_0 999990

SELECT "p"."Id", "p"."Created", "p"."Url"
FROM "Pictures" AS "p"
ORDER BY "p"."Id"
LIMIT @__p_1 OFFSET @__p_0

-- Использование курсора
.param set @__after_0 999990
.param set @__p_1 10

SELECT "p"."Id", "p"."Created", "p"."Url"
FROM "Pictures" AS "p"
WHERE "p"."Id" > @__after_0
ORDER BY "p"."Id"
LIMIT @__p_1

Видно, что первый запрос (страница/размер) использует ключевое слово OFFSET. Это ключевое слово говорит нашему провайдеру базы данных SQLite сканировать до смещения перед вызовом ключевого слова LIMIT. Как можно себе представить, сканирование таблицы из миллиона строк достаточно дорогостоящая операция.

Для запроса с курсором видно, что используется столбец Id с условием WHERE. Использование индекса позволяет SQLite эффективно перемещаться по таблице, обеспечивая и более эффективное выполнение запроса.

Заключение

Как мы видели в примерах выше, пагинация с помощью курсора становится более производительной по мере увеличения объ
ёма данных. Тем не менее, пагинация с использованием страницы и размера может имееть свое место в приложениях. Этот подход намного проще реализовать и для небольших наборов данных, которые меняются нечасто, это может быть отличным выбором. Как разработчики, мы обязаны выбрать лучший вариант для нашего сценария. Тем не менее, учитывая почти четырехкратное улучшение производительности запросов, трудно поспорить с пагинацией с помощью курсора.

Добавляем WebAPI в Windows-службу

В предыдущей статье мы рассмотрели создание Windows-службы с помощью фреймворка TopShelf. Но затем мне поднадобилось добавить возможности WebAPI в нашу службу. Рассмотрим как это можно сделать.

Тут нам поможет OWIN. С помощью NuGet Package Manager устанавливаем пакет в наш проект службы:

1
nuget Install-Package Microsoft.AspNet.WebApi.OwinSelfHost 

Затем конфигурируем наш self-host WebAPI путём создания Startup.cs файла следующего вида:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System.Net.Http.Formatting;
using Owin;
using System.Web.Http;
using System.Web.Http.Cors;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;

public class Startup
{
// Don't delete! This code configures Web API.
// The Startup class is specified as a type parameter in the WebApp.Start method
public void Configuration(IAppBuilder appBuilder)
{
var config = new HttpConfiguration(); // Configure Web API for self-host
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.Formatters.Clear();
config.Formatters.Add(new JsonMediaTypeFormatter());
config.Formatters.JsonFormatter.SerializerSettings =
new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
config.Formatters.JsonFormatter.SerializerSettings.Converters.Add(new StringEnumConverter());
var cors = new EnableCorsAttribute("*", "*", "*");
config.EnableCors(cors);
appBuilder.UseWebApi(config);
}
}

Выше происходит настройка роутинга, JSON формата запроса/ответа и cors.

Далее в проект требуется добавить WebAPI контроллер. Для примера возьмём контроллер из оригинальной статьи на сайте Microsoft, т.к. используемые мною контроллеры только загромоздят суть повествования излишним содержимым:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections.Generic;
using System.Web.Http;

public class ValuesController : ApiController
{
// GET api/values
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}

// GET api/values/5
public string Get(int id)
{
return "value";
}

// POST api/values
public void Post([FromBody]string value)
{
}

// PUT api/values/5
public void Put(int id, [FromBody]string value)
{
}

// DELETE api/values/5
public void Delete(int id)
{
}
}

Теперь отредактируем Program.cs файл Windows-службы для одновременного запуска с WebAPI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System;
using Topshelf;
using Microsoft.Owin.Hosting;
using System.Configuration;

public class Program
{
private const string ServiceName = "Authentication and WebAPI service";

static void Main()
{
var port = ConfigurationManager.AppSettings["Port"];

var options = new StartOptions();
options.Urls.Add($"http://*:{port}/");

using (WebApp.Start<Startup>(options)) // Start OWIN host
{
HostFactory.Run(config =>
{
config.StartAutomatically(); // startup type
config.EnableShutdown();
config.Service<AuthApiService>(service =>
{
service.ConstructUsing(svc => new AuthApiService());
service.WhenStarted(svc => svc.Start());
service.WhenStopped(svc => svc.Stop());
});
config.RunAsLocalSystem();
config.SetServiceName(ServiceName);
config.SetDisplayName(ServiceName);
config.SetDescription(ServiceName);
config.EnableServiceRecovery(service =>
{
service.OnCrashOnly();
service.RestartService(delayInMinutes: 0); // first failure
service.RestartService(delayInMinutes: 0); // second failure
service.RestartService(delayInMinutes: 1); // subsequent failures
service.SetResetPeriod(days: 1); // reset period for restart options
});
});
}
}
}

В результате WebAPI начинает прослушивать все входящие запросы на порт, считанный из файла конфигурации и работает Windows-служба, разработанная с помощью фреймворка Topshelf. Вот так вота! Или как говорят французы: Вуаля!

Разрабатываем Windows-службы с помощью TopShelf

Все, кто сталкивались с разработкой служб для операционной системы Windows, отмечали трудность отладки подобного ПО. Трудности старта разработки кода решаются шаблонами Visual Studio для Windows-служб, но вот сама отладка – очень неудобная. Приходится подключаться к запущеному процессу через Debug –> Attach to Process… , выбирать процесс, ставить точку останова и т.д. Но всё изменилось когда появился TopShelf. Этот фреймворк (а именно так они себя величают) позволяет разрабатывать и отлаживать Windows-службы как обычные консольные приложения!

Рассмотрим как разрабатывать Windows-службы с помощью TopShelf.

Установить TopShelf в ваш проект можно через менеджер пакетов NuGet:

1
nuget Install-Package Topshelf

Посмотреть исходный код фреймворка TopShelf можно на GitHub

Основной класс (в нашем случае AuthApiService) должен содержать методы public void Start() и public void Stop(), которые используются TopShelf при старте и остановке службы. В моём случае файл Program.cs же будет иметь следующий вид:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using Topshelf;

class Program
{
private const string ServiceName = "Authentication and WebAPI service";

static void Main(string[] args)
{
HostFactory.Run(config =>
{
config.StartAutomatically(); // startup type
config.EnableShutdown();
config.Service<AuthApiService>(service =>
{
service.ConstructUsing(svc => new AuthApiService());
service.WhenStarted(svc => svc.Start());
service.WhenStopped(svc => svc.Stop());
});
config.RunAsLocalSystem();
config.SetServiceName(ServiceName);
config.SetDisplayName(ServiceName);
config.SetDescription(ServiceName);
config.EnableServiceRecovery(service =>
{
service.OnCrashOnly();
service.RestartService(delayInMinutes: 0); // first failure
service.RestartService(delayInMinutes: 0); // second failure
service.RestartService(delayInMinutes: 1); // subsequent failures
service.SetResetPeriod(days: 1); // reset period for restart options
});
});
}
}

Всю магию делает метод HostFactory.Run. В нём указывается класс основного тела службы с методами Start и Stop, задаётся имя службы и описание в менеджере служб Windows (Services). TopShelf позволяет провести почти такую же настройку режима работы службы, которую выполняет менеджер служб Windows, в частности задать из под кого будет запускаться служба RunAsLocalSystem или задать параметры перезапуска в случае падения службы с помощью EnableServiceRecovery.

Установить службу в операционную систему Windows можно из командной строки, запущенной от Администратора, прямо в папке где находится служба командой:

1
AuthApiService.exe install

Удалить службу можно командой:

1
AuthApiService.exe uninstall

Всю магию установки и настройки службы берёт на себя Topshelf!

Службу можно установить и самостоятельно, но тогда настройки из раздела EnableServiceRecovery применены не будут.

1
sc create "Authentication and WebAPI service" binPath="<путь_к_службе>\AuthService.exe"

Удалить службу самостоятельно можно командой:

1
sc delete "Authentication and WebAPI service"

Enum.IsDefined и его самодельная альтернатива

Работа с перечислениями Enum в C# имеет очень большие издержки. Это странно, т.к. перечисления целочисленны по своей природе, но из-за требований безопасности типов даже простые операции обходятся дорого. Подробно этот аспект освещён в книге Бена Уотсона “Высокопроизводительный код на платформе .NET” (2-е издание) в главе 6 “Использование среды .NET Framework” раздел “Удивительно высокие издержки использования перечислений”. Там рассмотрено внутреннее устройство метода Enum.HasFlag. Анализ же метода Enum.IsDefined оставлен в виде домашнего задания. Займёмся анализом, учтя основную рекомендацию Бена: “если окажется, что проверять наличие флага приходится часто, реализуйте проверку самостоятельно”. Посмотрим внутренее устройство Enum.IsDefined, реализуем самодельную альтернативу MyEnum.MyIsDefined и сравним их в деле.

ILSpy показывает следующее устройство метода Enum.IsDefined:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// System.Enum
public static bool IsDefined(Type enumType, object value)
{
if ((object)enumType == null)
{
throw new ArgumentNullException("enumType");
}
return enumType.IsEnumDefined(value);
}

// System.Type
public virtual bool IsEnumDefined(object value)
{
if (value == null)
{
throw new ArgumentNullException("value");
}
if (!IsEnum)
{
throw new ArgumentException(SR.Arg_MustBeEnum, "value");
}
Type type = value.GetType();
if (type.IsEnum)
{
if (!type.IsEquivalentTo(this))
{
throw new ArgumentException(SR.Format(SR.Arg_EnumAndObjectMustBeSameType, type, this));
}
type = type.GetEnumUnderlyingType();
}
if (type == typeof(string))
{
string[] enumNames = GetEnumNames();
object[] array = enumNames;
if (Array.IndexOf(array, value) >= 0)
{
return true;
}
return false;
}
if (IsIntegerType(type))
{
Type enumUnderlyingType = GetEnumUnderlyingType();
if (enumUnderlyingType.GetTypeCodeImpl() != type.GetTypeCodeImpl())
{
throw new ArgumentException(SR.Format(SR.Arg_EnumUnderlyingTypeAndObjectMustBeSameType, type, enumUnderlyingType));
}
Array enumRawConstantValues = GetEnumRawConstantValues();
return BinarySearch(enumRawConstantValues, value) >= 0;
}
throw new InvalidOperationException(SR.InvalidOperation_UnknownEnumType);
}

Выглядит этот код сложно, причем я не привожу ещё реализации многочисленных внутренних функций типа GetEnumUnderlyingType, GetEnumRawConstantValues и др., чтобы не загромождать повествование. Видно, что в Enum.IsDefined можно передать и численное значение int, и имя в виде строки, и аргумент в виде значения преречисления PetType.Dog - во всех случаях метод проверит есть ли такое значение в перечислении (вернёт True, если есть, либо False, если нет).

Реализуем метод Enum.IsDefined самостоятельно. Для этого преобразуем перечисление в словарь equivalentEnumDictionary внутри класса MyEnum. Тогда эквивалентом метода Enum.IsDefined будет метод MyEnum.MyIsDefined с таким же набором входных аргументов:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.Linq;
using System.Collections.Generic;

namespace EnumSharp
{
class MyEnum
{
private static Dictionary<int, string> equivalentEnumDictionary;

public static bool MyIsDefined(Type enumType, object value)
{
if (equivalentEnumDictionary == null)
{
equivalentEnumDictionary = Enum.GetValues(enumType)
.Cast<object>()
.ToDictionary(k => (int)k, v => ((Enum)v).ToString());
}

if (value is int && equivalentEnumDictionary.ContainsKey((int)value))
return true;

if (value is string && equivalentEnumDictionary.ContainsValue((string)value))
return true;

if (value.GetType() == enumType && equivalentEnumDictionary.ContainsValue(value.ToString()))
return true;

return false;
}
}
}

Преобразование перечисления в словарь произойдёт единожды при первом обращении к методу MyEnum.MyIsDefined.

Примечание: LINQ преобразование в словарь я также пробовал в виде .ToDictionary(k => (int)k, v => v.ToString()) и .ToDictionary(k => (int)k, v => Enum.GetName(enumType, (int)v)). Вариант, представленный в теле метода MyIsDefined, оказался самым шустрым.

Теперь сравним насколько самостоятельная проверка быстрее встроенной. Для этого воспользуемся библиотекой BenchmarkDotNet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace EnumSharp
{
[Flags]
public enum PetType
{
None = 0, Dog = 1, Cat = 2, Rodent = 4, Bird = 8, Reptile = 16, Other = 32
};

public class EnumsBenchmark
{
[Benchmark]
public void MethodEnumIsDefined()
{
bool result;

result = Enum.IsDefined(typeof(PetType), 1); // true
result = Enum.IsDefined(typeof(PetType), 64); // false
result = Enum.IsDefined(typeof(PetType), "Rodent"); // true
result = Enum.IsDefined(typeof(PetType), PetType.Dog); // true
result = Enum.IsDefined(typeof(PetType), PetType.Dog | PetType.Cat); // false
result = Enum.IsDefined(typeof(PetType), "None"); // true
result = Enum.IsDefined(typeof(PetType), "NONE"); // false
}

[Benchmark]
public void MethodMyIsDefined()
{
bool result;

result = MyEnum.MyIsDefined(typeof(PetType), 1); // true
result = MyEnum.MyIsDefined(typeof(PetType), 64); // false
result = MyEnum.MyIsDefined(typeof(PetType), "Rodent"); // true
result = MyEnum.MyIsDefined(typeof(PetType), PetType.Dog); // true
result = MyEnum.MyIsDefined(typeof(PetType), PetType.Dog | PetType.Cat); // false
result = MyEnum.MyIsDefined(typeof(PetType), "None"); // true
result = MyEnum.MyIsDefined(typeof(PetType), "NONE"); // false
}

class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<EnumsBenchmark>();
}
}
}
}

Вот результаты:

1
2
3
4
5
|              Method |     Mean |    Error |   StdDev |
|-------------------- |---------:|---------:|---------:|
| MethodEnumIsDefined | 845.3 ns | 15.14 ns | 13.42 ns |
| MethodMyIsDefined | 350.2 ns | 6.95 ns | 6.83 ns |

Получается, что самостоятельная проверка MyEnum.MyIsDefined более чем в два раза быстрее даже при такой прямолинейной реализации. Стоит согласиться с Беном Уотсоном, что если проверять Enum.IsDefined приходится часто, то реализация самостоятельной проверки даст существенную выгоду по быстродействию. При обычном сценарии достаточно пользоваться встроенной Enum.IsDefined.

Добавляем из терминала VSCode NuGet пакеты в C# проект

Существует возможность создания разнообразных видов проектов .NET 5 прямо из терминала VSCode. Терминал можно вызвать горячими клавишами Ctrl+` или с помощью меню View -> Terminal. Взгляните:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
D:\Github\EnumSharp>dotnet new 
Template Name Short Name Language Tags
-------------------------------------------- ------------------- ---------- ----------------------
Console Application console [C#],F#,VB Common/Console
Class library classlib [C#],F#,VB Common/Library
WPF Application wpf [C#],VB Common/WPF
WPF Class library wpflib [C#],VB Common/WPF
WPF Custom Control Library wpfcustomcontrollib [C#],VB Common/WPF
WPF User Control Library wpfusercontrollib [C#],VB Common/WPF
Windows Forms App winforms [C#],VB Common/WinForms
Windows Forms Control Library winformscontrollib [C#],VB Common/WinForms
Windows Forms Class Library winformslib [C#],VB Common/WinForms
Worker Service worker [C#],F# Common/Worker/Web
MSTest Test Project mstest [C#],F#,VB Test/MSTest
NUnit 3 Test Project nunit [C#],F#,VB Test/NUnit
NUnit 3 Test Item nunit-test [C#],F#,VB Test/NUnit
xUnit Test Project xunit [C#],F#,VB Test/xUnit
Razor Component razorcomponent [C#] Web/ASP.NET
Razor Page page [C#] Web/ASP.NET
MVC ViewImports viewimports [C#] Web/ASP.NET
MVC ViewStart viewstart [C#] Web/ASP.NET
Blazor Server App blazorserver [C#] Web/Blazor
Blazor WebAssembly App blazorwasm [C#] Web/Blazor/WebAssembly
ASP.NET Core Empty web [C#],F# Web/Empty
ASP.NET Core Web App (Model-View-Controller) mvc [C#],F# Web/MVC
ASP.NET Core Web App webapp [C#] Web/MVC/Razor Pages
ASP.NET Core with Angular angular [C#] Web/MVC/SPA
ASP.NET Core with React.js react [C#] Web/MVC/SPA
ASP.NET Core with React.js and Redux reactredux [C#] Web/MVC/SPA
Razor Class Library razorclasslib [C#] Web/Razor/Library
ASP.NET Core Web API webapi [C#],F# Web/WebAPI
ASP.NET Core gRPC Service grpc [C#] Web/gRPC
dotnet gitignore file gitignore Config
global.json file globaljson Config
NuGet Config nugetconfig Config
Dotnet local tool manifest file tool-manifest Config
Web Config webconfig Config
Solution File sln Solution
Protocol Buffer File proto Web/gRPC

Examples:
dotnet new mvc --auth Individual
dotnet new web
dotnet new --help
dotnet new nunit --help

Создадим консольное приложение командой: dotnet new console

Но как теперь добавить в наш проект NuGet пакеты также из терминала?! Install-Package не работает :(

Тут нам поможет команда: dotnet add package <ИМЯ_ПАКЕТА>

Например, добавим в наш проект пакеты BenchmarkDotNet и BenchmarkDotNet.Annotations.
Для этого в терминале выполним следующие команды:
dotnet add package BenchmarkDotNet
dotnet add package BenchmarkDotNet.Annotations

Вуаля! Требующиеся NuGet пакеты добавлены в наш проект:

1
2
3
4
5
6
7
8
9
10
11
12
13
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.1" />
</ItemGroup>

</Project>

Как эффективно выделять ячейки в файлах Excel с помощью EPPlus

При построении отчетов в различных системах используется Excel. Для его формирования из C# кода часто используется EPPlus. EPPlus интуитивно понятен:

1
2
3
4
5
6
7
8
9
var file = new FileInfo(@"c:\temp\myWorkbook.xlsx");
using(var package = new ExcelPackage(file))
{
var sheet = package.Workbook.Worksheets.Add("My Sheet");
sheet.Cells["A1"].Value = "Hello World!";

// Save to file
package.Save();
}

EPPlus имеет много встроенных механизмов для получения одного и того же результата разными путями, но не все пути одинаково эффективны для больших и очень больших размеров рабочих листов. В частности, я столкнулся с проблемой обведения контуров ячеек и выделения жирным шрифтом документов Excel, содержащих свыше 10000 столбцов.

Используемый мной подход “в лоб” заключался в обводке контуров ячеек непосредственно в месте их заполнения данными с помощью функции DrawBorder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// использование функции DrawBorder. ПОЛНЫЙ ПРОВАЛ в эффективности!!!
DrawBorder(worksheet.Cells[4, 6, 4, shift + dateTimeRanges.Count - 1], ExcelBorderStyle.Medium, BorderSide.TopBottom);
DrawBorder(worksheet.Cells[4, 6, 4, shift + dateTimeRanges.Count - 1], ExcelBorderStyle.Thin, BorderSide.LeftRight);
DrawBorder(worksheet.Cells[4, shift + dateTimeRanges.Count - 1], ExcelBorderStyle.Medium, BorderSide.Right);

// определение функции DrawBorder: НАИВНОЕ и НЕЭФФЕКТИВНОЕ
private static void DrawBorder(ExcelRange worksheetCell, ExcelBorderStyle borderStyle, BorderSide side)
{
switch (side)
{
case BorderSide.EveryOne:
worksheetCell.Style.Border.Top.Style = borderStyle;
worksheetCell.Style.Border.Left.Style = borderStyle;
worksheetCell.Style.Border.Right.Style = borderStyle;
worksheetCell.Style.Border.Bottom.Style = borderStyle;
break;
case BorderSide.Top:
worksheetCell.Style.Border.Top.Style = borderStyle;
break;
case BorderSide.Left:
worksheetCell.Style.Border.Left.Style = borderStyle;
break;
case BorderSide.Bottom:
worksheetCell.Style.Border.Bottom.Style = borderStyle;
break;
case BorderSide.Right:
worksheetCell.Style.Border.Right.Style = borderStyle;
break;
case BorderSide.TopBottom:
worksheetCell.Style.Border.Top.Style = borderStyle;
worksheetCell.Style.Border.Bottom.Style = borderStyle;
break;
case BorderSide.LeftRight:
worksheetCell.Style.Border.Left.Style = borderStyle;
worksheetCell.Style.Border.Right.Style = borderStyle;
break;
default:
throw new ArgumentOutOfRangeException(nameof(side), side, null);
}
}

Данный подход показал свою полную неэффективность. Отчеты строились по часу и более…

Правильный подход заключается в определении именованных стилей NamedStyle и применении этих стилей для конкретных ячеек:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// определение именованных стилей NamedStyle
private static void AddNamedStyles(ExcelPackage excel)
{
var thinBorders = excel.Workbook.Styles.CreateNamedStyle("ThinBordersStyle");
thinBorders.Style.Border.Top.Style = ExcelBorderStyle.Thin;
thinBorders.Style.Border.Left.Style = ExcelBorderStyle.Thin;
thinBorders.Style.Border.Right.Style = ExcelBorderStyle.Thin;
thinBorders.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
thinBorders.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
thinBorders.Style.VerticalAlignment = ExcelVerticalAlignment.Center;

...

var totalThinBoldBorders = excel.Workbook.Styles.CreateNamedStyle("TotalThinBordersBoldStyle");
totalThinBoldBorders.Style.Border.Top.Style = ExcelBorderStyle.Thin;
totalThinBoldBorders.Style.Border.Left.Style = ExcelBorderStyle.Thin;
totalThinBoldBorders.Style.Border.Right.Style = ExcelBorderStyle.Thin;
totalThinBoldBorders.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
totalThinBoldBorders.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
totalThinBoldBorders.Style.VerticalAlignment = ExcelVerticalAlignment.Center;
totalThinBoldBorders.Style.Font.Bold = true;
totalThinBoldBorders.Style.Font.Size = 12;
}

// использование именованных стилей NamedStyle
worksheet.Cells[startRow, 1, currentRow - 1, shift + dateTimeRanges.Count - 1].StyleName = "ThinBordersStyle";
worksheet.Cells[currentRow, 1, currentRow + 1, shift + dateTimeRanges.Count - 1].StyleName = "TotalThinBordersBoldStyle";

Использование именованных стилей NamedStyle привело к намного более эффективному построению отчетов Excel. Отчеты стали строиться за 7-10 минут, что почти в 10 раз быстрее первоначального подхода с DrawBorder. Как говорилось в рекламе из 90-х, “не все йогурты одинаково полезны…”

Почему в календаре бывает 53 недели

Согласно ГОСТ ИСО 8601-2001 “ПРЕДСТАВЛЕНИЕ ДАТ И ВРЕМЕНИ” расчет номера недели в Российской Федерации осуществляется так:

Первой неделей года является та неделя, которая содержит число 4 января. То есть если первое января выпадает на пятницу, субботу, или воскресенье, то все еще продолжается последняя неделя предыдущего года.

Это может приводить к интересным спецэффектам - в году может содержаться 52 или 53 недели, 29, 30, 31 декабря могут относиться к первой неделе следующего года, и наоборот, 1, 2, и 3 января могут относиться к 52 или 53 неделе прошлого года. В частности 53 недели содержали 2015 и 2020 годы.

Привычный подход к вычислению количества недель в году с помощью Calendar.GetWeekOfYear для нашего ГОСТа даёт сбой и работает некорректно. 53-и недели при таком подходе отсустсвуют.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Такой подход вычисляет 53-ю неделю НЕКОРРЕКТНО
var ruCi = new CultureInfo("ru-RU");
var ruCal = ruCi.Calendar;
var ruCwr = ruCi.DateTimeFormat.CalendarWeekRule;
var ruFirstDow = ruCi.DateTimeFormat.FirstDayOfWeek;

for (var i = 1; i < dateTimeRanges.Count; i++)
{
var year = dateTimeRanges[i].Add(reportDto.ClientOffset).Year.ToString("D4");
var nn = ruCal.GetWeekOfYear(
dateTimeRanges[i].Add(reportDto.ClientOffset),
ruCwr,
ruFirstDow
).ToString("D2"); // вычисляет 53-ю неделю НЕКОРРЕКТНО !!!
worksheet.Cells[4, shift + i].Value = $"{nn}.{year}"; // НЕКОРРЕКТНОЕ значение для 53-ей недели!!!
}

Метод Calendar.GetWeekOfYear вычиляет недели в году не по ГОСТ ИСО 8601-2001 и для календаря РФ не подходит!

Для расчёта недель согласно ГОСТ ИСО 8601-2001 следует использовать статический класс ISOWeek и метод GetWeekOfYear:

1
2
3
4
5
6
7
8
for (var i = 1; i < dateTimeRanges.Count; i++)
{
var year = ISOWeek.GetYear(dateTimeRanges[i].Add(reportDto.ClientOffset)).ToString("D4");
var nn = ISOWeek.GetWeekOfYear(
dateTimeRanges[i].Add(reportDto.ClientOffset)
).ToString("D2"); // ПРАВИЛЬНОЕ значение для 53-ей недели
worksheet.Cells[4, shift + i].Value = $"{nn}.{year}";
}

Подробнее с ISOWeek.GetWeekOfYear можно познакомиться по ссылке