Anasayfa » 11. TypeScript’de Dekoratörler

11. TypeScript’de Dekoratörler

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

Bu makalemizde, TypeScript’de dekoratörlerin ne olduğunu ve neden dekoratörlere ihtiyaç duyduğumuzu konuşacak, ardından adım adım dekoratörün nasıl tanımlandığını görecek ve yeteneklerini inceleyecek, sonrasında da metot, sınıf ve özellik dekoratörlerin kullanımlarını birer örnek üzerinden deneyimleyeceğiz.


Dekoratör nedir?

Dekoratörler, Gang of Four’un meşhur Design Patterns kitabında yer alan Decorator Pattern’in dile bir uyarlaması olarak düşünülebilir. Aslında C#’da attributes, Java’da annotations, Python’da da decorators olarak geçen kavramların TypeScript’deki karşılığı olarak da özetlenebilir.

TypeScript dekoratörleri, sınıflar, metotlar ve özellikler gibi yapıları dinamik olarak değiştirip genişletmeyi sağlayan dil özellikleridir. Dekoratörler, kodu temiz ve modüler tutarak daha iyi okunabilirlik ve anlaşılabilirlik elde etmemizi sağlarlar. Ayrıca, tekrar kullanılabilirlik ve esneklik sağlayarak uygulamanızın karmaşıklığını yönetmenize yardımcı olurlar.

Fazla formal ve içi boş bir tanım oldu, farkındayım ama lütfen okumaya devam edin. İşinize oldukça yarayacak ve hoşunuza gidebilecek bilgiler öğreneceğiz.



Dekoratörlere Neden İhtiyacımız Var?

Dekoratörler sayesinde mevcut fonksiyon veya sınıflarımızın içeriğini değiştirmeden onlara yeni özellikler kazandırabiliriz. Örneğin;

  • Bazı fonksiyonların çalışma performansını ölçmek için kaç kez çağırıldığını ve her çalışmanın ne kadar sürdüğünü loglatabiliriz.
  • Bazı fonksiyonların cevapları için bir Cache mekanizması kurarak takip eden çağrıların veritabanına gitmeden doğrudan Cache’den dönülmesini talep edebiliriz.
  • Bazı sınıfların sadece oturum açmış ve belirli yetkilere sahip kullanıcılar tarafından kullanılmasını sağlayabiliriz.
  • Bazı fonksiyonlar için, bu fonksiyonları kim hangi IP adresinden ne zaman çağırdı türevi bir Audit Log tutulmasını sağlayabiliriz.
  • Tüm fonksiyonlara ayrı ayrı try-catch ve loglama altyapısı kurmak yerine bu yapıyı merkezi bir yerde kurabiliriz (Asenkron çağrımlara dikkat).
  • Özelliklere atanan değerler için merkezi validasyon ve kontrol mekanizması kurabiliriz.
  • İşaretlediğimiz tüm sınıfların önceden hazırladığımız fonksiyonlara sahip olmasını sağlayabiliriz.

Bunları elbette her şekilde yaparız ancak burada önemli olan, tüm bunları bahsi geçen sınıf ve fonksiyonların içeriklerine dokunmadan yapabiliyor olmamızdır. Asıl mesele dekoratörlerin, Decorator Pattern’de bahsedildiği gibi Open Closed prensibine uyarak, mevcut kodları değiştirmeden yeni fonksiyonalite eklememize imkân tanımasıdır.


İlk Dekoratörümüzü Yazalım

Burada biraz sabır rica ediyorum. Her şey bittiğinde dekoratörlerin aslında ne kadar kolay ve faydalı bir konu olduğunu göreceksiniz ancak her seferinde üstüne bir adım koyarak ilerlemezsek kaybolabilir ve pes edebilirsiniz. İlk olarak en basit haliyle, çok da esprisi olmayan bir dekoratör yazacağız.

Dekoratörleri kullanmak için öncelikle “tsconfig.json” dosyamızda küçük bir değişiklik yapmamız gerekiyor. Aşağıda gösterildiği gibi “experimentalDecorators” ve “emitDecoratorMetadata” değerlerini “true” olarak ayarlamalıyız.

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "sourceMap": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}


Ardından aşağıdaki gibi bir kod yazıp derleyerek çalıştıralım.

function myFirstDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
    console.log(`Dekoratör çalışdı, fonksiyon adı: ${propertyKey}`);
}

class MyClass {

    @myFirstDecorator
    myFunction1() {
        console.log("myFunction1 çalıştı");
    }

    @myFirstDecorator
    myFunction2() {
        console.log("myFunction2 çalıştı");
    }
}

