Anasayfa » 3. Singleton Pattern

3. Singleton Pattern

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

Bu makalemizde, özellikle nesne oluşturma süreçlerinde kullanılan Singleton tasarım kalıbını inceleyeceğiz. Öncelikle Singleton tasarım 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 Singleton 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.


Singleton Tasarım Kalıbı Nedir?

Singleton tasarım kalıbı, bir sınıfın tüm uygulama boyunca yalnızca tek bir örneğinin oluşturulabilmesini garanti eden bir tasarım kalıbıdır. Bu sayede, uygulama içinde ortak ve global bir erişim noktası oluşturulur ve sistem kaynaklarının daha etkili kullanımı sağlanır.

Singleton tasarım kalıbının kullanımına yönelik başlıca nedenler şunlardır:

  • Sınırlı kaynakların yönetimi: Singleton, özellikle kaynakların sınırlı olduğu ve paylaşılması gereken durumlarda etkili bir çözüm sunar. Örneğin, bir veritabanı bağlantısı veya bir dosya sistemi gibi ortak kaynakların kullanımı, Singleton sayesinde daha düzenli ve kontrollü hale gelir.
  • Performans ve bellek optimizasyonu: Singleton, uygulamanın performansını ve bellek kullanımını optimize eder. Tek bir nesne oluşturularak, gereksiz nesne oluşturma ve bellek tahsis işlemlerinin önüne geçer.
  • Global erişim noktası: Singleton, uygulama içinde global ve kolay erişilebilir bir erişim noktası sağlar. Bu sayede, farklı bileşenler arasında iletişim ve veri paylaşımı daha kolay hale gelir.


Talebimizi Alalım

Uygulamamız 3. parti bir uygulama ile HTTP REST API üzerinden entegre oluyor (İstemci olan bizim uygulamamız). Ancak yüksek işlem hacmi sebebiyle sıkıntı yaşanıyor. Karşı taraf, aynı işlevi bire bir yerine getiren 3 farklı API adresi daha paylaşıyor. Normalde bunları Load Balancer arkasına koymalı ve bizim API adresine yaptığımız talepleri bu 4 sunucu arasında paylaştırmalıydı ancak bir sebepten bunu yapamıyorlar. Bizim gönderdiğimiz talepleri bu 4 API adresine eşit şekilde paylaştırmamızı istiyorlar.


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

Aslında oldukça basit bir taleple karşı karşıyayız. Bir dizi içerisinde farklı API adreslerini tutacak, her seferinde dizideki bir sonraki adrese talepte bulunacağız. Dizideki son API adresine ulaştığımızda da ilk adrese tekrar geri döneceğiz. Projeye başlarken 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"
  ]
}

Ardından API adreslerini tutmak için “endpoints.json” isimli aşağıdaki gibi bir dosya oluşturalım.

[
  "https://www.google.com",
  "https://www.google.com.tr",
  "https://www.google.co.uk",
  "https://www.google.ru"
]

Sıra geldi yukarıda tarif edilen load balancer görevini yerine getirecek asıl sınıfımıza. Bu iş için aşağıdaki gibi bir sınıf işimizi görecektir.

import endpoints from "./endpoints.json";

export class LoadBalancer {

    private endpoints: string[];
    private currentIndex: number;

    public constructor() {

        this.endpoints = endpoints;
        this.currentIndex = 0;
    }

    public async callEndpoint(): Promise<any> {

        if (this.endpoints.length === 0) {

            throw new Error("Kayıtlı Endpoint bulunamadı");
        }

        const currentEndpoint = this.endpoints[this.currentIndex];

        console.log(`Kullanılan Endpoint: (${this.currentIndex}), ${currentEndpoint}`);

        this.currentIndex = (this.currentIndex + 1) % this.endpoints.length;

        return fetch(currentEndpoint);
    }
}

Sınıfın yapısı oldukça basit. “callEndpoint” metodunu çağırdığımızda her seferinde bir sonraki API adresine “fetch” ile talepte bulunuyor. Bu aşamada kaçıncı adresi kullandığını da ekrana logluyor.

