Dekorer klassene dine med funksjonalitet


fredag 17. juli 2009 OO Patterns C#

CropperCapture[18]

Decorator pattern er et objektorientert designmønster som lar deg legge ny funksjonalitet til et eksisterende objekt uten å modifisere det. Funksjonalitet implementert som decorators kan "plugges inn" dynamisk når man måtte ønske det, og holder grunn-klassen fri for forstyrrende kode.

I denne artikkelen illustrerer jeg et tenkt tilfelle hvor vi blir bedt om å legge til ny funksjonalitet i et eksisterende system, og hvor vi kan velge å gjøre det enkelt – noe som kan føre til større problemer på sikt – eller vi kan velge å bruke decorators, som vil la oss beholde en ren og fleksibel løsning. Jeg viser hvordan jeg dekorerer den eksisterende klassen med ny funkjonalitet, og snakker til slutt litt om motivasjonen for å gjøre det på denne måten.

I det tenkte systemet vårt har vi et abstrakt begrep vi kaller for en Service. Du kan tenke på det som en typisk windows service. For å administrere en service har vi laget en klasse vi kaller ServiceRunner – den har metoder for å starte, stoppe, pause og re-starte en service.

    1     public class ServiceRunner

    2     {

    3         private Service _service;

    4         public ServiceRunner(Service service)

    5         {

    6             _service = service;           

    7         }

    8         public void Start()

    9         {

   10             if (_service.State == ServiceState.Suspended)

   11                 _service.Resume();

   12             else

   13                 _service.Run();

   14         }

   15         public void Stop() { /* details omitted */ }

   16         public void Pause() { /* details omitted */ }

   17         public void Restart() { /* details omitted */ }

   18     }

ServiceRunner skjuler brukerne av koden vår for alle detaljene som inngår i de fire vanlige operasjonene vi ønsker å tilby. Implementasjonen ev Start() er vist over. Den er ikke spesielt kompleks, men det kunne tenkes at Service hadde et grensesnitt som det var vanskeligere å forholde seg til, og du aner ingenting om hva som skjuler seg bak de andre metodene – koden der kan godt være mere innviklet.

Systemet vårt fungerer ypperlig. Servicer starter og stopper mer eller mindre som de skal. Men så får vi inn et nytt krav fra kunden: Systemet skal logge hver gang en service starter, stopper, pauses eller re-startes.

Ok, det var jo ikke så vanskelig. Vi har allerede laget et abstrakt konsept vi har kalt Logger, som i praksis kan skrive til event-loggen, databasen, en logfil o.l. Vi kunne latt koden som kalte de ulike metoden på ServiceRunner logge før kallene, men hvordan skulle vi håndheve det? Nye klienter av ServiceRunner kan komme til, og da måtte vi ha duplisert kallene til Logger. Nei, vi kan gjøre noe mye enklere, Vi kan gi ServiceRunner tilgang til en Logger (gjennom dependency injection selvsagt), og implementere logging i hver av de fire metodene – illustrert her ved logging av Start i linje 12 nedenfor:

    1     public class ServiceRunner

    2     {

    3         private Logger _logger;

    4         private Service _service;

    5         public ServiceRunner(Service service, Logger logger)

    6         {

    7             _logger = logger;

    8             _service = service;

    9         }

   10         public void Start()

   11         {

   12             _logger.Log(String.Format("Starting service {0}", _service.Name));

   13             if (_service.State == ServiceState.Suspended)

   14                 _service.Resume();

   15             else

   16                 _service.Run();

   17         }

   18         /* Stop, Pause, and Restart omitted */

   19     }

Supert, dette fungerte som en drøm. Vi måtte riktignok endre alle steder ServiceRunner opprettes (vi burde ha innført en factory, slik at det kun var et sted konstruktøren til ServiceRunner ble kalt), men det var en smal sak. Nå vil alle operasjoner på en Service bli logget.

Like etter at vi releaser den nye ServiceRunneren får vi inn et nytt ønske. Brukere av systemet har ulike rettigheter, og ikke alle har lov til å starte en gitt service, stoppe en gitt service etc. Ok, vi bestemmer oss for å bruke mer eller mindre samme teknikk; vi implementerer et ServiceAuthorization objekt som vi kan spørre om den påloggede brukeren har lov til å foreta bestemte operasjoner på servicen. ServiceAuthorization sendes inn i konstruktøren, og brukes i hver av metodene, som i linje 15 nedenfor:

    1     public class ServiceRunner

    2     {

    3         private Logger _logger;

    4         private ServiceAuthorization _authorization;

    5         private Service _service;

    6         public ServiceRunner(Service service, Logger logger,

    7             ServiceAuthorization authorization)

    8         {

    9             _authorization = authorization;

   10             _logger = logger;

   11             _service = service;

   12         }

   13         public void Start()

   14         {

   15             if (!_authorization.CanStart(_service))

   16                 throw new NotAllowedToStartServiceException(_service);

   17 

   18             _logger.Log(String.Format("Starting service {0}", _service.Name));

   19             if (_service.State == ServiceState.Suspended)

   20                 _service.Resume();

   21             else

   22                 _service.Run();

   23         }

   24         /* Stop, Pause, and Restart omitted */

   25     }