const myClass = new MyClass();

myClass.myFunction1();
myClass.myFunction2();


Kodu çalıştırdığımızda aşağıdaki gibi bir çıktı görüyor olmalıyız.

Bizim çağırdığımız “myFunction1” ve “myFunction2” fonksiyonları çalıştı. Ancak öncesinde “myFirstDecorator” isimli fonksiyonumuz da çağırmamamıza rağmen iki kez çalıştı. Bunun sebebi, her iki fonksiyonun da başına “@” işareti ile birlikte bu fonksiyonu dekoratör olarak tanımladık ve TypeScript her iki fonksiyonu çalıştırmadan önce bu fonksiyonu birer kez çalıştırdı. Üstelik bu fonksiyon içerisinde bize çalıştırılacak fonksiyonun ismini de verdi. Aslında birazdan göreceğimiz gibi, fonksiyona geçilen parametrelere de ulaşmamız mümkündü.

Bu örneğimizi anlamamız çok önemli. Yazdığımız bir fonksiyonun, diğer fonksiyonlar çalışmadan önce otomatik olarak çalıştırılmasını sağladık. Bunu yaparken de mevcut fonksiyonlarımızın içeriğine dokunmadık, sadece fonksiyonun üstünde “@” işareti ile birlikte dekoratör fonksiyonumuzun ismini yazdık.


Çalıştırmadan Önce ve Çalıştırdıktan Sonra…

Şimdi bir adım ileri gidiyoruz. Monkey Patching kavramı JavaScript geliştiricilerin sevdiği ancak kullanılmaması tavsiye edilen efsanevi bir güçtür. Bu yöntem ile çalışma anında kodun bir kısmını, bir fonksiyonu veya komple bir sınıfı değiştirerek, orijinali yerine bu yeni kodun çalışmasını sağlayabiliriz. Aslında yapması çok kolay, kelimelerle anlatması çok daha zor bir konu. Bu yüzden örnek üzerinde göstereceğim.

Kodumuzu aşağıdaki şekilde değiştirelim.

function myFirstDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {

    // descriptor.value içerisinde hedef fonksiyonun referansı bulunur, bunu bir sabite yedekliyoruz 
    const originalFunction = descriptor.value;

    // Hedef fonksiyon içeriğini değiştiriyoruz
    descriptor.value = function (...args: any[]) {

        console.log("Fonksiyon çalışmadan önceki kısım");

        // Yedeklediğimiz hedef fonksiyonu çağırıyoruz
        const result = originalFunction.apply(this, args);

        console.log("Fonksiyon çalıştıktan sonraki kısım");

        // Hedef fonksiyonun dönüş değerini geri döndürüyoruz
        return result;
    };
}

class MyClass {

    @myFirstDecorator
    myFunction1() {
        console.log("myFunction1 çalıştı");
    }

    @myFirstDecorator
    myFunction2() {
        console.log("myFunction2 çalıştı");
    }
}

const myClass = new MyClass();

myClass.myFunction1();
myClass.myFunction2();


Kodumuzda sadece dekoratörün içeriğini değiştirdik, diğer kısımlar aynı kaldı. Peki ya ne yaptık?

  1. Dekoratör içerisindeki “descriptor.value” değeri, çağırılan hedef fonksiyonun referansını içeriyordu. Bunu “originalFunction” isminde bir sabit içerisine yedekledik.
  2. descriptor.value” değerine yeni bir fonksiyon yazdık. Yani aslında “MyClass” içerisindeki dekoratör uygulanmış fonksiyonlar çağırıldığında, aslında o fonksiyonlar değil, her seferinde bizim yeni yazdığımız fonksiyon çalıştırılacak.
  3. Fonksiyon içerisinde istediğimiz her şeyi yapabilirdik ama şimdilik sadece ekrana öncesi ve sonrası için log yazdırdık
  4. İki logun arasında da, “originalFunction” sabitinde yedeklediğimiz asıl çağırılan hedef fonksiyonu çağırıyoruz ve (eğer ki varsa) dönen cevabı “result” sabitinde saklıyoruz.
  5. Her şey bittiğinde bu “result” sabitinde yer alan değeri bizi çağıran yere geri dönüyoruz.

Bu kodu derleyip çalıştırdığımızda aşağıdaki gibi bir çıktı elde ediyor olmamız gerekiyor.



Geçilen Parametreleri de Alalım!

