T-Man tipser om Specification pattern


fredag 20. mars 2009 OO Patterns C#

Hver uke skriver jeg en artikkel for utviklerne i Contiki, hvor jeg forsøker å gi gode råd og tips først og fremst om ting som design og refakturering. Denne uken presenterte jeg Specification pattern, et mønster som isolerer business-regler og gjør kode som må ta mange avgjørelser enklere å lese og samtidig mere fleksibel.

Specification er et relativt avansert mønster som er en del av det som populært kalles Domenedrevet Design (DDD), som kan hjelpe oss (og da mener jeg spesielt oss i Contiki), til å eliminere duplisert kode. Jeg har alltid vært fasinert av konseptet om å lage en regelmotor, og dette mønsteret er én måte å implementere dette på.

Vi starter med litt tilfeldig kode..

For å fortelle deg om Specification vil jeg ta deg med på en liten reise, hvor vi starter med litt kode som har forbedringspotensiale, og som etterhvert vil endres til noe som er mer fleksibelt. Koden er ikke reell kode - bare noe jeg fant på i farten. SendIfPossible-metoden sender et dokument til en gitt bruker hvis noen kriterier tilfredstilles:

public class Step1

{

    private ISendStuff _sender;

    private IConfiguration _config;

    public void SendIfPossible(IDocument document, IUser externalUser)

    {

        if (document.Status != DocumentStatus.Draft

            && document.Status != DocumentStatus.ReleaseCandidate

            && (document.Status != DocumentStatus.Final

            || !_config.ApprovalNeeded))

        {

            if (document.SecurityLevel == DocumentSecurityLevel.Public

                || (document.SecurityLevel == DocumentSecurityLevel.Restricted

                && (externalUser.TrustLevel >= 3

                || externalUser.YearlyContribution >= 10000)))

            {

                _sender.AddObject(document)

                    .ToEmail(externalUser.EmailAddress)

                    .Send();

            }

        }

    }

}

Desverre skriver de fleste av oss kode som dette fra tid til annen. Testene som gjøres her er vanskelige å lese, og reglene er vanskelige å forstå. Ok, la oss forsøke å forbedre dette litt da....

Trekk ut metoder

public class Step2

{

    private ISendStuff _sender;

    private IConfiguration _config;

    public void SendIfPossible(IDocument document, IUser externalUser)

    {

        if (DocumentIsReadyToBeShown(document))

        {

            if (SecurityLevelOkForUser(document, externalUser))

            {

                _sender.AddObject(document)

                    .ToEmail(externalUser.EmailAddress)

                    .Send();

            }

        }

    }

    private bool DocumentIsReadyToBeShown(IDocument document)

    {

        return document.Status != DocumentStatus.Draft

                        && document.Status != DocumentStatus.ReleaseCandidate

                        && (document.Status != DocumentStatus.Final

                        || !_config.ApprovalNeeded);

    }

    private static bool SecurityLevelOkForUser(IDocument document, IUser externalUser)

    {

        return document.SecurityLevel == DocumentSecurityLevel.Public

                        || (document.SecurityLevel == DocumentSecurityLevel.Restricted

                        && (externalUser.TrustLevel >= 3

                        || externalUser.YearlyContribution >= 10000));

    }

}

Vi har nå trukket ut inneholdet i de to if-uttrykkene til separate metoder. Dette er en vanlig refaktureringsteknikk som add-ins som ReSharper, Refactor! og til og med Visual Studio kan hjelpe deg med. Gjennom å gi metodene gode navn er det klarere hvilke kriterier som må møtes for at dokumentet skal sendes til brukeren:

Dokumentet må være visningsklart, dvs. at statusen ikke må være DRAFT, ikke RELEASECANDIDATE, og hvis godkjenning av dokumenter er påkrevd så kan statusen heller ikke være FINAL. Og så må sikkerhetsnivået være ok for den gitte brukeren, dvs. at dokumentet enten må være PUBLIC, eller i tilfeller hvor det er RESTRICTED så må brukeren enten ha TRUST LEVEL 3 eller høyere, eller vi må tjene 10.000 eller mer på denne personen.

Ok, så kanskje det ikke var så lett å lese og forstå likevel. Dette bør vi kanskje splitte opp mere.

Men før vi gjør det, la oss først tenke gjennom om noe av denne logikken skulle vært flyttet til dokumentet eller bruker-objektet. For eksempel kunne vi ha opprettet en metode på IDocument for å spørre dokumentet om det var visningsklart. Men da måtte vi ha gitt IDocument en dependency mot IConfiguration, som jo holder på ApprovalNeeded-informasjonen. Eller i alle fall sende inn denne informasjonene til dokumentet på en eller annen måte. Og dette er noe dokumentet ikke burde behøve å forholde seg til. Det samme gjelder for å sjekke sikkerhetsnivå basert på bruker, hvor man må kombinere informasjon fra dokument og bruker - i tillegg til noen hardkodede konstanter som absolutt burde vært isolert bedre.

