Anasayfa » 11. Decorator Pattern

11. Decorator Pattern

by Levent KARAGÖL
16 dakikalık okuma süresi

Bu makalemizde, var olan bir nesneye kaynak kodunu değiştirmeden çalışma zamanında yeni özellikler eklemeye imkan sağlayan Decorator tasarım kalıbını inceleyeceğiz. Bu serinin genelinde olduğu gibi öncelikle Decorator tasarımın kalıbının ne olduğunu, hangi durumlarda hangi sorunlara çözüm sunduğunu konuşacak, sonrasında gelen bir talebe istinaden herhangi bir tasarım kalıbı kullanmadan bir çözüm üretecek, ardından bu çözümün problemlerini ve hangi yazılım tasarım prensiplerine aykırı olduğunu tartışacak, son olarak da Decorator tasarım kalıbı ile aynı talebe uygun bir çözüm üretmeye çalışacağız.

Not: Makale genelindeki örneklerin kaynak kodlarına Github üzerinden ulaşabilirsiniz.


Decorator Tasarım Kalıbı Nedir?

Yazılım tasarım prensiplerinden Composition over Inheritance ve Open/Closed prensiplerinin önde gelen temsilcilerinden olan Decorator tasarım kalıbı, var olan bir sınıfı değiştirmeksizin genişletmemize ve ona yeni özellikler kazandırmamıza imkan sağlar. Bu tasarım kalıbını anlamak, gelecek ek geliştirme taleplerine mevcut kodu değiştirmeden yanıt verebilme yeteneğinin anahtarıdır.

Yazılan her bir Decorator sınıf, mevcut sınıfı dekore ederek ona yeni bir özellik ekler. Decorator sınıf mevcut sınıf ile aynı Interface’i uyguladığı için, dekorasyon sonucunda elde edilen sınıf başka bir Decorator sınıfa verilerek farklı özellikler eklemek için aynı şekilde kullanılabilir. Böylece mevcut yazılmış sınıfın kodunu değiştirmeden ihtiyaç duyulan özellikler katmanlar halinde bu sınıfın etrafına sarılabilir.

Decorator tasarım kalıbı kullanılarak elde edilen nesneler, yer kürenin katmanları gibi birbirlerinin üzerine sarılmış birer yapıya benzerler. Çekirdekteki mevcut sınıfın üzerine eklenen her bir katman, kendi içerisindeki katmana ek bir özellik ekleyerek küreyi genişletmeye devam eder.

Bu tasarım kalıbının en önemli özelliği, Composition over Inheritance prensibini kullandığı için çalışma zamanında ihtiyaca yönelik olarak kullanılacak katmanlara karar verilebilir veya bunların sırası değiştirilebilir.


Talebimizi Alalım

Mevcut yazılım altyapısında TCP/IP üzerinden hizmet veren bir API’a erişmek için kullanılan aşağıdaki gibi bir sınıfımız var. Bu sınıftaki “write” metodu vasıtasıyla API’a veri gönderilebiliyor, “read” metodu vasıtasıyla da veri okunabiliyor.

export class ApiConnector {
    read(): string {
        return "gelen veri";
    }

    write(data: string): void {
        console.log(`gönderilen veri: ${data}`);
    }
}


Gelen talep ise, bu sınıfa belirtilen format ile verileri sıkıştırma/açma ve verilen algoritma ile verileri şifreleme/çözme yeteneklerinin kazandırılması. Ancak burada dikkat edilmesi gereken birkaç önemli husus var.

  • Uygulamanın geri kalanı şu anda sınıfın bu halini kullanıyor. Yaptığımız değişiklik sonrasında eskiden yazılmış kodlar sorunsuzca çalışmaya devam etmeli
  • Sıkıştırma ve şifreleme işlevleri opsiyonel olmalı. Yani bir kısım sadece sıkıştırmayı, diğer bir kısım sadece şifrelemeyi, diğer bir kısım da her iki işlevi birden kullanabilmeli


İlk Çözüm Önerimizi Sunalım

TypeScript’deki fonksiyon parametreleri için varsayılan değerler ile bu talebin üstesinden geliriz gibi duruyor. TypeScript serisindeki ilgili makalede önerilen “tsconfig.json” dosyasını oluşturarak işe başlayalım.

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "moduleResolution": "node",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "resolveJsonModule": true,
    "removeComments": true,
    "noUnusedParameters": true,
    "declaration": true,
    "lib": [
      "es2020",
      "esnext.asynciterable",
      "dom"
    ]
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}


ApiConnector” sınıfını aşağıdaki gibi değiştirirsek hem eski kısımlar olduğu gibi çalışmaya devam edecektir. Hem de yeni iki işlev ihtiyaca göre açılıp kapatılabilecektir.

