Dziś coś z gatunku „Adam Słodowy”. Zrobimy sterowanie lampkami choinkowymi tak, żeby zapalały się o zmierzchu i gasły w nocy, kiedy pójdziemy spać i nie będziemy ich podziwiać. W dzień nie będą świeciły, co pomoże ocalić planetę i podniesie respect factor.
Do wykonania zadania Adam Słodowy wziąłby deseczkę, kilka gwoździ i młotek. Nasze zadanie także będzie wymagać paru fizycznych detali i zdolności majsterkowania. Tym razem sam kod nie wystarczy. Będziemy potrzebowali: Raspberry Pi, kilku przewodów. Konieczny też będzie przekaźnik, konwerter napięcia i lampki choinkowe. Przyda się też choinka, ale to dopiero na końcu. Zamiast młotka weź lutownicę. Tymczasem zakładam, że jeśli masz malinę, to pozostałe gadżety też znajdziesz w szufladzie.
Hardware
Potrzebny sprzęt:
- Rpi4/5
- Konwerter napięcia TXS0108E
- Moduł przekaźnikowy
- Zegar czasu rzeczywistego RTC
- Lampki choinkowe i choinka

Elementy łączymy jak na schemacie. Zegar czasu rzeczywistego pokazany na schemacie to element opcjonalny i nie będę tu opisywał, jak go skonfigurować z maliną. Opisów jest dużo a procedura prosta. Raspbian wspiera RTC „z pudełka”. Zegar odpowiada za to, żeby malina nie zgubiła czasu, jeśli w mejscu instalacji nie ma dostępu do sieci.
Konwerter napięcia zapewni współpracę maliny (wejścia/wyjścia pracują z napięciem 3,3V, a element wykonawczy – przekaźnik jest zasilany napięciem 5V. Teoretycznie stan wysoki wyjścia maliny 3,3V powinien być powyżej progu stanu wysokiego takiego modułu (pewnie ok. 2,4V) ale jeśli chcemy mieć pewność, że to zadziała, to konwerter nie zaszkodzi. Jest jeszcze jeden powód stosowanie konwertera. W przypadku przypadkowego podania na wejście napięcia 5V nawet na chwilę, mamy prawie na pewno usmażony obwód wejściowy takiego wejścia. Nie wnikając teraz w elektronikę, powiem tylko, że mocno zalecam stosowanie konwertera.
Podłączenie obciążenia do modułu przekaźnikowego nie nastręczy Ci trudności, ani nie wymaga uprawnień SEP, ale zalecam ostrożność i stosowanie zasad bezpieczeństwa, jak przy każdej pracy z napięciem sieciowym.
Software
Program w C# będzie sterowany zdarzeniami. Składa się z podstawowego modułu zrealizowanego jako BackgroundService, który nasłuchuje, czy wystąpiło zdarzenie które ma włączyć lub wyłączyć przekaźnik. Skoro coś nasłuchuje, to należy także utworzyć moduły generujące zdarzenia. Potrzebne będą dwa zdarzenia: nadchodzi zmierzch (włącz lampki) oraz idę spać o 23.00 (wyłącz). Oba moduły generujące zdarzenia także będą BackgroundService’ami. Konieczny też będzie moduł sterujący fizycznymi wyjściami maliny. Wszystkie elementy omówimy po kolei. Na koniec zostanie wygenerowany kod dla maliny z gotowej aplikacji utrorzymy serwis systemowy.
Na początek utwórz projekt konsolowy i dodaj pakiety nuget potrzebne do zbudowania hosta. Dodamy też logger. Po wykonaniu pierwszego polecenia zmień folder na /choinka i wykonaj kolejne polecenia.
dotnet new console -n choinka
dotnet add package System.Device.Gpio --version 4.0.1
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.Abstractions
dotnet add package Serilog
Następnie utwórz host, który za chwilę będziemy wypełniać treścią
using choinka.Triggers.SolarTime;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
try
{
var hostBuilder= Host.CreateDefaultBuilder();
hostBuilder.Build().Run();
}
catch (Exception ex)
{
Log.Fatal("Fatar error: {Message}", ex.Message);
}
finally
{
Log.Information("Shutdown complete");
Log.CloseAndFlush();
}Zegar astronomiczny
Lampki będziemy zapalać o zachodzie słońca. Nie ma sensu ustalać sztywnej godziny, bo w zimie moment zapadania ciemności zmienia się bardzo dynamicznie. Na przyklad. już w 10 dni po przesileniu, słońce zachodzi ok. 30 minut później. Słowem, nie ma co się męczyć i nieustannie przetawiać czas włączenia lampek, Niech się dzieje samo! Wykorzystamy bibliotekę SolarCalculator.
dotnet add package SolarCalculator
Niech godzina zachodu odpowiada lokalizacji geograficznej. Solar Calculator obliczy zachód słońca w oparciu o podany czas i lokalizację (koordynaty geograficzne). Dodajmy miejca. Dla mnie wystarczą Gdańsk i Warszawa. Ty dodaj swoje:
public class Places
{
public IEnumerable<Coordinates> Coordinates { get; init; } = [
new Coordinates()
{
Name = "Warsaw", Latitude = 52.2298, Longitude = 21.0117
},
new Coordinates()
{
Name = "Gdansk", Latitude = 54.35, Longitude = 18.6667
},
];
}
public class Coordinates
{
public string Name { get; init; } = null!;
public Angle Latitude { get; init; } = Angle.Empty;
public Angle Longitude { get; init; } = Angle.Empty;
}Potem dodamy Places do kontenera DI, bo za chwilę wstrzykniem je do serwisu. Ta akurat choinka będzie w Warszawie. Wobec tego pobieram koordynaty Warszawy w konstruktorze. Ty pobierz swoje koordynaty, które wcześniej zdefiniowałeś. Kalkulator może policzyć czasy wystąpienie bardzo różnych zjawisk astronomicznych. Ja potrzebuję czas zachodu słońca.
internal class SolarCalculator : ISolarCalculator
{
private readonly Coordinates _warsaw;
private readonly Places _places;
public SolarCalculator(Places places)
{
_places = places;
_warsaw = _places.Coordinates.First(c => c.Name.Equals(
"Warsaw", StringComparison.Ordinal));
}
public DateTime GetWarsawSunset(DateTimeOffset? date = null)
{
var time = new SolarTimes(date ?? DateTimeOffset.Now, _warsaw.Latitude, _warsaw.Longitude);
return time.Sunset;
}
}
internal interface ISolarCalculator
{
DateTime GetWarsawSunset(DateTimeOffset? date = null);
}Teraz przychodzi czas na nieco ciekawszy kawałek kodu. Będzie to BackgroundService sprawdzający każdego dnia czy nadszedł zachód słońca. W odpowiednim momencie wywoła on event, którego obsługa będzie polegała na włączeniu lampek. Ten kawałek kodu objaśnię nieco szerzej już niedługo.
Solar Service
Zawiera dwa istotne elementy: Jeden to `event EventHandler SunsetOccurred` czyli zdarzenie, na które aplikacja zareaguje włączając lampki. Drugi element to logika obliczająca czas zachodu słońca i śledząca czy ten czas już nadszedł. Jest ona zaszyta w metodzie ExecuteAsync serwisu działającego w tle BackgroundService. ExecuteAsync uruchamia Task, który na początek bada, czy aplikacja nie została uruchomiona już po zachodzie słońca. W takim przypadku wywołuje zdarzenie SunsetOccurred. Po sprawdzeniu przechodzi do nieskończonej pętli (linia 52), w której, podobnie jak wcześniej, oblicza moment zachodu (ale już kolejnego dnia), oblicza też ile czasu zostało do tego zachodu, po czym przechodzi w uśpienie (linia 63). Kiedy czas minie event jest wywoływany pod warunkiem, że proces nie jest zamykany (linia 46). Dokładnie to CancellationToken przekazywany do metody ExecuteAsync wchodzi w stan canceled, kiedy wywołana jest metoda StopAsync BackgroundService’u. A StopAsync jest wołana, kiedy host otrzyma wezwanie do zatrzymania od systemu operacyjnego. Na jedno wychodzi, ale warto być dokładnym 😉
Zwróć uwagę na to, że pobranie czasu nie jest zakodowane w tasku RunEventLoopAsync, funkcja obliczająca jest parametrem wywołania (linia 16). W ten sposób łatwiej wprowadzać zmiany, albo dodawać kolejne taski eventLoop (np. SunriseOccurred). Będą one miały analogiczną strukturę. Należy tylko w wywołaniu podać inny delegat Func<> i string reason. Zwróć też uwagę, że każdy handler jest wywoływany w oddzielnym bloku try/catch (linia 128). To chroni aplikację przed błędami, jakie mogłyby wystąpić w metodzie obsługi zdarzenia (handlerze). Normalnie, po błędzie, kolejne handlery zasubskrybowane do zdarzenia nie byłyby wywołane. Warto zapamiętać ten wzorzec.
Kod obsługuje wyjątki OperationCanceledException, które będą rzucane przy zamykaniu aplikacji. Osobiście uważam, że ten wyjątek zawsze powinien być obsłużony i kiedy zamykamy aplikację, to nie powinny temu towarzyszyć wyjątki w logach. Mała rzecz, a cieszy 🙂
internal class SolarNotifierService(
ILogger<SolarNotifierService> logger,
IServiceScopeFactory scopeFactory) : BackgroundService
{
public event EventHandler SunsetOccurred;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RunEventLoopAsync(
"Zachód słońca",
(calc, date) => calc.GetWarsawSunset(date),
reason => InvokeSunsetEvent(reason),
stoppingToken);
}
private async Task RunEventLoopAsync(
string eventName,
Func<ISolarCalculator, DateTimeOffset?, DateTime> getEventTime,
Action<string> invokeEvent,
CancellationToken token)
{
try
{
// Initial check + possible catch-up
using (var scope = scopeFactory.CreateScope())
{
var calculator = scope.ServiceProvider.GetRequiredService<ISolarCalculator>();
var todayTime = getEventTime(calculator, null);
var now = DateTimeOffset.Now;
if (now > todayTime)
{
logger.LogWarning("Aplikacja uruchomiona po {EventName}. Wywołuję event natychmiast (catch-up).", eventName);
invokeEvent($"Zaległy {eventName} (start aplikacji po czasie)");
}
else
{
logger.LogInformation("Czekam na dzisiejszy {EventName}: {EventTime}", eventName, todayTime);
await WaitUntil(todayTime, token);
if (!token.IsCancellationRequested)
invokeEvent($"Dzisiejszy {eventName}");
}
}
// Daily loop
while (!token.IsCancellationRequested)
{
try
{
var tomorrow = DateTimeOffset.Now.AddDays(1);
using var scope = scopeFactory.CreateScope();
var calculator = scope.ServiceProvider.GetRequiredService<ISolarCalculator>();
var nextTime = getEventTime(calculator, tomorrow);
logger.LogInformation("Następny {EventName} zaplanowany na: {EventTime}", eventName, nextTime);
await WaitUntil(nextTime, token);
if (!token.IsCancellationRequested)
invokeEvent($"Planowy {eventName}");
}
catch (OperationCanceledException)
{
// cancellation requested - exit loop
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error in {EventName} loop", eventName);
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
catch (OperationCanceledException)
{
// cancelled before starting - ignore
}
catch (Exception ex)
{
logger.LogError(ex, "Fatal error while starting {EventName} loop", eventName);
}
}
private async Task WaitUntil(DateTime targetTime, CancellationToken token)
{
var delay = targetTime - DateTime.Now;
if (delay.TotalMilliseconds > 0)
{
try
{
await Task.Delay(delay, token);
}
catch (TaskCanceledException)
{
logger.LogWarning("Task cancelled");
// ignore
}
}
}
private void InvokeSunsetEvent(string reason)
{
logger.LogInformation("EVENT: ZACHÓD SŁOŃCA ({reason})", reason);
SafeInvoke(SunsetOccurred, EventArgs.Empty, "SunsetOccurred");
}
private void SafeInvoke(EventHandler? handler, EventArgs args, string eventName)
{
if (handler == null)
return;
var invocationList = handler.GetInvocationList();
foreach (var @delegate in invocationList)
{
try
{
if (@delegate is EventHandler eventHandler)
eventHandler(this, args);
}
catch (Exception ex)
{
logger.LogError(ex, "Exception thrown by handler for {EventName}; continuing with other handlers", eventName);
}
}
}
}
Wschód słońca
Wspomniałem. że SolarNotifierService może obsługiwać więcej zdarzeń. Gdybyśmy chcieli coś zrobić np. o świcie, to wystarczy zdefiniować kolejne zdarzenie i uruchomić task realizujący logikę jego wywoływania. Zamiast linii 10-18 mielibyśmy coś takiego:
public event EventHandler SunriseOccurred;
public event EventHandler SunsetOccurred;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var sunriseTask = RunEventLoopAsync(
"Wschód słońca",
(calc, date) => calc.GetWarsawSunrise(date),
reason => InvokeSunriseEvent(reason),
stoppingToken);
var sunsetTask = RunEventLoopAsync(
"Zachód słońca",
(calc, date) => calc.GetWarsawSunset(date),
reason => InvokeSunsetEvent(reason),
stoppingToken);
await Task.WhenAll(sunriseTask, sunsetTask);Oczywiście należałoby dodać definicję sunriseTask w sposób, który opisałem wcześniej. Trzeba też rozszerzyć interfejs ISolarCalculator o medodę GetWarsawSunrise i ją zaimplementować. Mając jednak wzorzec, to już pestka.
Alarm Clock Service
Żeby wyłączyć lampki potrzebujemy zdarzenia, że nadeszła 23:00. To jest zrealizowane analogicznie, jak w usłudze obsługującej zachód słońca. BackgroundService zawiera publiczny event EventHandler? Alarm2300Triggered, do którego za chwilę zasubskrybujemy procedurę wyłączenia prądu. Serwis zawiera także task alarm2300Task uruchamiany przy jego starcie. Po szczegóły odsyłam do kodu na Githubie.
GpioController
Teraz wypadało by obsłużyć zdarzenia i spowodować zapalanie lampek, Normalnie użylibyśmy bezpośrednio sterownika GpioController pochodzącego z pakietu System.Device.Gpio. Ma on jednak pewną cechę, którą chcę zmodyfikować. Otóż twórcy biblioteki zakładają, że aplikacja może uruchomić GpioController, ustawić jakiś stan i zakończyć pracę. Przy czym konfiguracja i stan wyjścia pozostaje taki jaki został ustawiony. Nie jest przywracany stan „spoczynkowy”. Jest to pożądane przy np. zadaniach uruchamianych wg harmonogramu. Manipuluje się wtedy stanem wyjść i kończy pracę. Jednak w tym przypadku chcę, aby to działało inaczej. Chodzi o to, by kończąc pracę, aplikacja zmieniła stan na zdefiniowany jako „spoczynkowy*. Ma to zapobiec sytuacji, polegającej na tym, że jeśli między zachodzem słońca a 23:00 aplikacja lub system operacyjny zostaną zamknięte, to lampki pozostaną zapalone po 23:00.
Stan wyjścia GPIO
Muszę zdefiniować i utrzymać stan wyjścia w aplikacji. Będzie on trzymany w klasie PinState.
public class PinState(GpioPin pin, PinValue onClose, PinValue? value)
{
public static PinState CreateState(GpioPin pin, PinValue onCloseValue, PinValue? value) =>
new(pin, onCloseValue, value);
public GpioPin Pin { get; set; } = pin;
public PinValue OnCloseValue { get; set; } = onClose;
public PinValue? Value { get; set; } = value;
}GpioControllerWithPinRestore
Stan będzie ustawiany w kontrolerze GpioControllerWithPinRestore, który wyłączy prąd i zdeaktywuje konwerter napięcia przy zamykaniu aplikacji. W tym celu rozszerzam GpioController.
Konstruktor jest „DI friendly” i przyjmuje wstrzyknięty Ilogger. Ten konstruktor zostanie wybrany przez kontener DI przy instancjonowaniu obiektu. .NET zawsze wybiera konstruktor (o ile ma wybór) który pozwoli na wstrztyknięcie jak największej liczby zarejestrowanych serwisów. Oprócz tego jest konstruktor bezparametrowy, który się przyda w aplikacji bez kontenera DI.
Przed użyciem wyjścia, trzeba wywołać OpenPin() (linia 14) i podać stan wyjcia, jaki chcemy mieć po zamknięciu aplikacji (onCloseValue). Jeśli tego nie zrobisz, to próba wykonania Write() (linia 21) rzuci wyjątek z bazowego GpioController.
Kontener DI przy zamykaniu aplikacji woła Dispose() (linia 34) dla wszystkich serwisów, które implenetują IDisposable. W tym momencie wyjścia zostaną ustawione w stan onCloseValue. Lampki zgasną. Sporo roboty, żeby zgasić światło 😉
public class GpioControllerWithPinRestore : GpioController, IDisposable
{
private readonly ConcurrentDictionary<int, PinState?> _pins = [];
private readonly ILogger<GpioControllerWithPinRestore>? _logger;
public GpioControllerWithPinRestore(ILogger<GpioControllerWithPinRestore> logger) : base()
{
_logger = logger;
}
public GpioControllerWithPinRestore() : base()
{ }
public GpioPin OpenPin(int pinNumber, PinMode mode, PinValue initialValue, PinValue onCloseValue)
{
var pin = base.OpenPin(pinNumber, mode, initialValue);
_pins.TryAdd(pinNumber, PinState.CreateState(pin, onCloseValue, initialValue));
return pin;
}
public new void Write(int pinNumber, PinValue value)
{
var isPinExisting = _pins.TryGetValue(pinNumber, out var pinState);
if (isPinExisting)
{
pinState!.Value = value;
_pins[pinNumber] = pinState;
}
base.Write(pinNumber, value);
}
// other commands omitted for brevity
public new void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public new void Dispose(bool disposing)
{
foreach (var pin in _pins)
{
try
{
Write(pin.Key, pin.Value.OnCloseValue);
}
catch (Exception)
{
// ignore
}
}
base.Dispose(disposing);
}
}Prawie wszystkie składowe są gotowe. Czas na ostatni, który je powiąże w funkcjonalną całość.
Główny serwis GpioWorker
Bardzo ważny, ale najprostszy z dotychczasowych serwis robi niewiele. Jego zadaniem jest zasubskrybowanie handlerów (tak, to te obrzydliwe metody void lub nawet gorzej async void) do zdarzeń. Na tym mógłby zakończyć pracę. Musi jednak być utrzymany przy życiu po to, żeby odśmiecacz Garbage collector nie usunął instancji a wraz z nią event handlerów trzymających referencje do metod obsługi.
Dobra praktyka nakazuje usunąć z listy subskrybcyjnej nieużywane handlery. To się dzieje w metodzie StopAsync() wołanej przez host przy jego zamykaniu.
internal class GpioWorker(
ILogger<GpioWorker> logger,
GpioControllerWithPinRestore gpioController,
SolarNotifierService solarNotifier,
AlarmClockService alarmClockService) : BackgroundService
{
const int _pinChoinka= 26;
const int _pinLevelConverter= 6;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
// subscribe to notifications and ensure level converter is enabled
solarNotifier.SunsetOccurred += Choinka_OnEventOccurred;
alarmClockService.Alarm2300Triggered += Choinka_OffEventOccurred;
gpioController.OpenPin(_pinLevelConverter, PinMode.Output, PinValue.High, PinValue.Low);
if (gpioController.Read(_pinLevelConverter) == PinValue.Low)
gpioController.Write(_pinLevelConverter, PinValue.High);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
logger.LogInformation("GpioWorker cancellation requested");
}
catch (Exception ex)
{
logger.LogError(ex, "Error initializing GpioWorker. In case of error 13, try to elevate privileges and run the application with 'sudo'.");
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("GpioWorker stopping, unsubscribing events and cleaning up pins");
try
{
solarNotifier.SunsetOccurred -= Choinka_OnEventOccurred;
alarmClockService.Alarm2300Triggered -= Choinka_OffEventOccurred;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error while stopping GpioWorker");
}
return base.StopAsync(cancellationToken);
}
private void Choinka_OnEventOccurred(object? sender, EventArgs e)
{
if(!gpioController.IsPinOpen(_pinLevelConverter))
gpioController.OpenPin(_pinLevelConverter, PinMode.Output, PinValue.High, PinValue.Low);
if (gpioController.Read(_pinLevelConverter) == PinValue.Low)
gpioController.Write(_pinLevelConverter, PinValue.High);
if (!gpioController.IsPinOpen(_pinChoinka))
gpioController.OpenPin(_pinChoinka, PinMode.Output, PinValue.Low, PinValue.Low);
if (gpioController.Read(_pinChoinka) == PinValue.Low)
gpioController.Write(_pinChoinka, PinValue.High);
}
private void Choinka_OffEventOccurred(object? sender, EventArgs e)
{
if (!gpioController.IsPinOpen(_pinChoinka))
gpioController.OpenPin(_pinChoinka, PinMode.Output, PinValue.Low, PinValue.Low);
else
gpioController.Write(_pinChoinka, PinValue.Low);
logger.LogInformation("Stan wyjścia: {StanChoinka}", gpioController.Read(_pinChoinka));
}
}Rejestracja usług
W Program.cs przed linią 9 (z pierwszego listingu) dodajemy sekcję rejestrującą serwisy. To, co tu może zwróić uwagę, to sposób rejestracji usług tła BackgroundService. Nie rejestruję ich najpopularniejszym sposobem AddHostedService<T>(), tylko rejestuję singleton (którym i tak są), ale używam przeciążenia biorącego funkcię AddHostedService(Func<IServiceProvider, T> func). Tak mam zarejestowany singleton, który mogę wstrzyknąć do GpioWorker, i jednocześnie uruchamiam serwis w tle. To jest jeden z alternatywnych sposobów rejestracji. Przy okazji przestrzegam przed pomysłem typu . To może i zadziała pozornie (po dodaniu konstruktora bezparametrowego, ale kontener DI nie będzie zarządzał cyklem życia takiego obiektu i może to mieć nie przewidziane konsekwencje.AddHostedService(sp => new AlarmClockService())
hostBuilder.ConfigureServices((ctx, services) =>
{
services.AddSingleton(sp => new Places());
// sun time services
services.AddSingleton(sp => new Places());
services.AddScoped<ISolarCalculator, SolarCalculator>();
services.AddSingleton<SolarNotifierService>();
services.AddHostedService(sp => sp.GetRequiredService<SolarNotifierService>());
// timed event service
services.AddSingleton<AlarmClockService>();
services.AddHostedService(sp => sp.GetRequiredService<AlarmClockService>());
services.AddSingleton<GpioControllerWithPinRestore>();
services.AddHostedService<GpioWorker>();
});Wsparcie menedżera systemu i usług w Linuxie
Raspian (system operacyjny RaspberryPi) pochodzi od Ubuntu i jest systemem linuxowym. Wygodnie będzie wdrożyć lampki jako usuługę systemową. To daje wygodę polegającą m.in na tym, że usługa sama wstaje razem z systemem.
Na początek dodaj opcjonalny pakiet Systemd. Poprawia on współpracę z Menedżerem systemu i usług w systemie Linux.
dotnet add package Microsoft.Extensions.Hosting.Systemd
I w Program.cs (po linii 8 pierwszego listingu) dodaj:
hostBuilder.UseSystemd();
Tu kończy się praca nad kodem C#. Pełna działająca wersja jest na moim Githubie
Publikacja projektu
Teraz trochę pracy DevOps. Jeśli chcesz zbudować projekt i uruchomić na malinie, to czytaj dalej. Jak już masz cały kod i buduje się on lokalnie, to czas wysłać go do maliny. Na początek opublikuj lokalnie aplikację ustawiając Linux jako docelowy OS. Zbudujemy aplikację, która nie będzia potrzebowała obecności .net na malinie (–self-contained true) i pakuje wszysko do jednego pliku (-p:PublishSingleFile=true). Rasbian ma już od dawna oficjalną wersję 64-bitową. Jeśli masz wersję 32-bitową, to wybierz odpowiednią komendę (przełącznik -r linux-arm64 lub -r linux-arm).
# Raspberry Pi 64-bit
dotnet publish ./choinka.csproj -c Release -r linux-arm64 -p:PublishSingleFile=true --self-contained true -o ./bin/Release/net8.0/publish/linux-arm64/
# Raspberry Pi 32-bit
dotnet publish ./choinka.csproj -c Release -r linux-arm -p:PublishSingleFile=true --self-contained true -o ./bin/Release/net8.0/publish/linux-arm/
Prześlij kod na malinę i ustaw właściciela plików oraz uprawnienia do wykonywania poleceń (execute). Zmodyfikuj polecenia, aby odpowiadały nazwie użytkownika, jakiej używasz. W przykładzie użytkownikiem jest pi.
scp -r ./bin/Release/net8.0/publish/linux-arm64/* pi@raspberrypi:./choinka
ssh pi@raspberrypi'sudo chown -R pi:pi ./choinka'
ssh pi@raspberrypi'sudo chmod +x ./choinka/choinka'
Usługa systemu Linux
Czas zarejestrować aplikację, jako usługę w Linuxie. Utwórz plik /etc/systemd/system/choinka.service.
[Unit]
Description=choinka background service
After=network.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/choinka
ExecStart=/home/pi/choinka/choinka
Restart=always
RestartSec=10
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=TZ=Europe/Warsaw
[Install]
WantedBy=multi-user.target
Już prawie koniec. Wystarczy przeładować demona menedżera i uruchomić usługę
sudo systemctl daemon-reload
sudo systemctl enable choinka.service
sudo systemctl start choinka.service
# tak możesz podejrzeć logi
sudo journalctl -u choinka.service -f
# a tak status usługi
sudo systemctl status choinka.service
To już definitywny koniec wpisu. Choinka świeci po zachodzie słońca a gaśnie o 23:00. Przy okazji pokazałem, jak działa aplikacja sterowana zdarzeniami i jak uruchomić usługę, króra przetwa restrart systemu w Linuxie. Miłego kodzenia 🙂
Repozytorium na Githubie:
Czasem aplikacja ma reagować na zmiany zachodzące w systemie plików aby, np. przetwarzać nowe pliki, które właśnie ktoś do niego dodał. Nadaje się do tego świetnie klasa FileSystemWatcher (FSW). Działa w Windows i pod przy odpowiedniej konfiguracji także pod Linuxem. Ale słodycz się kończy po skonteneryzowaniu aplikacji. Trafił mi się właśnie taki przypadek. Kontener nie dostaje notyfikacji o zmianach w systemie plików woluminu zamontowanego do kontenera. Zupełna cisza, albo cytując klasyka „Ciemność, widzę ciemność!”
Przyczyną tego stanu rzeczy są różne mechanizmy generowania i transmisji zdarzeń o zmianie w systemie plików przez różne OS, systemy plików, protokoły sieciowe. FileSystemWatcher instancjonuje obiekt inotify dla każdego śledzonego folderu, który nasłuchuje na zdarzenia generowane przez system plików. Jeśli jednak nastąpi błąd sieci, to zdarzenie nie dotrze. Jeśli przepełni się bufor zdarzeń, to zdarzenie nie dotrze, Jeśli system plików nie potrafi w sposób powtarzalny wysłać powiadomień, to one nie będą docierały. Tych jeżeli… jest sporo i zawsze kończą się tym, że zdarzenie nie dotrze. FSW sprawdza się w zasadzie jedynie dla dysków lokalnych i to najlepiej pod Windows.
A tymczasem w folderze jest coraz więcej plików, a aplikacja nie reaguje. Co robić? Pooling udziału sieciowego? Nic innego nie pozostaje.
Można do tego zastosować PhysicalFileProvider. W odróżnieniu od FSW, nie pozwala on na wybór rodzaju zdarzeń, które notyfikuje, ale powinien wystarczyć przynajmniej jako źródło informacji, że „coś się zmieniło”. Po prostu zostanie ustawiony cykliczny pooling folderu. Nie ma innego wyjścia w Dockerze. Sami będziemy musieli dowiedzieć się, jak zmiana zaszła. Jedynie co wiadomo, to że zaszła. Ale dobre i to.
Za to wykonamy porządną implementację obsługi kolejki zdarzeń. Przecież może się zdarzyć, że zdarzenia o nowych plikach będą spływały szybciej niż trwa ich obsługa. Wykorzystamy generyczną BlockingCollection do jednoczesnego dodawania i konsumowania zdarzeń. To kolejka FIFO, która jest bezpieczna dla wielu wątków (thread-safe). Jeden wątek może dodawać zdarzenia informujące o nowych plikach, a inny obsługiwać te zdarzenia. Nasz DirectoryWatcher ma następujący interfejs:
public interface IDirectoryWatcher
{
void RegisterCallback(Action<string> callback);
Task StartWatching(CancellationToken cancellationToken);
void Dispose();
}Po zainstancjonowaniu DirectoryWatchera rejestruje się callback. Jest on wołany w momencie obsługi zdarzenia pobieranego z kolejki. W przykładzie callbackiem jest medoda synchroniczna. Jest ona wołana w osobnym wątku, tym samym który obsługuje kolejkę. Obsługa callbacka nie nie blokuje działania pozostałego kodu. W razie potrzeby można łatwo zmienić kod, aby wywołanie było asynchroniczne.
Potem wystarczy uruchomić DirectoryWatcher wywołując StartWatching. Metoda wygląda tak:
public async Task StartWatching(CancellationToken ct = default)
{
_fileSystemEventBuffer = new BlockingCollection<IFileInfo>(EventBufferSize);
CreateFileWatcher();
_fileWatcherCallback = WatchForFileChanges();
var processBuffer = Task.Run(() =>
{
foreach (var fileInfo in _fileSystemEventBuffer.GetConsumingEnumerable(ct))
{
_callback?.Invoke(fileInfo.PhysicalPath!);
LogWhenBufferIsEmpty();
}
}, ct);
// Implement resilience to transient IO errors
_files = GetFiles();
BufferFiles();
await processBuffer.ConfigureAwait(false);
}Deklaracje zmiennych i stałych na razie pominę. Zostaną pokazane w dalszej części. Podobnie metody CreateFileWatcher() i WatchForFileChanges(). Teraz opiszę działanie typu BlockingCollection. Tworzymy bufor new BlockingCollection(EventBufferSize). W linii 8 uruchamiany jest wątek, który pobiera z kolejki informacje o nowych plikach i wywołuje callback. Kluczowe tu jest, że pętla się „nie kończy”. Po skonsumowaniu wszyskich zdarzeń zostaje „uśpiona” i wznawia działanie kiedy do kolekcji wpadną nowe elementy. A mogą one być dodawane w każdym momencie w innym wątku. Możemy zatem spokojnie w swoim tempie obsługiwać zdarzenia nie martwiąc się o zablokowanie możliwości ich dodawania. W linii 18 pobieramy informacje o plikach, bo na starcie programu zakładamy, że wszystkie pliki są nowe. Definicja metody GetFiles():
private List<IFileInfo> GetFiles() => _fileWatcher!.GetDirectoryContents(string.Empty).ToList();Następnie wszyskie dane wrzucamy do bufora (linia 19). Kiedy tylko zostanie dodany pierwszy rekord, wznawia pracę pętla foreach z linii 10 i wywoływany jest callback.
private void BufferFiles()
{
foreach (var fileInfo in _files!)
{
if (!_fileSystemEventBuffer!.TryAdd(fileInfo))
Console.WriteLine($"Buffer size exceeded ({EventBufferSize}) or buffer is disposed");
}
}I do by było na tyle, jeśli idzie o mechanizm bufora. Wielowątkowo dodajemy do niego i konsumujemy obiekty. Dla reszty kodu działanie „głównej pętli” odbywa się „w tle” nie wpływając na pracę innych wątków. Nie jest to wątek tła, ale dzięki asynchroniczności i bezpiecznemu dla wątków api BlockingCollection możemy tak roboczo przyjąć.
Do omówienia został mechanizm notyfikacji o nowych plikach w folderze. Na początek metoda CreateFileWatcher() wołana w linii 5 metody StartWatching(). Instancjonujemy PhysicalFileProvider, który nie będzie brał pod uwagę plików ukrytych (także z kropką) ani systemowych. Będzie za to cyklicznie sięgał do folderu na okoliczność wykrycia zmian w systemie plików. Nic specjalnego.
private void CreateFileWatcher()
{
_fileWatcher = new PhysicalFileProvider(_directoryToWatch, ExclusionFilters.Sensitive)
{
UsePollingFileWatcher = true,
UseActivePolling = true
};
}Za to metoda WatchForFileChanges() a właściwie to co ona zapoczątkowuje jest bardziej interesujące.
private static ulong _changeLevel;
private IChangeToken? _changeToken;
private IDisposable? _fileWatcherCallback;
private IDisposable WatchForFileChanges()
{
_changeToken = _fileWatcher!.Watch("**/*.*");
return _changeToken.RegisterChangeCallback(_ => NotifyFileChange(), default);
}
private void NotifyFileChange()
{
Console.WriteLine("Directory has changed. Callback invoked");
_fileWatcherCallback = WatchForFileChanges();
if (0 == Interlocked.CompareExchange(ref _changeLevel, 1, 0))
BufferNewFiles();
else
Interlocked.Exchange(ref _changeLevel, 2);
}W linii 7 wskazujemy jakie pliki nas interesują używając filtra. Metoda Watch() zwraca IChangeToken, który będzie notyfikowany o zmianach śledzonych plików. Następnie temu tokenowi wskazujemy callback jaki ma wywołać w wyniku notyfikacji (NotifyFileChange). W moich próbach po jednorazowej notyfikacji IChangeToken przestawał być użyteczny. Albo przestawał być notyfikowany o zmianach, albo rejestacja callbacka wygasała. W każdym razie w linii 15 ponownie wołam metodę WatchForFileChanges(), aby notyfikacje znów działały. A chcemy, żeby działały nieustannie nasłuchując na nowe pliki.
Teraz robi się ciekawiej. Trzeba jakoś ogarnąć różne stany w jakich może się znaleźć Watcher. Wyróżniłem trzy stany:
- 0 – nie trwa proces buforowania (dodawania zmian do kolekcji), brak notyfikacji o zmianach
- 1 – trwa proces buforowania, brak notyfikacji o zmianach,
- 2 – trwa proces buforowania i nadeszła notyfikacja o zmianach
Do przechowywania informacji, w jakim stanie jest Watcher, wykorzystałem zmienną statyczną, do której dostęp uzyskuję przez klasę Interlocked. Ta klasa zapewnia dostęp do zmiennej w sposób bezpieczny z różnych wątków (thread-safe).
W metodzie NotifyFileChange() jeśli mamy stan 0, to buforujemy nowe pliki i następuje zmiana stanu na 1. W przeciwnych przypadku ustawiamy stan na 2. Co się dzieje dalej, pokazują kolejne listingi.
private void BufferNewFiles()
{
do
{
Interlocked.CompareExchange(ref _changeLevel, 1, 2);
var filesActual = GetFiles();
var newFiles = filesActual.ExceptBy(_files!.Select(f => f.Name), fi => fi.Name);
_files = filesActual;
foreach (var fileInfo in newFiles)
{
if (!_fileSystemEventBuffer?.TryAdd(fileInfo) ?? false)
Console.WriteLine($"Buffer size exceeded ({EventBufferSize}) or buffer is disposed");
}
} while (1 < Interlocked.Read(ref _changeLevel));
Interlocked.Exchange(ref _changeLevel, 0);
}Metoda BufferNewFiles() na początek zmienia stan na 1 „trwa proces buforowania”. Pobiera aktualną listę plików w folderze, porównuje z zachowaną w pamięci i nowe pliki dodaje do bufora. Zastosowałem proste porównanie nazw plików, co nie zawsze musi być jednoznaczne. Gdybyśmy chcieli wykrywać zmianę plików (a nie dodanie nowych), to można by liczyć hash zawartości i przechowywać go jako wartość w słowniku, a znormalizowaną nazwę (albo jej hash) jako klucz. Ale w tym wpisie skupiam się na funkcji bezpiecznego i skutecznego notyfikowania o zmianach w folderze na poziomie systemu plików.
Po dodaniu plików do bufora, sprawdzana jest aktualna wartość _changeLevel, Jeśli jest 2, czyli w trakcie aktualizacji bufora pojawiła się notyfikacja o zmianach, powtarzamy proces buforowania. Jeśli nie, to kończy się proces buforowania a stan przyjmuje wartość 0. Watcher oczekuje na notyfikację.
W praktyce zdarzają się sytuacje, że notyfikacja nie nadchodzi. Pisałem o tym na początku. Można przyjąć dwie strategie wobec takich przypaków. Albo czeka się na następną notyfikację, po której i tak badana jest zawartość folderu, więc nowy plik nie umknie. Ale jeżeli założenie biznesowe jest takie, że nowe pliki mogą się pojawiać w takich dużych odstępach, że nie można sobie pozwolić na oczekiwanie z przetworzeniem pominiętego pliku, to trzeba znaleźć inne rozwiązanie. Ja przyjąłem drugi scenariusz. Timer co jakiś czas sprawdza folder nawet jak notyfikacja nie nadejdzie. Natomiast robi to w dużych interwałach i jego praca jest wstrzymywana, jeśli pliki są buforowane (_changeLevel != 0). Timer jest widoczny poniżej na pełnym listu Watchera.
using System.Collections.Concurrent;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Physical;
using Microsoft.Extensions.Primitives;
namespace DirectoryWatcher.DirectoryWatcher;
public class DirectoryWatcherWithPolling : IDirectoryWatcher, IDisposable
{
private const int EventBufferSize = 100000;
private const int DirectoryPollingInterval = 300;
private readonly string _directoryToWatch;
private static ulong _changeLevel;
private Action<string>? _callback;
private IEnumerable<IFileInfo>? _files;
private BlockingCollection<IFileInfo>? _fileSystemEventBuffer;
private PhysicalFileProvider? _fileWatcher;
private IChangeToken? _changeToken;
private IDisposable? _fileWatcherCallback;
private PeriodicTimer? _timer;
public DirectoryWatcherWithPolling(string directoryToWatch)
{
if (string.IsNullOrEmpty(directoryToWatch) || !Directory.Exists(directoryToWatch))
throw new ArgumentException("Directory can not be empty string");
_directoryToWatch = directoryToWatch;
}
public void RegisterCallback(Action<string>? callback) => _callback = callback;
public async Task StartWatching(Action<string>? callback, CancellationToken ct = default)
{
RegisterCallback(callback);
await StartWatching(ct);
}
public async Task StartWatching(CancellationToken ct = default)
{
try
{
_timer = new PeriodicTimer(TimeSpan.FromSeconds(DirectoryPollingInterval));
_fileSystemEventBuffer = new BlockingCollection<IFileInfo>(EventBufferSize);
CreateFileWatcher();
_fileWatcherCallback = WatchForFileChanges();
var processBuffer = Task.Run(() =>
{
foreach (var fileInfo in _fileSystemEventBuffer.GetConsumingEnumerable(ct))
{
_callback?.Invoke(fileInfo.PhysicalPath!);
LogWhenBufferIsEmpty();
}
}, ct);
// TODO Implement resilience policy
_files = GetFiles();
BufferFiles();
while (await _timer.WaitForNextTickAsync(ct))
{
if (Interlocked.Read(ref _changeLevel) != 0) continue;
_fileWatcher?.Dispose();
Console.WriteLine("Directory polling upon timer");
BufferNewFiles();
CreateFileWatcher();
_fileWatcherCallback = WatchForFileChanges();
}
await processBuffer.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// ignore
}
finally
{
Dispose();
}
}
private void LogWhenBufferIsEmpty()
{
if (_fileSystemEventBuffer?.Count == 0)
Console.WriteLine("Synchronisation buffer is empty");
}
private List<IFileInfo> GetFiles() =>
_fileWatcher!.GetDirectoryContents(string.Empty).ToList();
private void CreateFileWatcher()
{
// TODO Implement resilience policy
_fileWatcher = new PhysicalFileProvider(_directoryToWatch, ExclusionFilters.Sensitive)
{
UsePollingFileWatcher = true,
UseActivePolling = true
};
}
private IDisposable WatchForFileChanges()
{
_changeToken = _fileWatcher!.Watch("**/*.*");
return _changeToken.RegisterChangeCallback(_ => NotifyFileChange(), default);
}
private void NotifyFileChange()
{
Console.WriteLine("Directory has changed. Callback invoked");
_fileWatcherCallback = WatchForFileChanges();
if (0 == Interlocked.CompareExchange(ref _changeLevel, 1, 0))
BufferNewFiles();
else
Interlocked.Exchange(ref _changeLevel, 2);
}
private void BufferFiles()
{
foreach (var fileInfo in _files!)
{
if (!_fileSystemEventBuffer!.TryAdd(fileInfo))
Console.WriteLine($"Buffer size exceeded ({EventBufferSize}) or buffer is disposed");
}
}
private void BufferNewFiles()
{
do
{
Interlocked.CompareExchange(ref _changeLevel, 1, 2);
// TODO Implement resilience policy
var filesActual = GetFiles();
var newFiles = filesActual.ExceptBy(_files!.Select(f => f.Name), fi => fi.Name);
_files = filesActual;
foreach (var fileInfo in newFiles)
{
if (!_fileSystemEventBuffer?.TryAdd(fileInfo) ?? false)
Console.WriteLine($"Buffer size exceeded ({EventBufferSize}) or buffer is disposed");
}
} while (1 < Interlocked.Read(ref _changeLevel));
Interlocked.Exchange(ref _changeLevel, 0);
}
private bool _isDisposed;
public void Dispose() => Dispose(true);
private void Dispose(bool disposing)
{
if (_isDisposed) return;
if (disposing)
{
_fileWatcherCallback?.Dispose();
_fileSystemEventBuffer?.Dispose();
_timer?.Dispose();
}
_isDisposed = true;
}
}Uwagi na koniec. Skupiłem się na pokazaniu, jak sprawnie nasłuchiwać na zmiany w systemie plików przez aplikację działającą w kontenerze Dockera, gdzie nie zadziała FileSystemWatcher. Zaprezentowany mechanizm działa bardzo dobrze. W produkcji klasa DirectoryWatcherWithPolling jest instancjonowana z kontenera DI. Wstrzykiwany jest logger (zamiast wyjścia na konsolę), polityki odporności na błędy IO (każde odwołanie do dysku jest „uodpornione” na chwilowe błędy IO) i ustawienia.
Na Githubie można zapoznać się z przykładową aplikacją, wykorzystującą DirectoryWatcher.
Jak wspomniałem w poptrzednim wpisie na ten temat, zadanie polega na znalezieniu takich działań, które wstawione pomiędzy liczby dadzą równianie. Dla utrudnienia, poszczególne składniki obu części równości można łączyć tworząc liczbę składającą się z cyfr obu składowych. Nie można przestawiać kolejności składników ani cyfr w składnikach. Wynik każdego działania musi być liczbą całkowitą, a składowe muszą być liczbą całkowitą dodatnią. Dla przykładu dane składowe 1 i 23 można zestawić tak:
1 + 23
1 – 23
1 * 23
123
(1 / 23 odpada, bo wynik dzielenia pozostawia resztę)
Program będzie wykonywał sekwencję:
- Przyjmie dane wejściowe
- Wygeneruje możliwe kombinacje dla obu stron równiania
- Policzy wartość każdego wyrażenia eliminując te, które nie spełniają wymagania dzielenia bez reszty
- Znajdzie kombinacje obu stron równania, które można zestawić ze sobą (dają równość)
- Wyświetli wyniki
W tym wpisie zajmę się utworzeniem wyrażeń dla jednej strony równania. W kolejnych wpisach, kiedy będą obliczane wartości wyrażeń (punkt 3), wywołamy kod dwuktotnie. Część 1 i 5 będą pokazane na końcu, kiedy zbudowana zostanie aplikacja Blazor a z nią interfejs użytkownika.
Na początek utworzymy obiekt – model wyrażenia. Będzie on zawierał listę składowych i listę działań matematycznych. Oprócz tego zapiszemy wynik wyrażenia oraz flagę wskazującą, czy działanie jest ważne czy też błędne. Zdefiniujmy też dozwolone działania matematyczne.
public class Expression
{
public List<int> Numbers { get; set; } = new();
public List<OperationType> Operations { get; set; } = new();
public int? CalculatedResult { get; set; }
public bool ErrorOccured { get; set; }
}
public enum OperationType
{
Add,
Substract,
Multiplicate,
Divide
}Właściwości Numbers i Operations są od razu inicjalizowane, bo za chwilę będą w nich przechowywane kolejne elementy. Dla wyrażenia '1+23′ instancja Expression będzie zawierała w liście Numbers kolejno liczby 1, 23 a Operations jedną z operacji.
Wykonajmy metodę budującą te wyrażenia. Znajdzie się ona w klasie ExpressionBuilder. Wyrażenia będą budowane od lewej. Wejściowa lista liczb, np. 1, 2, 3 będzie dzielona na liczby z lewej strony operacji oraz na te z prawej strony. Pomiędzy nie wstawiamy po kolei operacje Add, Substract, Multiplicate, Divide. Na początku pozycja podziału znajduje się pomiędzy 1 (leftHandNumbers linia 13) a 2 i 3 (rightHandNumbers linia 19). W linii 14 wywoływana jest funkcja GetPartialExpressions, do której przekazywane są leftHandNumbers i całe wyrażenie. Tam tworzone są operacje częściowe 1+, 1-, 1* i 1/. Funkcja zwraca wyliczenie takich częściowych operacji. Potem w linii 18-20 do każdej z operacji częściowych dodawana jest nowa liczba powstała z połączenia składników rightHandNumbers (dla naszego przykładu jest to 23), a całe wyrażenie (1+23, 1-23 1*23, 1/23) dodawane do listy gotowych wyrażeń (linia 21). Następnie rekurencyjnie wołamy metodę CreateExpressions dla każdego elementu partialExpression przekazując rightHandNumbers (liczby 2 i 3). Co się tam wydarzy? Do każdego wyrażenia częściowego zostaną dobudowane kolejne:
1+2+
1+2-
1+2*
1+2/
1-2+
1-2-
….. i tak dalej.
Potem znów zostanie wywołana rekurencyjnie CreateExpressions. I tak do momentu, aż zabraknie składowych po prawej stronie i nie da się przesunąć wskaźnika position w prawo. Przypadek z trzema składowymi jest jeszcze prosty, ale przy większej ich liczbie pojawiają się wyrażenia bardziej złożone, np. 123 + 4 – 56 / 7 * 6 – 89.
public class ExpressionBuilder
{
private List<Expression> Expressions { get; } = new();
private void CreateExpressions(ICollection<int> numbers, Expression? expression = null)
{
var position = 0;
while (position < numbers.Count - 1)
{
++position;
var leftHandNumbers = numbers.Take(position);
var partialExpressions = GetPartialExpressions(leftHandNumbers.ToNumber(), expression);
foreach (var partialExpression in partialExpressions)
{
var newExpression = partialExpression.Clone();
var rightHandNumbers = numbers.Skip(position).ToList();
newExpression.Numbers.Add(rightHandNumbers.ToNumber());
Expressions.Add(newExpression);
CreateExpressions(rightHandNumbers.ToArray(), partialExpression);
}
}
}
private static IEnumerable<Expression> GetPartialExpressions(int number, Expression expression)
{
var newExpressions = new List<Expression>();
foreach (var operation in Enum.GetValues<OperationType>())
{
var newExpression = expression.Clone();
newExpression.Numbers.Add(number);
newExpression.Operations.Add(operation);
newExpressions.Add(newExpression);
}
return newExpressions;
}
}Za każdym razem, kiedy tworzymy nowe wyrażenie, robimy to na podstawie innego, nie do końca zbudowanego wyrażenia częściowego. To częściowe wyrażenie jest bazą dla budowy kilku nowych. Za każdym razem musi być utworzona kompletna kopia włączając kopiowanie typów referencyjnych składających się na wyrażenie (metoda Clone w liniach 18 i 33). Jest to tzw. deep copy Więcej od Microsoftu o kopiowaniu typów. Metoda jest składową typu Expression. Uzupełnijmy zatem listing
public class Expression
{
public List<int> Numbers { get; set; } = new();
public List<OperationType> Operations { get; set; } = new();
public int? CalculatedResult { get; set; }
public bool ErrorOccured { get; set; }
public Expression Clone()
{
var clone = (Expression)MemberwiseClone();
clone.Numbers = new List<int>(Numbers);
clone.Operations = new List<OperationType>(Operations);
return clone;
}
}Ponadto używana jest medoda ToNumber rozszerzająca typ IEnumerable<int>. To mała metoda pomocnicza do konkretnego zastosowania. Nie ma sensu tworzyć jej generycznego odpowiednika, choć byłoby to możliwe.
public static class Extensions
{
public static int ToNumber(this IEnumerable<int> numbers)
{
return int.Parse(string.Concat(numbers));
}
}Listing klasy ExpressionBuilder powyżej jest nieco uproszczony, aby nie zaciemniać podstawowego kodu tworzącego wyrażenie. Ale dla pełnego obrazu przytaczam całość z komentarzami w punktach:
- Zdefiniowana jest tu metoda GetExpressions, która pozwala nasz obiekt poprosić o wyniki. Osobiśnie wolę takie podejście, niż wydawać polecenie
expressionBuilder.CreateExpressions(), a potem odczytywać właściwośćExpressions. Niech procedura wykonania zadania pozostanie wewnętrzną sprawą obiektu. My prosimy tylko o dostarczenie wyników. - Linia 13 rozwiązuje przypadek brzegowy, kiedy przekazywana kolekcja nie zawiera żadnej liczby.
- Sprawdzamy, czy to jest pierwsza iteracja (linia 15) i wtedy dodajemy do wyników wyrażenie złożone z połączonych liczb nie zawierające żadnej operacji. Dodatkowo sprawdzany jest warunek brzegowy, czy przekazana kolekcja zawiera pojedynczą liczbę. Jeśli tak, to jest ona jedynym składniniej wyrażenia i metoda kończy pracę (linia 20).
- Piersze wywołanie
GetPartialExpressions(przy pierwszej iteracji) przekazuje nowy 'pusty’ obiektExpression(linia 31).
public class ExpressionBuilder
{
private List<Expression> Expressions { get; } = new();
public List<Expression> GetExpressions(ICollection<int> numbers)
{
CreateExpressions(numbers);
return Expressions;
}
private void CreateExpressions(ICollection<int> numbers, Expression? expression = null)
{
if (!numbers.Any()) return;
if (expression is null)
{
var newExpression = new Expression();
newExpression.Numbers.Add(numbers.ToNumber());
Expressions.Add(newExpression);
if (numbers.Count == 1) return;
}
var position = 0;
while (position < numbers.Count - 1)
{
++position;
var leftHandNumbers = numbers.Take(position);
var partialExpressions = GetPartialExpressions(leftHandNumbers.ToNumber(),
expression ??= new Expression());
foreach (var partialExpression in partialExpressions)
{
var newExpression = partialExpression.Clone();
var rightHandNumbers = numbers.Skip(position).ToList();
newExpression.Numbers.Add(rightHandNumbers.ToNumber());
Expressions.Add(newExpression);
CreateExpressions(rightHandNumbers.ToArray(), partialExpression);
}
}
}
private static IEnumerable<Expression> GetPartialExpressions(int number, Expression expression)
{
var newExpressions = new List<Expression>();
foreach (var operation in Enum.GetValues<OperationType>())
{
var newExpression = expression.Clone();
newExpression.Numbers.Add(number);
newExpression.Operations.Add(operation);
newExpressions.Add(newExpression);
}
return newExpressions;
}
}Na koniec uzupełniamy obiekt Expression naszą wersją metody ToString. Będzie potrzebna przy drukowaniu wyników, kiedy będziemy chcieli pokazać każde wyrażenie w postaci czytelnej dla człowieka.
public class Expression
{
// tu kod z poprzedniego listingu klasy Expression
public override string ToString()
{
if (!Operations.Any()) return Numbers.FirstOrDefault().ToString();
var sb = new StringBuilder();
var position = 0;
while (position < Operations.Count)
{
sb.Append(Numbers[position]);
switch (Operations[position])
{
case OperationType.Add:
sb.Append(" + ");
break;
case OperationType.Substract:
sb.Append(" - ");
break;
case OperationType.Multiplicate:
sb.Append(" * ");
break;
case OperationType.Divide:
sb.Append(" / ");
break;
}
++position;
}
sb.Append(Numbers.Last());
return sb.ToString();
}
}To na razie tyle. W następnym wpisie zbudujemy kalkulator, który policzy nam wartość wyrażenia z zachowaniem kolejności działań.
Zainspirowany pewną zagadką, którą otrzymałem na interview napisałem aplikację, która pozwoli obniżyć poziom stresu przynajmniej do momentu, aż rekturerzy znajdą ten wpis i wymyślą inne zagadki 🙂 Zresztą spotkałem się w takim zadankiem już wcześniej, więc tym bardziej zachciałem sprawę ogarnąć. Otóż jest pewna kategoria zabaw matematycznych, która polega na tym, że z kilku cyfr zestawia się równanie, które jest „niedokończone”. Należy pogrupować cyfry i powstawiać między nie operatory +, -, *, / aby równanie było prawdziwe. Przykład:
4 4 4 4 4 = 5 5
Rozwiązania:
44 / 4 - 4 / 4 = 5 + 544 + 44 / 4 = 5544 / 4 + 44 = 55
Albo takie cyferki:
1 2 3 4 = 5 6
Można powiązać następująco:
1 - 2 * 3 + 4 = 5 - 61 + 2 * 3 + 4 = 5 + 61 - 2 + 3 * 4 = 5 + 612 + 3 - 4 = 5 + 6
Nie umniejszając zaletom łamania głowy przy wynajdowaniu rozwiązań, ja postanowiłem do sprawy podejść programistycznie. Powstała aplikacja, która znajduje wszystkie możliwe rozwiązania dla danych zestawów liczb. Można się nią posłużyć przy rozstrzyganiu zakładów o to, ile jest możliwych rozwiązań, tworzeniu zagadek (to dla rekruterów), albo ich rozwiązywaniu (aplikanci). Programik jest dostępny tu Play With Numbers (uwaga: Azure Apps w bezpłatnej wersji potrafią 'zamrozić’ aplikację, a jest przywracanie trwa nawet 3-4 minuty). W kolejnych wpisach blogu poznany algorytm, który program napędza.
Duże jest na początku małe. Potem rośnie. Dotyczy to także oprogramowania. Zanim zrobię „dużą” bazę, to czasem mam potrzebę wykonania prototypu. Nie musi on odzwierciedlać wszystkich encji, ale te których powiązania chcę sprawdzić. Po co tak? W przypadku Entity Framework, warto popatrzeć, co wyprodukuje framework czy to z modeli i 'annotations’, czy z kodu fluent api. To nie jest to T-SQL, gdzie widzę, co piszę 😉 Dlatego szybki prototyp się przydaje. W tym celu robię sobie migrację i aplikuję ją do bazy. Wtedy widać jaka będzie finalna postać moich zamierzeń. Mogę ją też przetestować.
Nie przedłużając, bo to ma być szybki prototyp a pizza już jest w piekarniku, zabieramy się do pracy 😉
Robimy sobie folder na naszą aplikację, w której utworzymy modele encji. Zakładam, że .NET SDK i MSSQL Server mamy zainstalowane. Potrzebne też będzie narzędzie dotnet-ef. Kto ma, to pomija pierwsze polecenie.
dotnet tool install --global dotnet-ef
md Alamakota
cd Alamakota
donet new console
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.DesignŚrodowisko testowe mamy gotowe. Teraz przyszedł czas na obiekt, który będzie testowany. Otwieramy projekt w ulubionym IDE i dodajemy DbContext.
using Alamakota.Entities;
using Microsoft.EntityFrameworkCore;
namespace Alamakota;
public class ApplicationContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Data Source=.\SQLEXPRESS;Initial Catalog=ala-test;Integrated Security=True;Trust Server Certificate=Yes");
}
}To sprawdźmy, czy wszystko jest w porządku? Poniższa komenda powinna zbudować aplikację i zwrócić wynik:C:\Users\marek\Alamakota> dotnet ef migrations list Build started…
Build succeeded.
No migrations were found.
Fakt, nie mamy jeszcze migracji, ani bazy, ale wiemy, że aplikacja się buduje i mamy połączenie z serwerem. To czas na jakieś modele, np.
namespace Alamakota.Entities;
public class Person
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual ICollection<Friendship>? Friendships { get; set; }
}
public class Friendship
{
public int Id { get; set; }
public virtual ICollection<Person>? Persons { get; set; }
}Uzupełniamy ApplicationContext dodając poniżej metody OnConfiguring obiekty DbSet<> i inicjalizujemy bazę danymi.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>().HasData(new Person { Id = 1, Name = "Ala" });
modelBuilder.Entity<Person>().HasData(new Person { Id = 2, Name = "Bolek" });
modelBuilder.Entity<Person>().HasData(new Person { Id = 3, Name = "Lolek" });
}
public DbSet<Person> Persons { get; set; } = null!;
public DbSet<Friendship> Friendships { get; set; } = null!;Następnie tworzymy pierwszą migrację oraz aplikujemy ją do bazy.C:\Users\marek\Alamakota> dotnet ef migrations add initial
C:\Users\marek\Alamakota> dotnet ef database update
Serwer powinien pokazać nową bazę i tabele.