Şimdi de sıra geldi uygulamamızdaki bu sınıfı kullanacak kısma. Aşağıdaki gibi basit bir sınıf işimizi görecektir.

import {LoadBalancer} from "./load-balancer";

export class Consumer1 {

    public static run(): void {

        const loadBalancer: LoadBalancer = new LoadBalancer();

        setInterval(async () => {

            const responseData = await loadBalancer.callEndpoint();

        }, 1000);
    }
}

Bu sınıfımız da oldukça basit. “setInterval” ile saniyede 1 kez az önce hazırladığımız “callEndpoint” metodunu çağırıyor.

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 {Consumer1} from "./consumer1";

Consumer1.run();

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

Tüm talepler her seferinde bir sonraki API adresine gönderiliyor ve sunucular arasında eşit dağıtılıyor. Talebi karşıladık ve günü kurtardık, bu iş bu kadar!

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ış?

Yanlışı görebilmek için belki de uygulamamızı biraz daha genişletmemiz gerekiyor. Farklı sınıflardan da bu Load Balancer’ı kullanacak yerler illa ki olacaktır. Projemize aşağıdaki içerikle “consumer2.ts” isimli bir dosya daha ekleyelim. Bu dosya hemen hemen “consumer1.ts” dosyamızın aynısı olacak. Sadece 1 sn yerine 600 ms’de bir çağrım yapacak.

import {LoadBalancer} from "./load-balancer";

export class Consumer2 {

    public static run(): void {

        const loadBalancer: LoadBalancer = new LoadBalancer();

        setInterval(async () => {

            const responseData = await loadBalancer.callEndpoint();

        }, 600);
    }
}

Ayrıca “app.ts” dosyamızın içeriğini de aşağıdaki şekilde değiştirelim.

import {Consumer1} from "./consumer1";
import {Consumer2} from "./consumer2";

Consumer1.run();
Consumer2.run();

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

Mükemmel dağıtımımız bir anda dağıldı. Kim nereye gidiyor, belli değil. Peki sebep nedir?

Sebep, “Consumer1” ve “Consumer2” sınıflarımızın birbirinden ayrı şekilde “LoadBalancer” sınıfının örneğini çıkartmasıdır. Her “loadBalancer” örneği kendi içerisinde ayrı birer dizi ve sayaç barındırır ve birbirinden habersiz şekilde çalışır. Dolayısıyla farklı birimlerdeki çağrımları bu şekilde ardışık sıraya sokmak mümkün değildir.

Bunun yerine tek bir “loadBalancer” nesnesi “app.ts” dosyası içerisinde tanımlanıp, Dependency Injection ile “Consumer1” ve “Consumer2” sınıflarına geçilebilir ve her iki sınıfın da aynı nesneyi kullanması sağlanabilir. Ancak bu yöntem, Separation of Concerns tasarım prensibine aykırıdır. Load Balancer görevi “LoadBalancer” sınıfının konusudur. “app.ts” kısmının bu işle ilgilenmemesi, hatta konudan haberinin bile olmaması gerekir. Bu bilgi dışarı çıkarsa, Encapsulation prensibini de çiğnemiş oluruz.

Bu prensipleri çiğnediğimiz taktirde, LoadBalancer’ı kullanacak tüm yazılım birimlerinin, onun bir nesne örneğini çıkartıp kullanmaması, bunun yerine daha üst katmandaki bir yerden bu nesneyi alarak kullanması gerektiğini bilmesi şart hale gelecektir. Biz bunu istediğimiz kadar dokümante edelim, doküman okumayı çok sevmesine rağmen vakit bulamadığı için okuyamayan ekip arkadaşlarımız da illa ki olacaktır!


Nasıl Bir Çözüm Sunulabilirdi?

Bir sınıfın tüm uygulama boyunca yalnızca tek bir örneğinin oluşturulabilmesini garanti eden Singleton tasarım kalıbı bu problem için güzel bir öneri olabilir. “load-balancer.ts” dosyamızın içeriğini aşağıdaki şekilde değiştirelim.

import endpoints from "./endpoints.json";