export class ApiConnector {
    read(compress: boolean = false, compressionFormat: string = "", encrypt: boolean = false, encryptionAlgorithm: string = ""): string {

        let data = "gelen veri";

        if (compress) {
            data = `${compressionFormat} ile açılmış ${data}`;
        }

        if (encrypt) {
            data = `${encryptionAlgorithm} ile çözülmüş ${data}`;
        }

        return data;
    }

    write(data: string, compress: boolean = false, compressionFormat: string = "", encrypt: boolean = false, encryptionAlgorithm: string = ""): void {

        if (encrypt) {
            data = `${encryptionAlgorithm} ile şifrelenmiş ${data}`;
        }

        if (compress) {
            data = `${compressionFormat} ile sıkıştırılmış ${data}`;
        }

        console.log(`gönderilen veri: ${data}`);
    }
}


JavaScript yerine TypeScript kullandığımız için eski kodların hata vermemesi adına ya opsiyonel parametreleri kullanmalıyız, ya da parametreler için varsayılan değerler vermeliyiz. Opsiyonel parametreleri kullanmak bu durumda mantıklı değil çünkü sondaki opsiyonel parametreyi geçebilmek için öncesindeki tüm parametrelere değer atamak gerekecektir.

Yukarıdaki yapıda geçilen değerlere göre geçilen veriye sıkıştırma ve şifreleme işlemlerini uyguluyoruz. İsteyen istediği işlemi açıp kapatabilir.

Son olarak uygulamamızı ayağa kaldıracak en dış kabuk olan “app.ts” dosyamızı aşağıdaki içerik ile oluşturalım.

import {ApiConnector} from "./api-connector";

let connector = new ApiConnector();

// Eski yöntem
connector.write("veri1");
console.log(connector.read());

console.log();

// Sıkıştırmayı aktif ediyoruz
connector.write("veri2", true, "gzip", false, "");
console.log(connector.read(true, "gzip", false, ""));

console.log();

// Sıkıştırma ve şifrelemeyi aktif ediyoruz
connector.write("veri3", true, "gzip", true, "aes256");
console.log(connector.read(true, "gzip", true, "aes256"));


Uygulamamızı derleyip çalıştırdığımızda aşağıdaki gibi bir ekran görüntüsü ile karşılaşırız. Görüldüğü üzere üç farklı kullanımda da ihtiyaca uygun kısımlar çalıştı ve veriyi değiştirdi.


Genel yapıyı daha net görebilmek adına öneriye ilişkin Class Diagram aşağıdadır.


Peki Bu Çözümün Nesi Yanlış?

Burada elbette Single Responsibility prensibine ilişkin sıkıntılardan bahsetmek mümkün ancak en ciddi sıkıntımız, daha önceden yazılmış bir sınıfta değişiklik yapmamızdan geçiyor. Şimdiye kadar işlediğimiz tüm tasarım kalıplarında sürekli bu sıkıntıya değindik. Şunu net bir şekilde ifade etmek gerekir ki, Open/Closed prensibini çiğnediğimiz her geliştirme, sistemin baştan aşağı test edilmesi ihtiyacını doğurur. Yapılması gereken etki analizinin kapsamını genişletir. Uygulamanın, hiç tahmin etmediğimiz bir yerden hata vermesine sebep olabilir.

Burada “Talebin kendisi mevcut fonksiyonun genişletilmesiydi. Bunu mevcut kodu değiştirmeden nasıl yapabilirdik ki?” sorusunu soruyorsak, Decorator Pattern ile tanışma vaktimiz gelmiş demektir.


Nasıl Bir Çözüm Sunulabilirdi?

Öncelikle mevcut sınıfımızın bir Interface’ini çıkarmamız gerekiyor. Bunun için “IConnector” isimli Interface’i aşağıdaki şekilde projemize ekleyelim.

export interface IConnector {
    read(): string;
    write(data: string): void;
}


Ardından “ApiConnector” sınıfımıza bu Interface’i uygulayalım (Dikkat edelim, sınıfın yapısını değiştirmiyoruz. Sadece kendisinden ürettiğimiz Interface’i uyguluyoruz).

import {IConnector} from "./iconnector";

export class ApiConnector implements IConnector {
    read(): string {
        return "gelen veri";
    }

    write(data: string): void {
        console.log(`gönderilen veri: ${data}`);
    }
}


Sıkıştırma işlevini mevcut sınıfa ekleme görevini üstelenecek “CompressionDecorator” sınıfımızı aşağıdaki şekilde projemize ekleyelim.

import {IConnector} from "./iconnector";

export class CompressionDecorator implements IConnector {
    constructor(private connector: IConnector, private format: string) {
    }

    read(): string {

        const data = this.connector.read();

        //Gelen veri açılıyor
        return `${this.format} ile açılmış ${data}`;
    }

    write(data: string): void {

        // Gelen veri sıkıştırılıyor
        data = `${this.format} ile sıkıştırılmış ${data}`;

        this.connector.write(data);
    }
}