Trekk ut flere metoder

public class Step3

{

    private ISendStuff _sender;

    private IConfiguration _config;

    public void SendIfPossible(IDocument document, IUser externalUser)

    {

        if (DocumentIsReadyToBeShown(document))

        {

            if (SecurityLevelOkForUser(document, externalUser))

            {

                _sender.AddObject(document)

                    .ToEmail(externalUser.EmailAddress)

                    .Send();

            }

        }

    }

    private bool DocumentIsReadyToBeShown(IDocument document)

    {

        return DocumentIsDone(document) && AllowedIfFinal(document);

    }

    private static bool DocumentIsDone(IDocument document)

    {

        return document.Status != DocumentStatus.Draft

            && document.Status != DocumentStatus.ReleaseCandidate;

    }

    private bool AllowedIfFinal(IDocument document)

    {

        return document.Status != DocumentStatus.Final

            || !_config.ApprovalNeeded;

    }

    private static bool SecurityLevelOkForUser(IDocument document, IUser externalUser)

    {

        return document.SecurityLevel == DocumentSecurityLevel.Public

            || (document.SecurityLevel == DocumentSecurityLevel.Restricted

            && IsUserVeryImportant(externalUser));

    }

    private static bool IsUserVeryImportant(IUser externalUser)

    {

        return externalUser.TrustLevel >= 3

            || externalUser.YearlyContribution >= 10000;

    }

}

Som du forhåpentlig vis ser blir koden mer lesevennlig jo mer vi splitter den opp, hovedsaklig fordi metodenavnene dokumenterer hva testene egentlig betyri domenemodellen vår. Men det gjenstår noen problemer. For det første begynner det å bli "trangt om plassen" i klassen vår - det blir rotete og uoversiktelig med mange, små metoder. For det andre er det svært sansynlig at testene som disse metodene våre utfører også vil være nyttige andre steder, sansynligvis i andre klasser. Jeg får derfor lyst til å trekke dem ut herfra...

Specification pattern (enkel variant)

Da er det endelig tid for å ta en titt på Specification mønsteret. Først trenger vi et enkelt interface:

public interface ISpecification<T>

{

    bool IsSatisfiedBy(T candidate);

}

Interfacet trenger bare én metode, og den kaller vi IsSatisfiedBy. Og nå kan vi implementere noen forretningsregler:

public class DocumentIsDoneSpecification : ISpecification<IDocument>

{

    private IConfiguration _config;

    public DocumentIsDoneSpecification(IConfiguration config)

    {

        _config = config;

    }

    public bool IsSatisfiedBy(IDocument document)

    {

        return DocumentIsDone(document) && AllowedIfFinal(document);

    }

    private static bool DocumentIsDone(IDocument document)

    {

        return document.Status != DocumentStatus.Draft

            && document.Status != DocumentStatus.ReleaseCandidate;

    }

    private bool AllowedIfFinal(IDocument document)

    {

        return document.Status != DocumentStatus.Final

            || !_config.ApprovalNeeded;

    }

}

public class DocumentHasOkSecurityLevelForUserSpecification

    : ISpecification<IDocument>

{

    private IUser _user;

    public DocumentHasOkSecurityLevelForUserSpecification(IUser user)

    {

        _user = user;

    }

    public bool IsSatisfiedBy(IDocument document)

    {

        return document.SecurityLevel == DocumentSecurityLevel.Public

            || (document.SecurityLevel == DocumentSecurityLevel.Restricted

            && IsVeryImportantUser);

    }

    private bool IsVeryImportantUser

    {

        get

        {

            return _user.TrustLevel >= 3

                || _user.YearlyContribution >= 10000;

        }

    }

}

I konstruktørene tar spesifikasjonene inn sine eksterne avhengigheter - all den informasjonen de måtte behøve for å avgjøre om et hvilket som helst dokument tilfredstiller forretningsregelen spesifikasjonen implementerer. Nå har vi et sentralt sted, en klasse, hvor regelen er definert, og som kan brukes hvor som helst i koden vår.

Ok, la oss forsøke å bruke disse reglene da:

public class Step4

{

    private ISendStuff _sender;

    private IConfiguration _config;

    public void SendIfPossible(IDocument document, IUser externalUser)

    {

        IList<ISpecification<IDocument>> criterions = new List<ISpecification<IDocument>>();

        criterions.Add(new DocumentIsDoneSpecification(_config));

        criterions.Add(new DocumentHasOkSecurityLevelForUserSpecification(externalUser));

        foreach (var criterion in criterions)

            if (!criterion.IsSatisfiedBy(document))

                return;

        _sender.AddObject(document)

            .ToEmail(externalUser.EmailAddress)

            .Send();

    }

}

