Mâinile slobode fac lucrătura Diavolului

Versiunile dedicate mentenanței – scurte, limitate și în general banale – sunt, în ciuda caracterului cenușiu și fastidios, acte de igienă elementară în viața unei aplicații. În primul rând direct, întrucât micile probleme (ce pot ascunde investigații realmente interesante, e drept) produc uneori fricțiuni disproporționat de mari; în al doilea rând, indirect, deoarece oferă ocazia revizitării unor zone de cod ce mai mult ca sigur pot fi îmbunătățite fără contuzii bugetare.

Pe bună dreptate

Pe bună dreptate

Iar dacă un lucru e adevărat, este că există mereu un pic de gunoi sub preș, uneori generat inclusiv (sau mai cu seamă) de ingineri experimentați, știți voi – cei cu naturelul simțitor, ce dau ochii peste cap și îngână citate din cei mai de seamă instalatori neomoderniști: cine v-a lucrat aici, că și-a bătut joc de voi.

Și, deși sunt vădit răutăcios în observația mea, acumularea unor astfel de restanțe este, din varii motive, inevitabilă. Ba că e prea plictisitor un anumit detaliu și este expediat cu silă, ba că un anumit mod de-a face lucrurile a început temporar și-a ajuns drept cutumiar. Bref e că se strâng tot felul de bisericuțe cu turle răsucite ce trebuie chestionate, chit că se cer venerate.

Și cum toți dezvoltatorii, programatorii și inginerii sunt – cel puțin în buletin – tot oameni, iar inteligența artificială este antrenată pe oameni, nimeni nu este complet ferit de atari șobolani tipografici, așa cum foarte frumos a ilustrat Unchiu’ Bob în Clean Code, unde, la finalul cărții, a executat niște operațiuni exemplificatoare de curățenie inclusiv pe propriul cod.

Așadar, am profitat de o modificare trivială în structura unui document – un câmp în plus – pentru a face un pic de curat în structura fișierelor .cshtml ce redau acele două formulare (de creare și de modificare), fiind îndeplinite simultan câteva condiții favorabile:

– eram în zonă;
– aveam niște timp la dispoziție;
– am o considerabilă libertate în alegerea implementării (în limitele bunului simț);
– nu pot invoca exclusiv greaua moștenire, căci am rânit și eu acolo de-a lungul timpului;
– strategia (și timpii) de testare nu ar fi fost afectată.

Vitoria Lipan are dreptate: gunoiul nu se aruncă în fața Soarelui

Problema era una de repetiție consistentă în cadrul a două categorii de câmpuri, recapitulându-și codul de câte cinci ori în același fișier și apoi din nou – cu câteva excepții – între cele două formulare (creare și modificare). Iată un exemplu pentru unul din tipurile de câmpuri, din formularul de modificare:

@if (Model.TypeConfig.UserString5.IsVisible())
{
    <tr>
        <td>
            @(WebHelper.FormLabel(
                $"{Model.TypeConfig.UserString5.DisplayText}:",
                Model.TypeConfig.UserString5.IsRequired()
            ))
        </td>
        <td colspan="2">
            @(Html.Kendo()
                .TextBoxFor(m => m.DocumentRecord.UserString5)
                .HtmlAttributes(new
                {
                    maxlength = ModelConstant
                        .DocumentRecord_UserString_Length,
                    style = "width:100%;",
                    tabindex = 180
                })
                .Enable(
                    Model.TypeConfig.UserString5
                        .IsEditable(isSystemGenerated, 
                            canBeAltered)
                )
            )

            @if (Model.TypeConfig.UserString5
                .IsReadonly(isSystemGenerated, canBeAltered))
            {
                @Html.HiddenFor(m => m.DocumentRecord.UserString5)
            }

            @(Html.ValidationMessageFor(
                m => m.DocumentRecord.UserString5
            ))
        </td>
    </tr>
}
else
{
    @(Html.HiddenFor(m => m.DocumentRecord.UserString5))
}

Excepțiile în cauză sunt în mare legate de testarea posibilității de modificare, în sensul că la creare nu se folosesc cei doi indicatori – isSystemGenerated și canBeAltered  – deci eventuala igienizare a locului trebuie să țină cont de această regulă. Nu o să intru în ce semnifică aceste două variabile, că nu contează.

Așadar, notând țelurile restructurării:

– evident, tunderea codului redundant, astfel încât sunt acceptabile doar două fișiere .cshtml parțiale, câte unul pentru fiecare tip de câmp;
– denumirile și identificatorii câmpurilor – deci atributele name și id – trebuie menținute;
– trebuie păstrată natura type-safe, inclusiv prin utilizarea lambda-urilor pentru selectarea câmpurilor (ex. .TextBoxFor(m => m.DocumentRecord.UserString5));
– view-urile trebuie să ia mai puține decizii.

Prima schiță de soluție

Partea bună e că cele două categorii de câmpuri pot fi susținute identic din punct de vedere logic, deci de un singur view-model, adică o clasă ce încapsulează datele și o parte din deciziile specifice ecranelor.

public class DocumentCustomFieldInputViewModel
{
    public DocumentCustomFieldInputViewModel(
        Expression<
            Func<DocumentCustomFieldInputViewModel, string>
        > fieldSelector,
        DocumentEditorViewModel editorViewModel,
        CustomField fieldConfig,
        int maxLength,
        int tabIndex)
    {
        FieldSelector = fieldSelector
            ?? throw new ArgumentNullException(nameof(fieldSelector));

        EditorViewModel = editorViewModel
            ?? throw new ArgumentNullException(nameof(editorViewModel));

        FieldConfig = fieldConfig
            ?? throw new ArgumentNullException(nameof(fieldConfig));

        MaxLength = maxLength;
        TabIndex = tabIndex;
    }

    public DocumentCustomFieldInputViewModel(
        Expression<
            Func<DocumentCustomFieldInputViewModel, string>
        > fieldSelector,
        DocumentEditorViewModel editorViewModel,
        CustomField fieldConfig,
        bool isSystemGenerated,
        bool canBeAltered,
        int maxLength,
        int tabIndex)
        : this(fieldSelector, 
            editorViewModel, 
            fieldConfig, 
            maxLength, 
            tabIndex)
    {
        IsSystemGenerated = isSystemGenerated;
        CanBeAltered = canBeAltered;
    }

    public bool IsReadOnly() =>
        IsSystemGenerated.HasValue && CanBeAltered.HasValue
            ? FieldConfig?.IsReadonly(
                IsSystemGenerated.Value,
                CanBeAltered.Value
            ) ?? false
            : FieldConfig?.IsReadonly() ?? false;

    public bool IsEditable() =>
        IsSystemGenerated.HasValue && CanBeAltered.HasValue
            ? FieldConfig?.IsEditable(
                IsSystemGenerated.Value,
                CanBeAltered.Value
            ) ?? false
            : FieldConfig?.IsEditable() ?? false;

    public bool IsRequired() => FieldConfig?.IsRequired() ?? false;

    public bool IsVisible() => FieldConfig?.IsVisible() ?? false;

    public DocumentEditorViewModel EditorViewModel { get; }

    public DocumentRecordViewModel DocumentRecord =>
        EditorViewModel?.DocumentRecord;

    public Expression<
        Func<DocumentCustomFieldInputViewModel, string>
    > FieldSelector { get; }

    public CustomField FieldConfig { get; }

    public string DisplayText => FieldConfig?.DisplayText;

    public bool? IsSystemGenerated { get; set; }

    public bool? CanBeAltered { get; set; }

    public int MaxLength { get; set; }

    public int TabIndex { get; set; }
}

Alimentează cu date proaspăt coapte două fișiere .cshtml, fiecare specific unei categorii. Pentru a păstra denumirile câmpurilor și simultan folosirea notației lambda pentru selectarea proprietăților pentru care se generează acele câmpuri nu este musai să păstrăm același view-model, dar trebuie să păstrăm aceeași cale de selecție:

m => m.DocumentRecord.UserString5

De unde și necesitatea expunerii proprietății DocumentRecord. Simultan, am încapsulat utilizarea celor doi indicatori astfel încât ecranele parțiale (partial views) să poată fi folosite în ambele scenarii. Și, în general, am păstrat un singur nivel de adresare, evitând apelurile înlănțuite: adică A.C, în loc de A.B.C, fiind treaba lui A cum se descurcă. Ecranul parțial arată așa:

@model DocumentCustomFieldInputViewModel