Bu dekoratör sınıf, mevcut sınıfımız ile aynı Interface’i uyguluyor ve kurucu metodundan bu Interface’i uygulayan başka bir nesneyi Dependency Injection edilmek üzere bekliyor. Bunun yanında ihtiyaç duyduğu diğer parametreleri de alıyor (Buradaki örneğimizde sıkıştırma formatını alıyor).

Uyguladığı Interface sebebiyle “read” ve “write” metotlarının imzası aynı ancak içeriğinde kendine ait özel işlemleri içeriyor. Bu işlemleri gerçekleştirmesinin yanında, kendisine geçilmiş “IConnector” türevi nesnenin “read” ve “write” metotlarını da çağırıyor. Birazdan göreceğimiz gibi, bu nesne bizim orijinal “ApiConnector” sınıfımızın bir örneği olacak. Dolayısıyla sıkıştırma ve açma işlevlerinin yanında eski “read” ve “write” metotlarını da çağırıyor olacak.

Aynı mantıkta şifreleme işlevi için “EncryptionDecorator” sınıfımızı aşağıdaki şekilde projemize ekleyelim.

import {IConnector} from "./iconnector";

export class EncryptionDecorator implements IConnector {
    constructor(private connector: IConnector, private algorithm: string) {
    }

    read(): string {

        const data = this.connector.read();

        // Gelen veri çözülüyor
        return `${this.algorithm} ile çözülmüş ${data}`;
    }

    write(data: string): void {

        // Gelen veri şifreleniyor
        data = `${this.algorithm} ile şifrelenmiş ${data}`;

        this.connector.write(data);
    }
}


Burada da aynı yapıyı görüyoruz. Şifreleme ve çözme işlemlerinin yanında kendisine geçilmiş “IConnector” türevi nesnenin “read” ve “write” metotlarını da çağırıyor. Birazdan göreceğimiz gibi, bu nesne az önce projeye eklediğimiz “CompressionDecorator” sınıfımızın bir örneği olacak. Dolayısıyla kendi işlevlerinin yanında bu sınıfın “read” ve “write” metotlarını da çağırıyor olacak.

Son olarak “app.ts” dosyamızı aşağıdaki şekilde güncelleyelim.

import {ApiConnector} from "./api-connector";
import {CompressionDecorator} from "./compression-decorator";
import {EncryptionDecorator} from "./encryption-decorator";

let connector = new ApiConnector();

// Eski yöntem
connector.write("veri1");
console.log(connector.read());

console.log();

// Sıkıştırmayı aktif ediyoruz
connector = new CompressionDecorator(connector, "gzip");
connector.write("veri2");
console.log(connector.read());

console.log();

// Şifrelemeyi de aktif ediyoruz
connector = new EncryptionDecorator(connector, "aes256");
connector.write("veri3");
console.log(connector. Read());


Burada üç farklı kullanım şekli görüyoruz. İlkinde orijinal “ApiConnector” sınıfımızın nesnesi üzerinden çağrımda bulunuyoruz. İkinci kullanımda bu nesneyi “CompressionDecorator” sınıfına geçiyoruz, yani sınıfı dekore ediyoruz. Oluşan yeni nesne, “ApiConnector” sınıfının tüm özelliklerini taşımasının yanı sıra sıkıştırma ve açma işlevlerini de içeriyor. Üçüncü kullanımda bu nesneyi “EncryptionDecorator” sınıfına geçiyoruz, yani 2. kez dekore ediyoruz. Oluşan yeni nesne, “ApiConnector” ve “CompressionDecorator” sınıflarının tüm özelliklerini taşımasının yanı sıra şifreleme ve çözme işlevlerini de içeriyor. Yani katman katman işlevleri birbiri üzerine ekleyerek orijinal sınıfımızın işlevlerini genişletiyoruz.

Burada Composition over Inheritance prensibinin güzel bir uygulamasına da şahit oluyoruz. Bu sınıflar Inheritance ile iç içe geçerek aynı katmanlı yapı elde edilebilirdi. Ancak böyle bir kullanımda, örneğin sıkıştırma işlevi olmadan şifreleme işlevine ulaşmak imkansız olurdu. Oysa Decorator tasarım kalıbı sayesinde, sadece ihtiyacımız olan işlevler, ihtiyacımıza uygun sırada, çalışma zamanında nesnelere eklenmiş oluyor.

Uygulamamızı derleyip çalıştırdığımızda aşağıdaki gibi bir ekran görüntüsü ile karşılaşırız.


Genel yapıyı daha net görebilmek adına öneriye ilişkin Class Diagram aşağıdadır.



Böylece Decorator tasarım kalıbı konusunun sonuna geldik. Bir sonraki makalemizde Flyweight tasarım kalıbını işleyeceğiz.

Herkese iyi çalışmalar…

İLGİNİZİ ÇEKEBİLİR

Bir Yorum Yaz