Ser dere hva som skjer her? En veldig enkel klasse begynner å vokse etterhvert som vi trenger mer funksjonalitet. Denne naive måten å legge til adferd på fører til prosedyrebasert kode som blir vanskeligere og vanskeligere å holde orden på. Klassen får flere og flere dependencies til andre deler av systemet. Dette er begynnelsen på noe som om kort tid vil bli omtalt som et legacy-system av utviklerne som må vedlikeholde det.

Problemet er at vi må endre ServiceRunner hver gang vi skal legge til noe nytt som har med kjøring av servicer å gjøre. Vi bryter dermed med Open/Closed-prinsippet innenfor objektorientert design. Det er også vanskelig å argumentere for at ServiceRunner tilfredstiller Single Responsibility Principle – om vi vil endre hva vi logger så må vi endre ServiceRunner, om vi vil endre autorisasjonsprosessen må vi endre ServiceRunner, og om måten en Service fungerer på endres må vi også endre ServiceRunner.

Decorator Pattern

Decorator pattern er en alternativ løsning vi kan bruke for å implementere den nødvendige loggingen, autoriseringen, og en rekke andre, nye krav som måtte komme til. Første steg er å bruke ReSharper, Refactor! Pro eller manuell refakturering til å trekke ut interfacet fra den konkrete ServiceRunner-klassen. Dette vil gjøre det enklere å implementere dekoratørene.

Jeg velger å gi interfacet navnet ServiceRunner, mens jeg endrer den konkrete implementasjonen til å hete ServiceRunnerImplementor (alternativt kan du kalle interfacet for IServiceRunner og den konkrete klassen for ServiceRunner – en konvensjon du sikkert er mer komfortabel med). Med min metode behøver vi ikke endre alle eksisterende referanser til ServiceRunner – de vil nå referere interfacet, som er det vi ønsker.

Jeg har også bestemt at ServiceRunner skal ha en Service property som eksponerer instansen gitt gjennom konstructøren. Grunnen vil bli åpenbar om litt.

    1     public interface ServiceRunner

    2     {

    3         Service Service { get; }

    4         void Start();

    5         void Stop();

    6         void Pause();

    7         void Restart();

    8     }

    9 

   10     // Original ServiceRunner is renamed...

   11     public class ServiceRunnerImplementor : ServiceRunner

Steg 2 er å lage en abstrakt klasse som arver fra ServiceRunner: ServiceRunnerDecorator. Dette blir baseklassen for alle de ulike funksjonene vi vil dekorere ServiceRunner med. ServiceRunnerDecorator tar inn en instans av en ServiceRunner i konstruktøren, og delegerer så alle kall til denne instansen. Den abstrakte dekoratøren er altså ikke annet enn en enkel wrapper rundt en ServiceRunner.

    1     public abstract class ServiceRunnerDecorator : ServiceRunner

    2     {

    3         private ServiceRunner _runner;

    4         public ServiceRunnerDecorator(ServiceRunner runner) {

    5             _runner = runner;           

    6         }

    7         public Service Service

    8         {

    9             get { return _runner.Service; }

   10         }

   11         public virtual void Start() { _runner.Start(); }

   12         public virtual void Stop() { _runner.Stop(); }

   13         public virtual void Pause() { _runner.Pause(); }

   14         public virtual void Restart() { _runner.Restart(); }

   15     }

Og nå kan vi implementere logge-funksjonaliteten. Vi lager er konkret ServiceRunnerDecorator som vi kaller for LoggingServiceRunner. I tillegg til å ta inn en instans av en ServiceRunner trenger den selvsagt også en Logger. I Start-metoden kan vi gjøre loggingen på samme måte som vi tidligere gjorde i den orginale ServiceRunner'en. Deretter delegeres resten av start-logikken til baseklassen, som til syvende og sist kaller Start-metoden på den orginale klassen.

    1     public class LoggingServiceRunner : ServiceRunnerDecorator

    2     {

    3         private Logger _logger;

    4         public LoggingServiceRunner(ServiceRunner runner, Logger logger)

    5             : base(runner) {

    6             _logger = logger;

    7         }

    8         public override void Start()

    9         {

   10             _logger.Log(String.Format("Starting service {0}", Service.Name));

   11             base.Start();

   12         }

   13         /* Logging for other methods omitted */

   14     }

Nå ser du også grunnen til at jeg ønsket en Service property i ServiceRunner, slik at dekoratørene kan få tak Servicen de skal gjøre noe med (i dette tilfelle logges navnet).