export class LoadBalancer {

    private static instance: LoadBalancer;
    private endpoints: string[];
    private currentIndex: number;

    private constructor() {

        this.endpoints = endpoints;
        this.currentIndex = 0;
    }

    public static getInstance(): LoadBalancer {

        if (!LoadBalancer.instance) {

            LoadBalancer.instance = new LoadBalancer();
        }

        return LoadBalancer.instance;
    }

    public async callEndpoint(): Promise<any> {

        if (this.endpoints.length === 0) {

            throw new Error("Kayıtlı Endpoint bulunamadı");
        }

        const currentEndpoint = this.endpoints[this.currentIndex];

        console.log(`Kullanılan Endpoint: (${this.currentIndex}), ${currentEndpoint}`);

        this.currentIndex = (this.currentIndex + 1) % this.endpoints.length;

        return fetch(currentEndpoint);
    }
}

Sınıfımızda yaptığımız ilk değişiklik constructor’ı “private” olacak şekilde değiştirmek oldu. Böylece bu sınıfın dışarıdannew” kelimesi ile örneğinin çıkartılmasını engelledik.

Bunun yerine sınıf içerisinde “instance” isminde static bir nesne tanımladık ve yine statik olan “getInstance” metodu çağırıldığında bu nesnenin daha önceden oluşturulup oluşturulmadığını kontrol ettik. Eğer oluşturulmadıysa, bu metodun ilk çağrımı anlamına geldiğinden, sınıfın yeni bir örneğini çıkarttık ve bunu sınıfın kendi içerisindeki statik değer içerisinde sakladık. Ardından da bu nesneyi çağrımı yapan yere döndük.

Uygulamadaki farklı bir sınıftan “getInstance” metodu tekrar çağırıldığında, yine bu nesnenin daha önceden oluşturulup oluşturulmadığını kontrol edilecektir ve oluşturulduğu görüleceğinden yeni bir nesne oluşturulmadan eski nesnenin dönmesi sağlanacaktır.

Böylece “getInstance” metodu her nereden, kaç kez çağırılırsa çağırılsın, uygulama kapatılana kadar ilk çağrımda oluşturulan statik nesnemiz geri dönülecektir. Bu da sınıfın uygulama boyunca yalnızca tek bir örneğinin oluşturulmasını garanti altına alacaktır.

Şimdi de “consumer1.ts” ve “consumer2.ts” sınıflarımızın içeriğini aşağıdaki şekilde değiştirelim.

import {LoadBalancer} from "./load-balancer";

export class Consumer1 {

    public static run(): void {

        const loadBalancer: LoadBalancer = LoadBalancer.getInstance();

        setInterval(async () => {

            const responseData = await loadBalancer.callEndpoint();

        }, 1000);
    }
}
import {LoadBalancer} from "./load-balancer";

export class Consumer2 {

    public static run(): void {

        const loadBalancer: LoadBalancer = LoadBalancer.getInstance();

        setInterval(async () => {

            const responseData = await loadBalancer.callEndpoint();

        }, 600);
    }
}

Burada çok küçük bir değişikliğimiz var. “LoadBalancer” sınıfının “new” ile yeni bir örneğini çıkartmak yerine (Ki constructor private olduğu için bunu yapmamız artık mümkün değil) “getInstance” metodunu çağırarak nesnenin bize verilmesini sağlıyoruz. Ardından da eskisi gibi nesnedeki fonksiyonu çağırmaya devam ediyoruz.

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

Çağrımlar farklı yerlerden de yapılsa, her seferinde aynı nesne tarafından işlendiği için sıralamada bir sapma yaşanmaz. Daha da önemlisi, bu yöntemle birlikte Load Balancer ile ilgili kodlar “LoadBalancer” sınıfı içerisinde kalmıştır. Uygulamanın geri kalanının, tek bir fonksiyon çağırmak haricinde konu ile ilgili bir bilgisi veya görevi yoktur. Separation of Concerns ve Encapsulation prensiplerine uyulmuştur.

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

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

Herkese iyi çalışmalar…

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

Bir Yorum Yaz