Buraya kadar öğrendiklerimizle bile pek çok imkana kavuştuk. Çağrılan fonksiyonun ismini biliyoruz. Fonksiyon çağrılmadan hemen önce ve çağrıldıktan hemen sonra istediğimiz kodu da çalıştırtabiliyoruz. Ama asıl amacın hasıl olabilmesi için son bir şeye ihtiyacımız var; o da parametrelerin değerleri.

Kodumuzu aşağıdaki şekilde değiştirelim.

function myFirstDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {

    const originalFunction = descriptor.value;

    descriptor.value = function (...args: any[]) {

        console.log(`${propertyKey} isimli fonksiyon çağrıldı`);

        for (const arg of args) {

            console.log(arg);
        }

        const result = originalFunction.apply(this, args);

        console.log(`Fonksiyon çalıştırıldı, dönen değer: ${result}`);

        return result;
    };
}

class MyClass {

    @myFirstDecorator
    myFunction1(value1: string, value2: string): string {

        console.log("myFunction1 çalıştı");

        return value1 + " " + value2;
    }

    @myFirstDecorator
    myFunction2(value1: number, value2: number): number {
        console.log("myFunction2 çalıştı");

        return value1 + value2;
    }
}

const myClass = new MyClass();

myClass.myFunction1("Esma", "Ayşe");
myClass.myFunction2(7, 3);


Aslında dekoratör içerisinde değişen çok fazla bir kod yok. Sadece for-of ile dönüp “args” dizisi içerisindeki tüm parametrelerin değerlerini ekrana yazdırdık. Kodu çalıştırdığımızda oluşan çıktı aşağıdaki gibi olmalı.



İyi de Geçilen Parametrelerin İsimleri Nerede?

Güzel soru. Tüm fonksiyonlar için merkezi bir sistem kurabiliriz ama parametrelerin isimlerin olmadan değerleri ne yapacağız ki? Ne yazık ki “args” dizisi sadece geçilen parametrelerin değerlerini içerir. Parametrelerin isimleri fonksiyonun prototype tanımı içerisinde bulunur. Ama yazacağımız ekstra bir fonksiyon ve biraz regex sihri ile bu isimlere ulaşmak çok da zor olmasa gerek.

Kodumuzu aşağıdaki şekilde değiştirelim.

function myFirstDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {

    const originalFunction = descriptor.value;
    const paramNames = getParameterNames(originalFunction);

    descriptor.value = function (...args: any[]) {

        console.log(`${propertyKey} isimli fonksiyon çağrıldı`);

        for (let i = 0; i < args.length; i++) {

            console.log(`${paramNames[i]}: ${args[i]}`);
        }

        const result = originalFunction.apply(this, args);

        console.log(`Fonksiyon çalıştırıldı, dönen değer: ${result}`);

        return result;
    };
}

function getParameterNames(fn: Function): string[] {

    const source = fn.toString();

    const match = source.match(/\((.*?)\)/);

    if (match) {

        return match[1].split(',').map(param => param.trim());
    }
    
    return [];
}

class MyClass {

    @myFirstDecorator
    myFunction1(value1: string, value2: string): string {

        console.log("myFunction1 çalıştı");

        return value1 + " " + value2;
    }

    @myFirstDecorator
    myFunction2(value1: number, value2: number): number {
        console.log("myFunction2 çalıştı");

        return value1 + value2;
    }
}

const myClass = new MyClass();

myClass.myFunction1("Esma", "Ayşe");
myClass.myFunction2(7, 3);


Burada “getParameterNames” fonksiyonu ile geçilen parametrelerin isimlerini bir dizide biriktirerek değerleri ile birlikte ekrana yazdırdık. Kodu çalıştırdığımızda oluşan çıktı aşağıdaki gibi olmalı.


Artık elimizde hem çağrılan fonksiyon ismi, hem geçilen parametrelerin isimleri, hem de parametrelerin değerleri var. Üstelik fonksiyon çalışmadan önce ve çalıştıktan sonra kod yazabileceğimiz bir fonksiyon ve hedef fonksiyondan dönen değer de elimizde mevcut. Artık bunlarla istersek log tutarız, istersek performans ölçeriz, istersek yetkilendirme yaparız, istersek merkezi hata yakalama ve loglama yönetimi yaparız vs. vs.


Sınıf Dekoratörleri

Şimdiye kadar fonksiyon seviyesindeki dekoratörleri gördük. Şimdi de dekoratörlerin sınıf seviyesinde nasıl kullanıldığına bir göz atalım. Son örneğimizde dekoratör bağladığımız her fonksiyon için loglama yaptırmıştık. Şimdi dekoratörü sınıf seviyesine çekerek, her fonksiyon için ayrı ayrı uğraşmak yerine, tek bir dekoratör bağlantısı ile sınıftaki tüm fonksiyonlar için loglama yapılmasını sağlayalım.