Videre kan vi implementere en ny dekoratør som tar seg av å sjekke om brukeren har lov til å bruke tjenesten. Jeg kaller den StrictServiceRunner:

    1     public class StrictServiceRunner : ServiceRunnerDecorator

    2     {

    3         private ServiceAuthorization _authorization;

    4         public StrictServiceRunner(ServiceRunner runner,

    5             ServiceAuthorization authorization) : base(runner)

    6         {

    7             _authorization = authorization;

    8         }

    9         public override void Start()

   10         {

   11             if (!_authorization.CanStart(Service))

   12                 throw new NotAllowedToStartServiceException(Service);

   13             base.Start();

   14         }

   15         /* Authorization for other methods omitted */

   16     }

Som du ser har hver av klassene vi nå står igjen med hvert sitt ansvar: StrictServiceRunner handler kun om autorisasjon, LoggingServiceRunner handler kun om logging, ServiceRunnerImplementor bryr seg kun om å utføre de nødvendige operasjonene på Service-instansen. Hver av disse klassene kan så komponeres sammen, slik at når brukeren kaller Start() på sin instans av ServiceRunner så kalles Start-metoden etter tur i alle klassene, som nå utgjør en "pipeline" med relatert funksjonalitet. Du kan se for deg hvordan dekoratørene pakker inn den orginale ServiceRunner'en lag for lag, som en løk:

DecoratorOnion

Nå bør vi til slutt implementere en factory for å opprette ServiceRunnere, slik som jeg hintet til helt i starten. Gitt en Service kan vi komponere en ServiceRunner med ønsket funksjonalitet (eller "dekor" om du vil).

    1         public ServiceRunner CreateServiceRunner(Service service)

    2         {

    3             return

    4                 new StrictServiceRunner(

    5                     new LoggingServiceRunner(

    6                         new ServiceRunnerImplementor(service)

    7                         , _eventLogger)

    8                     , _userServiceAuthorization);

    9         }

Eller vi kan går for en mer dynamisk factory som komponerer ServiceRunnere basert på konfigurasjon, slik som dette:

    1         public ServiceRunner CreateServiceRunner(Service service)

    2         {

    3             ServiceRunner runner = new ServiceRunnerImplementor(service);

    4 

    5             if (_configuration.Service_ACL_Enabled)

    6                 runner = new StrictServiceRunner(runner, GetServiceAuthorizationService());

    7 

    8             if (_configuration.Should_log_to_EventLog)

    9                 runner = new LoggingServiceRunner(runner, GetEventLogLogger());

   10 

   11             if (_configuration.Should_log_to_DataBase)

   12                 runner = new LoggingServiceRunner(runner, GetDataBaseLogger());

   13 

   14             //TODO: add additional decorators..

   15 

   16             return runner;

   17         }

Legg merke til hvordan jeg her potensielt legger til to LoggingServiceRunnere, en for logging til eventloggen i linje 9, og en for logging til databasen i linje 12.

Når skal man bruke decorators?

I Working Effectively with Legacy Code (kapittel 6: "I Don't Have Much Time and I Have to Change It") snakker Michael Feathers om hvordan Decorator pattern kan være et nyttig hjelpemiddel når man skal implementere ny funksjonalitet i et komplisert system man ikke er trygg på. La oss tenke oss at den orginale ServiceRunner-klassen er mye større, og allerede har mange avhengigheter som gjør det svært vanskelig å lage enhetstester for den. Vi ønsker å være trygg på den nye funksjonaliteten vi skal implementere, og vil derfor skrive tester, men det koster rett og slett for mye å få ServiceRunner inn i et testing harness.

Da kan man velge å implementere den nye funksjonaliteten som en dekoratør. Den kan testes uavhengig av ServiceRunner. ServiceRunner er i seg selv fortsatt Legacy Code, men alt nytt vi legger til er enhetstestet og til å stole på. Dette er ikke ønskedrømmen – aller helst burde vi klare å få testet ServiceRunner også - men noen ganger må man ta snarveier, og da er Decorator pattern et bra verktøy.

Robert C. Martin forteller også om Decorator i boken Agile Principles, Patterns, and Practices in C#, hvor han klassifiserer mønsteret sammen med Visitor pattern og Extension pattern. Han har følgende å si om hvordan disse teknikkene støtter opp under gode OO-prinsipper:

"The Visitor family of patterns provides us with a number of ways to modify the behaviour of a hierarchy of classes without having to change them. Thus, they help us maintain the Open/Closed Prinsiple. They also provide mechanisms for segregating various kinds of funktionality, keeping classes from getting cluttered with many different functions. As such, they help us maintain the Common Closure Prinsiple. It should be clear that LSP [Liskov's Substitution Principle] and DIP [Dependency Invertion Principle] are also applied to the structure of the Visitor family."

Uncle Bob gir videre følgende råd om bruken av Visitor, Decorator og Extension:

"The Visitor patterns are seductive. It is easy to get carried away with them. Use them when they help, but maintain a healthy skepticism about their necessity. Often, something that can be solved with a Visitor can also be solved by something simpler."

For mer informasjon anbefaler jeg at du tar en titt på Decorator Pattern på wikipedia, og så finner du en video kalt Learning the Decorator Pattern som er under 10 minutter lang på DimeCasts.NET.

Knagger: , , , , , , , ,


comments powered by Disqus