@if (Model.IsVisible())
{
    <tr>
        <td>
            @(WebHelper.FormLabel(
                $"{Model.DisplayText}:",
                Model.IsRequired()
            ))
        </td>
        <td colspan="2">
            @(Html.Kendo()
                .TextBoxFor(Model.FieldSelector)
                .HtmlAttributes(new
                {
                    maxlength = Model.MaxLength,
                    style = "width:100%;",
                    tabindex = Model.TabIndex
                })
                .Enable(Model.IsEditable())
            )

            @if (Model.IsReadOnly())
            {
                @Html.HiddenFor(Model.FieldSelector)
            }

            @(Html.ValidationMessageFor(Model.FieldSelector))
        </td>
    </tr>
}
else
{
    @(Html.HiddenFor(Model.FieldSelector))
}

Iată și un exemplu de utilizare, în prima sa versiune:

@Html.Partial(
    "~/Views/Documents/_UserStringField.cshtml",
    new DocumentCustomFieldInputViewModel(
        m => m.DocumentRecord.UserString6,
        Model,
        Model.TypeConfig.UserString6,
        isSystemGenerated,
        canBeAltered,
        ModelConstant.DocumentRecord_UserString_Length,
        190
    )
)

Mai e puțin praf pe șifonier

Este, în mod cert, mult mai bine, dar prezintă umflături în continuare și provoacă un deranjant scrâșnet de măsele. În primul rând trebuie să repeți calea fișierului; în al doilea rând, sunt valori ce pot foarte bine să fie transmise implicit (fie că sunt specifice categoriei de câmp – ex. ModelConstant.DocumentRecord_UserString_Length, fie în general – Model); în al treilea rând, secvența de construcție a view-model-ului ar putea foarte bine să nu existe.

Pe scurt, așa ceva ar fi dezirabil:

@Html.UserStringFieldFor(m => m.DocumentRecord.UserString6)
    .WithFieldConfig(Model.TypeConfig.UserString6)
    .IsSystemGenerated(isSystemGenerated)
    .CanBeAltered(canBeAltered)
    .TabIndex(190);

S-o luăm puțin la pilă

Pentru înfăptuirea acestui ideal, avem nevoie de puțină sârmă, cositor și pastă decapantă:

– un set de clase constructoare auxiliare pentru a putea colecta datele într-o manieră fluentă, cu apeluri decurgând unul din celălalt;
– un set de metode extensie pentru obiectul Html (view-helpers).

Clasele auxiliare reprezintă o ierarhie simplă cu una la bază și câte una pentru fiecare tip de câmp, ce gestionează valorile implicite (calea către fișierul .cshtml, lungimea maximă). Totodată, pentru a putea fi redate implicit folosind notația @, trebuie să implementeze IHtmlString. Avem, deci, clasa de bază:

public abstract class DocumentCustomFieldBuilderBase<TBuilder> 
    : IHtmlString where TBuilder 
        : DocumentCustomFieldBuilderBase<TBuilder>
{
    private readonly HtmlHelper<DocumentEditorViewModel> _htmlHelper;
    
    private readonly Expression<
        Func<DocumentCustomFieldInputViewModel, string>
    > _fieldSelector;
    
    private readonly string _partialPath;

    protected DocumentEditorViewModel _editorViewModelValue;    
    
    protected CustomField _fieldConfigValue;
    
    protected bool? _isSystemGeneratedValue;
    
    protected bool? _canBeAlteredValue;
    
    protected int _maxLengthValue;
    
    protected int _tabIndexValue;

    protected DocumentCustomFieldBuilderBase(
        HtmlHelper<DocumentEditorViewModel> htmlHelper,
        Expression<
            Func<DocumentCustomFieldInputViewModel, string>
        > fieldSelector,
        string partialPath)
    {
        _htmlHelper = htmlHelper
            ?? throw new ArgumentNullException(nameof(htmlHelper));

        _fieldSelector = fieldSelector
            ?? throw new ArgumentNullException(nameof(fieldSelector));

        _partialPath = partialPath
            ?? throw new ArgumentNullException(nameof(partialPath));
    }

    public TBuilder BindTo(DocumentEditorViewModel editorViewModel)
    {
        _editorViewModelValue = editorViewModel
            ?? throw new ArgumentNullException(nameof(editorViewModel));

        return (TBuilder)this;
    }

    public TBuilder WithFieldConfig(CustomField fieldConfig)
    {
        _fieldConfigValue = fieldConfig
            ?? throw new ArgumentNullException(nameof(fieldConfig));

        return (TBuilder)this;
    }

    public TBuilder IsSystemGenerated(bool isSystemGenerated)
    {
        _isSystemGeneratedValue = isSystemGenerated;
        return (TBuilder)this;
    }

    public TBuilder CanBeAltered(bool canBeAltered)
    {
        _canBeAlteredValue = canBeAltered;
        return (TBuilder)this;
    }

    public TBuilder MaxLength(int maxLength)
    {
        _maxLengthValue = maxLength;
        return (TBuilder)this;
    }

    public TBuilder TabIndex(int tabIndex)
    {
        _tabIndexValue = tabIndex;
        return (TBuilder)this;
    }

    public MvcHtmlString Render()
    {
        if (_editorViewModelValue == null)
            throw new InvalidOperationException("Missing model.");

        if (_fieldConfigValue == null)
            throw new InvalidOperationException("Missing config.");

        DocumentCustomFieldInputViewModel viewModel = 
            BuildViewModel();

        return _htmlHelper.Partial(_partialPath, 
            viewModel);
    }

    protected virtual DocumentCustomFieldInputViewModel BuildViewModel()
    {
        if (_isSystemGeneratedValue.HasValue 
            && _canBeAlteredValue.HasValue)
        {
            return new DocumentCustomFieldInputViewModel(
                _fieldSelector,
                _editorViewModelValue,
                _fieldConfigValue,
                _isSystemGeneratedValue.Value,
                _canBeAlteredValue.Value,
                _maxLengthValue,
                _tabIndexValue
            );
        }

        return new DocumentCustomFieldInputViewModel(
            _fieldSelector,
            _editorViewModelValue,
            _fieldConfigValue,
            _maxLengthValue,
            _tabIndexValue
        );
    }

    public string ToHtmlString()
    {
        return Render().ToHtmlString();
    }
}