Kodumuzu aşağıdaki şekilde değiştirelim.

function MyFirstDecorator(target: any) {

    for (const propertyName of Object.getOwnPropertyNames(target.prototype)) {

        const descriptor = Object.getOwnPropertyDescriptor(target.prototype, propertyName);

        if (descriptor && typeof descriptor.value === "function") {

            const originalFunction = descriptor.value;
            const paramNames = getParameterNames(originalFunction);

            descriptor.value = function (...args: any[]) {

                console.log(`${propertyName} isimli fonksiyon çağrıldı`);

                for (let i = 0; i < args.length; i++) {

                    console.log(`${paramNames[i]}: ${args[i]}`);
                }

                const result = originalFunction.apply(this, args);

                console.log(`Fonksiyon çalıştırıldı, dönen değer: ${result}`);

                return result;
            };

            Object.defineProperty(target.prototype, propertyName, descriptor);
        }
    }
}

function getParameterNames(fn: Function): string[] {

    const source = fn.toString();

    const match = source.match(/\((.*?)\)/);

    if (match) {

        return match[1].split(',').map(param => param.trim());
    }

    return [];
}

@MyFirstDecorator
class MyClass {

    myFunction1(value1: string, value2: string): string {

        console.log("myFunction1 çalıştı");

        return value1 + " " + value2;
    }

    myFunction2(value1: number, value2: number): number {

        console.log("myFunction2 çalıştı");

        return value1 + value2;
    }
}

const myClass = new MyClass();

myClass.myFunction1("Esma", "Ayşe");
myClass.myFunction2(7, 3);


Burada bir önceki örneğimizden farklı olarak, dekoratörü doğrudan sınıfa bağladık. Böylece dekoratör fonksiyonu içerisinde “Object.getOwnPropertyNames” ile sınıf bileşenleri arasında gezindik ve “Object.getOwnPropertyDescriptor” yardımıyla bileşenlerin tipini kontrol ederek, “function” tipindeki bileşenlerin her biri için bir önceki örnekte yaptığımız yeniden yazma işlemini gerçekleştirdik. Böylece her bir fonksiyon ile ayrı ayrı uğraşmak zorunda kalmadık.

Kodu çalıştırdığımızda oluşan çıktı da bir önceki örnek ile bire bir aynı olacaktır.



Özellik Dekoratörleri

Son olarak bir de özellik seviyesindeki dekoratörlere göz atalım. Diyelim ki sınıfımızda sayısal tipteki değişkenlerimiz var. Bunların belirli sınırlar arasında değer almasını amaçlıyoruz ve bu değerlerin dışında bir değer atanmaya çalışıldığında da uygun bir hata fırlatmak istiyoruz.

Bu durumda aşağıdaki gibi bir dekoratöre ihtiyacımız olacaktır.

function RangeCheck(min: number, max: number) {

    return function (target: any, propertyKey: string) {

        const privatePropertyKey = `_${propertyKey}`;

        Object.defineProperty(target, propertyKey, {
            get() {
                return this[privatePropertyKey];
            },
            set(value: number) {

                if (value < min || value > max) {

                    throw new Error(`${propertyKey} için değer, ${min} ile ${max} arasında olmalıdır`);
                }

                this[privatePropertyKey] = value;
            },
        });
    };
}

class Person {

    @RangeCheck(0, 100)
    public age: number = 0;

    @RangeCheck(20, 250)
    public weight: number = 50;
}

const person = new Person();

try {

    person.age = 150;

} catch (error: any) {

    console.error(error.message); // Değer, 0 ile 100 arasında olmalıdır.
}

try {

    person.weight = 500;

} catch (error: any) {

    console.error(error.message); // Değer, 20 ile 250 arasında olmalıdır.
}


Burada “RangeCheck” isminde bir dekoratör fonksiyon hazırladık ve kontrol sağlamak istediğimiz özelliklere bu dekoratörü, alt ve üst sınırları belirleyerek bağladık. Ardından bu aralıklara uygun olmayan değerler atadık ve fırlatılan hata mesajlarının konsola yazılmasını sağladık.

Kodu çalıştırdığımızda oluşan çıktı aşağıdaki gibi olacaktır.


Böylece TypeScript’de dekoratörler konusunun da sonuna geldik. Bir sonraki makalemizde TypeScript’de “tsconfig.json” ile konfigürasyon seçeneklerini inceleyeceğiz.

Herkese iyi çalışmalar…

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

Bir Yorum Yaz