I to by było w zasadzie wszystko, co istotne. Mamy bazę, której strukturę chcieliśmy sprawdzić. Dla mnie najważniejszy jest plik ApplicationContextModelSnapshot.cs który Entity Framework utworzył w folderze Migrations. W nim widzę, jak framework przełożył mój kod na polecenia fluent api, które są odzwierciedlane w bazie danych. Trzeba jednak pamiętać, że ten kod, nie pokazuje w 100% tego co się wydarzy w bazie. Framework posługuje się pewnymi ustawieniami domyślnymi. W kodzie fluent api nie wszystko jest uwidocznione. Np. domyślnie na kluczu głównym zakładany jest indeks klastrowy, a w kodzie tego nie widać. Zawsze jednak można sobie z poziomu MSSMS wygenerować skrypt odtwarzający interesujący element, np. tabelę. Tam już nic nam nie umknie 🙂
Na zakończenie wisienka na torcie. Sprawdźmy, że nasz DbContext sięgnie dane z bazy. Zamieniamy zawartość Program.cs na następującą:
using Alamakota;
using var dbContext = new ApplicationContext();
var persons = dbContext.Persons;
foreach (var person in persons)
{
Console.WriteLine(person.Name);
}I po wydaniu komendy dotnet run powinniśmy zobaczyć:AlaBolekLolek
Pizza gotowa. To już wszystko na dziś 🙂
Na początek słowo wyjaśnienia. W tytule nie chodzi o Razor jako Razor Pages – wzorzec aplikacyjny, który Microsoft wprowadził przed ASP.NET MVC. Tu mam na myśli silnik Razor generujący kod html. Ten silnik jest używany w ASP.NET MVC do generowania strony z komponentów Razor. Skoro już to wyjaśniłem, to teraz gładko przechodzimy do meritum 😉
W aplikacjach webowych stronicowanie (ang. paging) przydaje się, kiedy szczodry serwer chce zasypać użytkownika duża ilością danych. Dużą, to jest taką, że wyświetlenie ich na raz mogło by tegoż użytkownika zniechęcić do zapoznania się z nimi. O ile jest to lista przyjętych na Wydział Informatyki UW, to pal sześć. Ale jeśli nasze dane to sklep internetowy z ofertą tysięcy oprawek do okularów (kto wybierał oprawki przez Internet, to wie o czym mówię ;), to aspektowi user experience trzeba poświęcić więcej uwagi. Aby strona nie była niczym zwój papirusu, to całą listę dzieli się na mniejsze kawałki i wyświetla w porcjach w nadziei, że klient łatwiej to zniesie. Stronicowanie ma też swoją praktyczną stronę. Pomaga ograniczać obciążenie łącza i źródła danych, bo kwerenda paginacji zwraca tylko tyle rekordów, ile mamy zamiar wyświetlić na stronie i ani jednego więcej.
ASP.NET Core nie oferowało paginacji „z pudełka”. I chyba nadal nie oferuje, bo samouczek Microsoftu dla .NET7 nadal pokazuje własną implementację. Jest ona o tyle pouczająca, co bardzo nieużyteczna. Implementacja w takiej postaci w aplikacji webowej oznacza każdorazowo modyfikację kodu z wielu miejscach. Dlatego proponuję wykonać pracę raz, ale w taki sposób, aby łatwo ją zastosować do dowolnej kolekcji. Wykonamy własny tag helper. Do wykorzystanie go w widoku, będzie potrzebna jedna linijka kodu.
Na początek potrzebujemy reprezentacji nawigacji do naszej listy. Będzie ona zawierała właściwości potrzebne do generowania klawiszy nawigacji.
public interface IPagination
{
int CurrentPage { get; }
int TotalPages { get; }
int PageSize { get; }
int TotalCount { get; }
bool HasPrevious { get; }
bool HasNext { get; }
}Następnie zdefiniujemy obiekt listy. Generyczny parametr będzie można zastąpić typem odpowiednim w konkretnej aplikacji (np. GlassesFrame). Tu będzie logika odpowiedzialna za ustawienie wartości właściwości paginacji. PagedList jak na przyzwoity obiekt przystało sam zadba o utworzenie egzemplarza (linia 19, metoda Create()). Metoda bierze parametry strony (numer i ilość rekordów) oraz źródło danych jako IQueryable<T>. Interfejs IQueryable powinien być tak zaimplementowany przez Data Provider, żeby kwerenda była generowana dopiero po zdefiniowaniu zapytania Linq i wywołaniu polecenia materializacji wyników (linia 21, polecenie Count()). Oznacza to, że Linq-to-SQL przeanalizuje wyrażenie i nie wykona przeliczenia kolekcji ściągniętej z serwera, tylko zoptymalizuje zapytanie. To serwer SQL policzy, ile mamy różnych oprawek do okularów i zwróci wartość skalarną.
Napisałem „powinno”, bo to nie my mamy kontrolę nad implementacją interfejsu, a dostawca Data Providera. My możemy tylko liczyć, że kontrakt (interfejs) będzie wykonany. Spokojnie, jeśli to będzie Entity Framework, to nie mamy czego się obawiać ;). Z wywodu można wysnuć wniosek (prawidłowy), że nie ma jednego Linq. Linq zależy od źródła danych, na którym operuje wyrażenie.
To jest klasa PagedList
using System.Collections.Generic;
using System.Linq;
public class PagedList<T> : List<T>, IPagination where T: class
{
public int CurrentPage { get; }
public int TotalPages { get; }
public int PageSize { get; }
public int TotalCount { get; }
public bool HasPrevious => CurrentPage > 1;
public bool HasNext => CurrentPage < TotalPages;
private PagedList(IEnumerable<T> items, int count, int pageNumber, int pageSize)
{
TotalCount = count;
PageSize = pageSize;
CurrentPage = pageNumber;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
AddRange(items);
}
public static PagedList<T> Create(IQueryable<T> source, int pageNumber, int pageSize)
{
var count = source.Count();
var items = source
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
return new PagedList<T>(items, count, pageNumber, pageSize);
}
}Było już o tym, że obiekt PagedList, sam policzy sobie wartości potrzebne do generowania stronicowania (CurrentPage, HasPrevious itd). Wrócimy do tego, skąd bierze potrzebne do tego informacje? Na razie wiemy, że przechowuje swój stan. Skoro tak, to możemy użyć go do przygotowania customowego Tag helpera. Robi się to w obiekcie dziedziczącym po abstrakcyjnej klasie TagHelper.
W aplikacjach MVC dużą rolę odgrywają konwencje. Dotyczy to także mapowania atrybutów tagu z kodzie html na właściwości obiektu tag helpera. Nie ma silnego typowania, różne języki, trzeba sobie jakoś radzić… Wywołanie tag helpera będzie wyglądało tak:
<paging pagination="@Model" first-page-text="Pierwsza" last-page-text="Ostatnia" controller="FramesController" action="GetFrames"></paging>
Obiekt tag helpera (z grubsza, bo wyciąłem to co w tym momencie zbędne) wygląda tak, jak niżej. Łatwo się zorientować, że nazwa tagu to pierwszy człon nazwy obiektu PagingTagHelper, a atrybuty są mapowane na właściwości. W ten sposób kiedy proces renderujący stronę natrafi na tag <paging>, to utworzy instancję PagingTagHelper i zainicjuje właściwości wartościami atrybutów tagu.
public class PagingTagHelper : TagHelper
{
public IPagination Pagination { get; set; }
public string FirstPageText { get; set; } = "First";
public string LastPageText { get; set; } = "Last";
public string Controller { get; set; }
public string Action { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
BuildMainTag(output);
BuildFirstLastPageButton(FirstPageText, 1, Pagination.CurrentPage == 1, output);
BuildFirstLastPageButton(@"«", Pagination.CurrentPage - 1, Pagination.CurrentPage == 1, output);
BuildPageButtons(Pagination.CurrentPage, Pagination.TotalPages, output);
BuildFirstLastPageButton(@"»", Pagination.CurrentPage + 1, Pagination.CurrentPage == Pagination.TotalPages, output);
BuildFirstLastPageButton(LastPageText, Pagination.TotalPages, Pagination.CurrentPage == Pagination.TotalPages, output);
}
}Potem uruchamiana jest metoda Process(), ktora w kolejnych krokach buduje obiekt TagHelperOutput. Jeden z tych kroków pokazuje listing poniżej. Obiekt TagHelperOutput zawiera właściwość TagHelperContent:Content, która z kolei ma metodę rozszerzającą AppendHtml(). Budowanie htmla polega na wywoływaniu metody AppendHtml() przyjmującej enkodowany html. Metodę AppendHtml() wywołuje się wielokrotnie 'doklejając’ kawałki htmla. Na zakończenie metoda Process() zwraca wynik do silnika Razor. W ten sposób kolejno budowane są klawisze nawigacji.
private void BuildPageButtons(int pageNumber, int totalPages, TagHelperOutput output)
{
if (totalPages < 8)
{
for (var i = 1; i <= totalPages; i++)
{
BuildPageButton(pageNumber, i, output);
}
}
else
{
BuildSpacerButton(Pagination.HasPrevious, output);
for (var i = Math.Max(1, pageNumber - 2); i <= Math.Min(totalPages, pageNumber + 2); i++)
{
BuildPageButton(pageNumber, i, output);
}
BuildSpacerButton(Pagination.HasNext, output);
}
}Powyżej jest kod jednej z prywatnych metod naszego tag helpera, pokazująca jak budowany jest kod html. Metoda generuje przyciski z numerami stron 1, 2 do 7 Jeśli stron jest więcej niż 7, to widać tylko 7 numerowanych klawiszy i symbol …
Efekt wygląda tak:


Klasa PaginationTagHelper wyposażona jest oczywiście w konstruktor i przeznaczona do pracy z kontenerem Dependency Injection. LinkGenerator jest dostępny automatycznie i udostępniany w przestrzeni nazw Microsoft.AspNetCore.Routing. Natomiast domyślną implementację IHttpContextAccessor należy zarejestrować services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
Tag helper trzeba zaimportować do widoków dyrektywą @addTagHelper (najczęściej robi się to w pliku '_ViewImports.cshtml’).
Cały kod jest poniżej:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;
public class PaginationTagHelper : TagHelper
{
private IPagination Pagination { get; set; }
public string FirstPageText { get; set; } = "First";
public string LastPageText { get; set; } = "Last";
public string Controller { get; set; }
public string Action { get; set; }
private readonly LinkGenerator _linkGenerator;
private readonly IHttpContextAccessor _contextAccessor;
public PaginationTagHelper(LinkGenerator linkGenerator, IHttpContextAccessor contextAccessor)
{
_linkGenerator = linkGenerator;
_contextAccessor = contextAccessor;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
BuildMainTag(output);
BuildFirstLastPageButton(FirstPageText, 1, Pagination.CurrentPage == 1, output);
BuildFirstLastPageButton(@"«", Pagination.CurrentPage - 1, Pagination.CurrentPage == 1, output);
BuildPageButtons(Pagination.CurrentPage, Pagination.TotalPages, output);
BuildFirstLastPageButton(@"»", Pagination.CurrentPage + 1, Pagination.CurrentPage == Pagination.TotalPages, output);
BuildFirstLastPageButton(LastPageText, Pagination.TotalPages, Pagination.CurrentPage == Pagination.TotalPages, output);
}
private static void BuildMainTag(TagHelperOutput output)
{
output.TagName = "ul";
output.Attributes.Add("class", "pagination");
output.Attributes.Add("aria-label", "Stronicowanie");
}
private void BuildFirstLastPageButton(string pageText, int pageNumber, bool disabled, TagHelperOutput output)
{
var li = new TagBuilder("li");
li.Attributes.Add("class", disabled ? "page-item disabled" : "page-item");
li.Attributes.Add("aria-label", pageText);
li.TagRenderMode = TagRenderMode.StartTag;
output.Content.AppendHtml(li);
var path = _linkGenerator.GetPathByAction(_contextAccessor.HttpContext, Action, Controller, new {pageNumber, pageSize = Pagination.PageSize});
var link = $@"<a class=""page-link"" href=""{path}"">{pageText}</a>";
output.Content.AppendHtml(link);
output.Content.AppendHtml("</li>");
}
private void BuildPageButtons(int pageNumber, int totalPages, TagHelperOutput output)
{
if (totalPages < 8)
{
for (var i = 1; i <= totalPages; i++)
{
BuildPageButton(pageNumber, i, output);
}
}
else
{
BuildSpacerButton(Pagination.HasPrevious, output);
for (var i = Math.Max(1, pageNumber - 2); i <= Math.Min(totalPages, pageNumber + 2); i++)
{
BuildPageButton(pageNumber, i, output);
}
BuildSpacerButton(Pagination.HasNext, output);
}
}
private void BuildPageButton(int pageNumber, int buttonNumber, TagHelperOutput output)
{
var li = new TagBuilder("li");
li.Attributes.Add("class", buttonNumber == pageNumber ? "page-item active" : "page-item");
li.Attributes.Add("aria-label", buttonNumber.ToString());
li.TagRenderMode = TagRenderMode.StartTag;
output.Content.AppendHtml(li);
var path = _linkGenerator.GetPathByAction(_contextAccessor.HttpContext, Action, Controller, new {pageNumber = buttonNumber, pageSize = Pagination.PageSize});
var link = $@"<a class=""page-link"" href=""{path}"">{buttonNumber}</a>";
output.Content.AppendHtml(link);
output.Content.AppendHtml("</li>");
}
private static void BuildSpacerButton(bool isVisible, TagHelperOutput output)
{
const string span = @"<span class=""page-link"">...</span>";
if (!isVisible) return;
var li = new TagBuilder("li");
li.Attributes.Add("class", "page-item disabled");
li.Attributes.Add("aria-hidden", bool.TrueString);
li.TagRenderMode = TagRenderMode.StartTag;
output.Content.AppendHtml(li);
output.Content.AppendHtml(span);
output.Content.AppendHtml("</li>");
}
}To już prawie koniec, ale cały czas nie mamy danych, które będą stronicowane. To niedopatrzenie rozwiązuje się w warstwie aplikacji odpowiedzialnej za persystencję danych. Tam budujemy PagedList w oparciu o dane z kontrolera (pageSize, pageNumber) i dane z bazy.
public PagedList<GlassesFrame> GetFrames(int pageSize, int pageNumber)
{
var frames= _productManager.GlassesFrames
.Include(x => x.GlassesFramesSizes)
.ThenInclude(x => x.Sizes);
return PagedList<GlassesFrame>.Create(frames, pageNumber, pageSize);
}Taki tag helper jest w pełni funkcjonalny. Wrożenie jest błyskawiczne. Potrzebny jest plik z kodem interfejsu i klasy PagedList oraz plik z klasą PaginationTagHelper oraz rejestracja jednego serwisu i zaimportowanie tag heplera. Wykorzystanie w kodzie to jedna linijka przytoczona już wyżej w tekście.
Miłego kodzenia 🙂
Repozytorium z przykładową aplikacją wykorzystującą PaginationTagHelper: github
Dziś o kopiowaniu. Tyle, że nie o kopiowaniu czyjegoś kodu, czego nie zalecam robić bez zrozumienia, bo można sobie/klientowi/pracodawcy zrobić krzywdę. Będzie o kopiowaniu plików. Kopiowanie plików jak wiadomo nie jest operacją, przez którą mielibyśmy nie spać. NET zapewnia zgrabne metody w statycznych klasach File czy Directory. Dodajemy sobie przestrzeń System.IO i już możemy kopiować do woli.
File.Copy("stąd", "tam")Jeśli chcemy być bardziej profi, a na naszym pliku wykonujemy więcej niż jedną operację, na przykład chcemy do jakiegoś pliku dopisywać nową linijkę upamiętniającą nowego SMSa, którego Romek napisał do Ali, to możemy utworzyć obiekt new FileInfo("smsy-romka.txt") i używać go do tego celu. Będzie się to odbywało nieco szybciej i fajniej (czyt. profesjonalnie).
Schody zaczną się, kiedy zamiast tekstowych SMSów będziemy chcieli skopiować zdjęcia ważące po kilka MB jedno, które Romek zrobił na spacerze, a miał Romek dużą kartę pamięci i trochę czasu. Wtedy może to potrwać dłużej. Całkiem nam się humor popsuje, jeśli pliki trzeba będzie przesyłać przez sieć, nie w obrębie jednej maszyny. Wtedy najwolniejszym ogniwem nie będzie nasz dysk, tylko wydajność sieci, a z tą bywa różnie.
Takim kopiowaniem, jak pokazałem powyżej zablokujesz wątek. Jeśli jest to wątek UI, to aplikacja stanie się „trudna w kontakcie” lub inaczej mówiąc nie responsywna i może się okazać, że nawet nie da się jej zamknąć (WinForms). Sprytnie możesz przekazać taki przydługi proces do wykonania w tle w wątku pobranym z puli, a wątek główny może w tym czasie wykonywać inne zadania, jakie dla niego wymyślimy, albo obsługiwać interfejs użytkownika.
var copyTask = Task.Run(() => File.Copy("stąd", "tam"));Kopiowanie plików wykonywane przez OS „pod spodem” .NET jest asynchroniczne. Dlatego zatrudnienie nowego wątku niczego nie polepszy, jeśli Task.RunI() zastosujemy w kodzie asynchronicznym. Taki trik ma sens tylko, jeśli znajdzie się w kodzie wykonywanym przez główny wątek aplikacji.
Trzeba poczekać na zakończenie tego Tasku wydając komendę: await copyTask. Po dojściu do await, wątek główny przejdzie do innych zadań. I jeśli jest to aplikacja webowa lub okienkowa, to natychmiast docenimy zalety naszego posunięcia, bo aplikacja przestanie „lagować”. W wypasionej wersji można nawet przekazać CancellationToken, aby skończyć z takim Taskiem, gdyby oczekiwanie znudziło usera i postanowił zamknąć aplikację.
Jest jednak jeden problem. Po zamknięciu aplikacji przed zakończeniem kopiowania system plików zostawi nam w docelowej lokalizacji plik z przypadkową zawartością. Na dodatek zaalokuje miejsce na dysku. Romek nawet nie będzie wiedział, że stracił swoje zdjęcie!
Aby temat ogarnąć tak, żeby wstydu nie było, wypadało by zrobić dwie rzeczy:
- Zadanie kopiowania wykonać asynchronicznie, ale tak, żeby ewentualny shut down aplikcji dał nam czas na zwolnienie zasobów i posprzątanie, zanim ostatecznie aplikacja zostanie zamknięta.
- Posprzątanie pozostałości po przerwanym zadaniu.
Do pierwszego punktu użyjemy klasy FileStream, która zawiera asynchroniczną metodę CopyToAsync. Nie będziemy oryginalni i użyjemy tej metody. Czyli kopiujemy tak:
async Task CopyFileAsync(string sourcePath, string destinationPath)
{
var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
var destinationStream = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write);
await sourceStream.CopyToAsync(destinationStream);
}Kod jest uproszczony, aby był przejrzysty. Pełny działający kod można znaleźć na Githubie. Link znajduje się pod artykułem.
Jest prawie super, ale musimy rozwiązać temat sprzątania po ewentualnym przerwaniu kopiowania. Poprawiona wersja wygląda tak:
async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken)
{
try
{
var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
var destinationStream = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write);
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
}
catch (OperationCanceledException)
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(3));
await Delete(destinationPath, cts.Token);
}
catch (Exception)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(10));
await Delete(destinationPath, cts.Token);
throw;
}
}Po pierwsze, asynchroniczna metoda CopyToAsync przyjmuje CancellationToken, więc korzystamy z tego natychmiast i przekazujemy do niej token (linia 7).
Po drugie, w linii 9 przechwytujemy OperationCanceledException i czas jaki mamy na zamkmięcie procesu wykorzystujemy na skasowanie pliku, który został utworzony w docelowej lokalizacji, ale na pewno będzie uszkodzony, bo przecież kopiowanie zostało przerwane. W tym celu tworzymy nowy token, który daje metodzie Delete() trzy długie sekundy z pięciu jakie defaultowo dostaje aplikacja na zamknięcie (linia 12).
Po trzecie, w przypadku innego błędu także kasujemy plik, który najprawdopodobniej będzie uszkodzony, ale dodatkowo rzucamy wyjątek do kodu wywołującego kopiowanie. Niech go sobie obsłuży (linia 20).
W ten sposób kopiowanie nawet dużych plików może odbywać się bez utraty responsywności aplikacji, a ewentualne błędy nie powodują śmietnika w systemie plików. Kod, który jest na Githubie dodatkowo pilnuje cyklu życia egzemplarzy FileStream. Ma też zaimplementowaną flagę overvrite i ma dodatkową metodę MoveFileAsync.
W każdym kodzie znajdzie się coś do poprawienia. Nie inaczej jest w tym przypadku. Plik po zapisaniu w nowej lokalizacji powinien być zweryfikowany na okoliczność zgodności z oryginałem. Można to zaimplementować przez obliczenie skrótów oryginału i kopii i porównaniu ich wartości. Jeśli nie są zgodne, to coś poszło nie tak i należy taki przypadek obsłużyć.
Uwaga na koniec
Z punktu widzenia aplikacji .net metoda File.Copy wykona się synchronicznie i kod wywołujący File.Copy rzeczywiście na chwilę spauzuje. Jednak operacje I/O są asynchroniczne na poziomie systemu operacyjnego. Co więcej, aplikacja wykonująca się pod kontrolą Windows nawet nie otrzymuje danych do kopiowania. .net przesyła do systemu informacje o pliku i reszta odbywa się już bardzo szybko na poziomie systemu. Zatem możesz spokojnie stosować synchroniczne komendy np. File.Copy w swoim kodzie.
Kiedy wobec tego stosować kopiowanie asynchroniczne?
Roważ to rozwiązanie kiedy
- pliki są duże
- aplikacja ma UI i obsługa kopiowania może wpłynąć na płynność interfejsu
Przykładowy kod
Kod ilustrujący ten artykuł znajdziesz:
Pełen kod na Githubie