Parametrul Expression<Func<DocumentCustomFieldInputViewModel, string>> captează forma lambda ce desemnează proprietatea pentru care va fi generat câmpul, BuildViewModel() asigură construcția corectă între cele două variante de utilizare, iar metoda explicită Render() rămâne utilă, pentru cazurile în care vrem să fim mai clari sau să controlăm momentul redării conținutului (ceea ce probabil niciodată, dar ITiștii au mereu motivații pompoase pentru apucături aleatorii).

În fine, iată și una din cele două clase specializate:

public class DocumentUserStringFieldBuilder
    : DocumentCustomFieldBuilderBase<DocumentUserStringFieldBuilder>
{
    private const string PartialPath =
        "~/Views/Documents/_UserStringField.cshtml";

    public DocumentUserStringFieldBuilder(
        HtmlHelper<DocumentEditorViewModel> htmlHelper,
        Expression<
            Func<DocumentCustomFieldInputViewModel, string>
        > fieldSelector)
        : base(htmlHelper, fieldSelector, PartialPath)
    {
        _maxLengthValue = ModelConstant
            .DocumentRecord_UserString_Length;
    }
}

Unde merge sârmă, e păcat să pui șurub

Cu tot eșafodajul descris și construit până acum, extensiile pentru Razor sunt pur și simplu un punct de inițializare foarte subțirel:

public static class DocumentCustomFieldViewHelpers
{
    public static DocumentUserStringFieldBuilder UserStringFieldFor(
        this HtmlHelper<DocumentEditorViewModel> htmlHelper,
        Expression<
            Func<DocumentCustomFieldInputViewModel, string>
        > fieldSelector)
    {
        return new DocumentUserStringFieldBuilder(htmlHelper, 
                fieldSelector)
            //Use the model instance from the view data context
            .BindTo(htmlHelper.ViewData.Model);
    }
}

Ca atare, acum se poate folosi exact construcția dorită. Nu am introdus un framework nou, nu am rescris ecranele complet, nu am pufnit că nu e cea mai nouă tehnologie ca și cum ar fi un atac la persoană, nu am introdus o abstractizare demnă de un contract de leasing operațional, iar bilanțul de cod scris versus cod eliminat reprezintă o economie netă.

Este un gen de oportunism pe care-l recomand: impact rezonabil, efort mic, câștig ulterior clar, ocazie bună de exersat tipare fără să ridici o catedrală peste o magazie și, desigur, șanse ușor sporite la o masă caldă și luna următoare.

Lasă un răspuns

Adresa ta de email nu va fi publicată. Câmpurile obligatorii sunt marcate cu *

Anti-Spam * Time limit is exhausted. Please reload CAPTCHA.

Acest site folosește Akismet pentru a reduce spamul. Află cum sunt procesate datele comentariilor tale.