Írjunk FFmpeg frontend-et – 4. rész
Az előző részben a preset betöltésnél és validálásnál hagytuk abba a program készítését. Ebben a részben az alap architektúra kialakításával folytatjuk, de előtte a preset témánál maradva komponenseket készítünk a felhasználói bemenet validálására és konvertálására.
Konverterek és validáció
Mivel a program menet közben a felhasználótól értékeket fog bekérni, elkerülhetetlen, hogy ezeket ellenőrizzük és esetlegesen konvertáljuk. Mivel a programban N darab konvertálóra és validálóra lesz szükségünk, ezért célszerű ezeket egy interfész segítségével leírni. A konverziós interfészünk nem meglepő módon az IConverter nevet kapta. Ez egy bemeneti szöveget alakít át egy másik szöveggé.
namespace FFConvert.Interfaces;
internal interface IConverter
{
string Convert(string input);
}
A validációért az IValidator interfész lesz felelős. Ez már egy picit bonyolultabb. Az általa definiált Validate metódusnak az egyik paramétere maga a szöveg amit ellenőrzünk, a második paramétere pedig egy IDictionary, ami az általa használt paramétereket tárolja kulcs/érték párokban.
namespace FFConvert.Interfaces;
internal interface IValidator
{
(bool status, string errorMessage) Validate(string input, IDictionary<string, string> parameters);
}
A visszatérési értéke egy tuple, ami tartalmazza, hogy sikeres volt-e a validáció vagy sem, illetve egy hibaüzenetet, amit majd ki tudunk írni a felhasználónak.
Kérdés már csak az, hogy a ValidatorParameters tulajdonság szövegéből hogyan is lesznek kulcs/érték párok a konverternek? A paraméterek szintaxisára a következőt találtam ki: param1=ertek1;param2=ertek2. Ez alapján az egyes értékpárok között pontosvessző az elválasztó, míg kulcs és érték között az egyenlőségjel. A szabályrendszer alapján készíthetünk egy extension metódust erre a célra:
using FFConvert.Domain;
namespace FFConvert.DomainServices;
internal static class PresetExtensions
{
public static bool TryGetValidatorParamDictionary(this PresetParameter parameter, out IDictionary<string, string> parameters)
{
try
{
parameters = new Dictionary<string, string>();
if (parameter.ValidatorParameters == null)
{
return true;
}
string[] argumentPairs = parameter.ValidatorParameters.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (string argumentPair in argumentPairs)
{
string[] keyValue = argumentPair.Split('=', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (keyValue.Length == 2)
{
parameters.Add(keyValue[0], keyValue[1]);
}
else
{
throw new InvalidOperationException();
}
}
return true;
}
catch (Exception)
{
parameters = new Dictionary<string, string>();
return false;
}
}
}
Konverterek és validálók betöltése
A konvertáló és validáló osztályainkat manuálisan is példányosíthatnánk, de ez nem a legjobb ötlet. Mégpedig azért, mert akkor valahol ezeket nekünk karban kellene tartanunk és egy újabb konverter vagy validáló hozzáadásakor nem csak az interfészt implementáló osztályt kellene megírnunk, hanem egy központi helyre is fel kellene vennünk nyilvántartásba. Ez értelemszerűen nem túl kényelmes. Éppen ezért készítettem egy ImplementationsOf<T> generikus osztályt.
using FFConvert.Interfaces;
namespace FFConvert.Infrastructure;
internal sealed class ImplementationsOf<T> : IImplementationsOf<T> where T: class
{
private readonly Dictionary<string, T> _implementations;
public ImplementationsOf()
{
var items = typeof(ImplementationsOf<T>)
.Assembly.DefinedTypes
.Where(x => x.ImplementedInterfaces.Contains(typeof(T)) && !x.IsAbstract)
.Select(x => Activator.CreateInstance(x) as T);
_implementations = new Dictionary<string, T>();
foreach (var item in items)
{
if (item != null)
{
_implementations.Add(item.GetType().Name, item);
}
}
}
public int Count => _implementations.Count;
public bool Contains(string name)
{
return _implementations.ContainsKey(name);
}
public T Get(string name)
{
return _implementations[name];
}
}
Ez az osztály lekérdezi a jelenlegi szerelvényben tárolt összes típust és ezek közül megnézi, hogy melyik típus implementálja a T paraméternek megfelelő interfészt. Ha a típus implementálja ezt, akkor egy belső tárban elhelyezésre kerül belőle egy példány.
Ezt a példányt majd név alapján tudjuk lekérdezni, illetve tudjuk ellenőrizni azt is, hogy egyáltalán a megadott nevű osztály betöltésre került-e. Ez majd a konverterek és a validálók ellenőrzésénél lesz használva egy későbbi lépésben.
Az osztály rendelkezik egy IImplementationsOf<T> felülettel is, ami leginkább absztrakciós célokat szolgál majd a későbbiek során.
namespace FFConvert.Interfaces;
internal interface IImplementationsOf<T> where T : class
{
bool Contains(string name);
T Get(string name);
int Count { get; }
}
Oszd meg és uralkodj
Az FFmpeg megfelelő argumentumokkal való elindítását megvalósíthatnánk egy gigantikus monolit osztályként számos metódussal, de már az előző mondat megfogalmazásából sejthető, hogy ez nem a legjobb ötlet. Ennek az az oka, hogy a monolitokkal csak a baj van: nehezen tesztelhetőek, mivel rengeteg belső kapcsolattal rendelkeznek.
Éppen ezért érdemes a komplex feladatot kisebb, egyszerűbb lépésekre osztani. Ilyen osztás megvalósítására kiválóan alkalmas jelen program esetén a chain of responsibility, vagy magyarul felelősséglánc tervezési minta.
A programban nem teljesen a nagykönyvben megírt mintát használtam, hanem helyesen fogalmazva egy „rá hasonlítót”. Ennek az oka az, hogy a klasszikus felelősségláncban az egyes láncszemek tudnak a következő és opcionálisan az azt megelőző lépésről, mint egy láncolt listában. Ezt viszont nem szerettem volna, ezért jelen program esetén a lépések nem tudnak egymásról. Ezek végrehajtásárért majd egy másik komponens fog felelni.
Az egyes lépéseket az alábbi interfész fogja leírni:
using FFConvert.Domain;
namespace FFConvert.Interfaces;
internal interface IStep
{
IEnumerable<string> Issues { get; }
bool CanSkip(State state);
bool TryExecute(State state);
}
Az Issues a lépés futtatása közben keletkezett hibák gyűjteményét fogja tárolni. Maga a TryExecute metódus lesz az, amelyik leírja a lépés fő logikáját. Ha ez igaz értékkel tér vissza, akkor a lépés futtatása sikeres volt. Ez egy jelzés lesz a központi futtató felé, hogy hívja meg a következő lépés TryExecute metódusát. Ha azonban hamis visszatérési értéket kaptunk, akkor valami probléma történt. Ebben az esetben majd az Issues által tárolt hibák gyűjteménye fog kiíródni a képernyőre.
Amennyiben a CanSkip metódus igaz visszatérésű, akkor a lépés kihagyandó.
Feltűnhet, hogy a TryExecute és a CanSkip rendelkezik egy State típusú bemenő paraméterrel. Ez felelős a globális állapot tárolásáért. Ennek a definíciója a következő:
namespace FFConvert.Domain;
internal sealed class State
{
public Preset[] Presets { get; }
public IList<string> InputFiles { get; }
public Preset CurrentPreset { get; set; }
public ProgramConfiguration Configuration { get; }
public Arguments Arguments { get; }
public IList<FFMpegCommand> CreatedCommandLines { get; }
public State(Preset[] presets, ProgramConfiguration configuration, Arguments arguments)
{
Presets = presets;
Arguments = arguments;
Configuration = configuration;
CreatedCommandLines = new List<FFMpegCommand>();
InputFiles = new List<string>();
CurrentPreset = new Preset();
}
}
A központi állapot tárolja az összes beolvasott preset-et (Presets), amiből majd egyet kiválasztunk (CurrentPreset) az argumentumok (Arguments) alapján. A CreatedCommandLines lista pedig tárolja a létrehozott FFmpeg parancsokat. Ennek leírásához egy külön osztályt készítettem, ami a FFMpegCommand nevet viseli. Ez a parancssor mellett tartalmazza a bemeneti fájlt és kimeneti fájlt is.
internal sealed class FFMpegCommand
{
public string CommandLine { get; init; }
public string OutputFile { get; init; }
public string InputFile { get; init; }
public FFMpegCommand()
{
InputFile = string.Empty;
CommandLine = string.Empty;
OutputFile = string.Empty;
}
}
Erre azért volt szükség, hogy a későbbiekben például egy adott lépés előtt tudjuk ellenőrizni, hogy egyáltalán létezik-e még a bemeneti fájl, illetve ha már létezik a kimenet által meghatározott fájl, akkor erre is reagálni tudjunk anélkül, hogy a generált parancsorból próbálnánk kihámozni ezeket az információkat.
Ezen felül az állapot tartalmaz még egy ProgramConfiguration típusú osztályt is. Ez a program globális beállításait írja le. Jelen esetben ebből csak egy darab van még, ez pedig az FFmpeg és ffprobe parancsok mappáját határozza meg. Alapértelmezetten ezeket ugyanabban a mappában keressük, mint ahol a mi programunk lesz, de elképzelhető, hogy ez a végfelhasználó gépén teljesen máshol lesz.
public sealed class ProgramConfiguration
{
public string FFMpegDir { get; set; }
public ProgramConfiguration()
{
FFMpegDir = AppDomain.CurrentDomain.BaseDirectory;
}
}
Az egyes lépések könnyebb implementálása értekében készítettem egy BaseStep ősosztályt, amiből az összes lépésünk fog származni.
using FFConvert.Domain;
using FFConvert.Interfaces;
namespace FFConvert.Steps;
internal abstract class BaseStep : IStep
{
private readonly List<string> _issues;
protected BaseStep()
{
_issues = new List<string>();
}
public IEnumerable<string> Issues => _issues;
protected void AddIssue(string format, params object[] parameters)
{
_issues.Add(string.Format(format, parameters));
}
protected bool AreNoIssues()
{
return _issues.Count == 0;
}
public abstract bool TryExecute(State state);
public virtual bool CanSkip(State state)
{
return false;
}
}
Ez az ősosztály, mint látható, főként a lépés futtatása közben keletkező hibaüzenetek kezelését egyszerűsíti majd le.
Folytatása következik…