I denne versjonen av SendIfPossible-metoden lager vi først en liste av spesifikasjoner, og kontrollerer så hver av dem før vi sender dokumentet til brukeren. Dette er bare en måte å bruke spesifikasjoner på. Den faktiske listen kunne ha blitt konfigurert et annet sted, og for eksempel inneholdt ulike criterier i ulike situasjoner.

Komponerbare spesifikasjoner

La oss utvide konseptet noe. Vi ønsker nå å kunne kombinere ulike spesifikasjoner, og dermed lage nye, komponerte spesifikasjoner på et høyere nivå. La oss utvide ISpecification og i tillegg lage en abstrakt klasse om arver fra dette:

public interface ISpecification<T>

{

    bool IsSatisfiedBy(T candidate);

    ISpecification<T> And(ISpecification<T> other);

    ISpecification<T> Or(ISpecification<T> other);

    ISpecification<T> Not();

}

public abstract class CompositeSpecification<T> : ISpecification<T>

{

    public abstract bool IsSatisfiedBy(T candidate);

    public ISpecification<T> And(ISpecification<T> other)

    {

        return new AndSpecification<T>(this, other);

    }

    public ISpecification<T> Or(ISpecification<T> other)

    {

        return new OrSpecification<T>(this, other);

    }

    public ISpecification<T> Not()

    {

        return new NotSpecification<T>(this);

    }

}

Her har vi lagt til tre operasjoner på spesifikasjon: And, Or og Not. Den abstrakte klassen implementerer disse, en implementasjoner som vil være gjeldene for alle konkrete forretningsregler vi implementerer. Den abstrakte klassen benytter tre nye klasser som vi også må definere:

public class AndSpecification<T> : CompositeSpecification<T>

{

    private ISpecification<T> One;

    private ISpecification<T> Other;

    public AndSpecification(ISpecification<T> x, ISpecification<T> y)

    {

        One = x;

        Other = y;

    }

    public override bool IsSatisfiedBy(T candidate)

    {

        return One.IsSatisfiedBy(candidate) && Other.IsSatisfiedBy(candidate);

    }

}

public class OrSpecification<T> : CompositeSpecification<T>

{

    private ISpecification<T> One;

    private ISpecification<T> Other;

    public OrSpecification(ISpecification<T> x, ISpecification<T> y)

    {

        One = x;

        Other = y;

    }

    public override bool IsSatisfiedBy(T candidate)

    {

        return One.IsSatisfiedBy(candidate) || Other.IsSatisfiedBy(candidate);

    }

}

public class NotSpecification<T> : CompositeSpecification<T>

{

    private ISpecification<T> Wrapped;

    public NotSpecification(ISpecification<T> x)

    {

        Wrapped = x;

    }

    public override bool IsSatisfiedBy(T candidate)

    {

        return !Wrapped.IsSatisfiedBy(candidate);

    }

}

Vi kan nå implementere våre forretningsregler ved å arve fra CompositeSpecifications. Jeg overlater dette som en oppgave til leseren.., i stedet hopper vi direkte til slutten og ser hvordan dette kan brukes:

public class Step5

{

    private ISendStuff _sender;

    private IConfiguration _config;

    public void SendIfPossible(IDocument document, IUser externalUser)

    {

        var isDone = new DocumentIsDoneSpecification(_config);

        var okSecurity = new DocumentHasOkSecurityLevelForUserSpecification(externalUser);

        ISpecification<IDocument> sendCriterion = isDone.And(okSecurity);

        if (sendCriterion.IsSatisfiedBy(document))

        {

            _sender.AddObject(document)

                .ToEmail(externalUser.EmailAddress)

                .Send();

        }

    }

}

Nå har vi laget et slags "fluent interface" (eller mer presist brukt method chaining), og vi kan dermed kjede sammen forretningsregler slik som i dette banale eksempelet: isDone.And(okSecurity). Fantasien din har nok allerede begynt å jobbe med eksempler som har mange flere forretningsregler. Og for å sjekke om kriteriene er oppfylt trenger vi bare kalle IsSatisfiedBy på den komponerte spesifikasjonen.

Dette mønsteret åpner opp mange, spennende muligheter. Koden vår kan forholde seg til det enkle ISpecification interfacet for å sjekke kriterier, uten å vite noe om hvordan disse kriteriene har blitt konstruert. Spesifikasjoner kan bli sendt fra sted til sted, og nye spesifikasjoner kan kjedes til på ulike steg i prosessen - samtidig som koden holdes ren og ryddig.

Knagger: , , , ,

Inspirasjonskilder: DDD:Specification pattern blog post av Casey Charlton, Specification pattern på Wikipedia


comments powered by Disqus