Flutter Uygulama Mimarisi: Repository Pattern 💫 🌌 ✨

gulsenkeskin

Gülsen Keskin

Posted on January 27, 2022

Flutter Uygulama Mimarisi: Repository Pattern 💫 🌌 ✨

Not: Bu yazı Andrea Bizzotto'nun makalesinin Türkçe çevirisi niteliğini taşır. Orijinal içeriğe linkten ulaşabilirsiniz.

Tasarım kalıpları, yazılım tasarımında sık karşılaşılan sorunları çözmeye yardımcı olan kullanışlı şablonlardır.

Uygulama mimarisi (app architecture) söz konusu olduğunda, yapısal (structural) tasarım kalıpları, uygulamanın farklı bölümlerinin nasıl düzenlendiğine karar vermemize yardımcı olabilir.

Bu bağlamda, backend API gibi çeşitli kaynaklardan veri nesnelerine (data objects) erişmek ve bunları uygulamanın domain katmanına (iş mantığımızın yaşadığı yer olan) type-safe (tür güvenli) varlıklar olarak kullanılabilir hale getirmek için repository pattern'ı kullanabiliriz.

Ve bu makalede repository pattern'ı ayrıntılı olarak öğreneceğiz.
• Nedir ve ne zaman kullanılır?
• Bazı pratik örnekler
• Abstract (soyut) ve concrete (somut) sınıfları

Repository Design Pattern'ı Nedir?

Bunu anlamak için aşağıdaki mimari diyagramı ele alalım:

Image description

Veri katmanında (data layer) repositori'ler bulunur. Ve onların işi:

• Domain modellerini (ya da entities) veri katmanındaki (data layer) veri kaynaklarının uygulama ayrıntılarından ayırmak,

• Veri aktarım nesnelerini (data transfer objects), domain katmanı tarafından anlaşılan doğrulanmış varlıklara (entities) dönüştürmek,

• (isteğe bağlı olarak) data caching (veri önbelleğe alma) gibi işlemleri gerçekleştirmektir.

Yukarıdaki şema, uygulamanızın mimarisini oluşturmanın birçok olası yolundan yalnızca birini göstermektedir. MVC, MVVM veya Clean Architecture gibi farklı bir mimariyi takip ederseniz işler farklı görünecektir, ancak aynı kavramlar geçerlidir.

Ayrıca widget'ların , iş mantığı (business logic) veya ağ koduyla hiçbir ilgisi olmayan sunum katmanına (presentation layer) nasıl ait olduğuna da dikkat edin.

Widget'larınız doğrudan bir REST API'sinden veya uzak bir veritabanından key-value çiftleriyle çalışıyorsa, yanlış yapıyorsunuz demektir. Başka bir deyişle: iş mantığını (business logic) UI kodunuzla karıştırmayın. Bu, kodunuzu test etmeyi, hata ayıklamayı ve nedenini bulmayı çok daha zor hale getirecektir.

Repository Pattern Ne Zaman Kullanılır?

Uygulamanızın, uygulamanın geri kalanından izole etmek istediğiniz yapılandırılmamış verileri (unstructured data) (JSON gibi) döndüren birçok farklı uç noktaya sahip karmaşık bir veri katmanı varsa, repository pattern çok kullanışlıdır.

Daha genel olarak, repository pattern'in en uygun olduğu birkaç kullanım örneği:

• REST API'leri ile çalışmak
• Yerel(local) veya uzak(remote) veritabanlarıyla çalışmak (örn. Sembast, Hive, Firestore, vb.)
• Cihaza özel API'lerle çalışmak(ör. izinler, kamera, konum vb.)

Bu yaklaşımın büyük bir yararı, kullandığınız herhangi bir 3. taraf API'sinde son derece önemli değişiklikler olması durumunda, yalnızca repository kodunuzu güncellemeniz gerekmesidir.

Öyleyse onları nasıl kullanacağımızı görelim! 🚀

Örnek olarak, OpenWeatherMap API'sinden hava durumu verilerini çeken basit bir Flutter uygulamasını inceleyeceğiz.

API belgelerini okuyarak, JSON formatındaki bazı yanıt verileri örnekleriyle birlikte API'yi nasıl çağıracağımızı öğrenebiliriz.

Repository pattern, tüm ağ ve JSON serileştirme kodunu soyutlamak için harikadır.

Örneğin, repository için interface tanımlayan abstract(soyut) bir sınıf:



abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
}



Enter fullscreen mode Exit fullscreen mode

WeatherRepository'nin yalnızca bir yöntemi vardır, ancak daha fazlası da olabilir (örneğin, tüm CRUD işlemlerini desteklemek istiyorsanız).

Önemli olan, repository'nin belirli bir şehir için hava durumunu nasıl alacağımıza dair bir sözleşme tanımlamamıza izin vermesidir.

http veya dio gibi bir ağ istemcisi (networking client) kullanarak gerekli API çağrılarını yapan somut (concrete) bir sınıfla WeatherRepository'i implemente ederiz.



import 'package:http/http.dart' as http;

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // tüm API ayrıntılarını tanımlayan özel sınıf
  final OpenWeatherMapAPI api;
  // API'ye çağrı yapmak için bir client
  final http.Client client;

  // yöntemi soyut sınıfta uygular
  Future<Weather> getWeather({required String city}) {
    // TODO: istek gönder, yanıtı ayrıştır, Hava durumu nesnesini döndür veya hata at
  }
}



Enter fullscreen mode Exit fullscreen mode

Tüm bu uygulama ayrıntıları, veri katmanıyla (data layer) ilgili endişelerdir ve uygulamanın geri kalanı bunları umursamamalı, hatta bilmemelidir.

JSON verilerini ayrıştırma

Elbette , API yanıt verilerini (response data) ayrıştırmak için JSON serileştirme koduyla birlikte bir Weather model sınıfı (veya entity ) tanımlamamız gerekecek:



class Weather {
  factory Weather.fromJson(Map<String, dynamic> json) {
    // TODO: JSON'u ayrıştır ve doğrulanmış Hava Durumu nesnesini döndür
  }
}



Enter fullscreen mode Exit fullscreen mode

JSON yanıtı birçok farklı alan içerebilirken, yalnızca kullanıcı arayüzünde (UI) kullanılacak olanları ayrıştırmamız gerektiğini unutmayın.

JSON ayrıştırma kodunu elle yazabilir veya Freezed gibi bir kod oluşturma paketi kullanabiliriz . JSON serileştirme hakkında daha fazla bilgi edinmek için Freezed Kullanarak Flutter'da JSON Nasıl Ayrıştırılır'a bakabilirsiniz.

Repository Initializing

Bir repository tanımladıktan sonra, onu başlatmanın ve uygulamanın geri kalanı için erişilebilir hale getirmenin bir yoluna ihtiyacımız var.

Bunu yapmak için kullanılan sözdizimi, seçtiğiniz DI/state management (durum yönettimi) çözümünüze bağlı olarak değişir.

İşte get_it kullanan bir örnek :



import 'package:get_it/get_it.dart';

GetIt.instance.registerLazySingleton<WeatherRepository>(
  () => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client(),
);


Enter fullscreen mode Exit fullscreen mode

İşte Riverpod paketinden bir sağlayıcı kullanan başka biri:



import 'package:flutter_riverpod/flutter_riverpod.dart';

final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
  return HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client());
});



Enter fullscreen mode Exit fullscreen mode

Flutter_bloc paketine dahilseniz , işte eşdeğeri :



import 'package:flutter_bloc/flutter_bloc.dart';

RepositoryProvider<WeatherRepository>(
  create: (_) => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client()),
  child: MyApp(),
))


Enter fullscreen mode Exit fullscreen mode

Sonuç aynıdır: Repository'nizi bir kez başlattığınızda, ona uygulamanızın herhangi bir yerinden (widget'lar, bloklar, conroller vb.) erişebilirsiniz.

Abstract (soyut) ve concrete(somut) sınıflar

Repository oluştururken sık sorulan bir soru şudur: gerçekten abstract bir sınıfa ihtiyacınız var mı , yoksa sadece concrete bir sınıf oluşturup tüm işinizi halledebilir misiniz?

İki sınıfa giderek daha fazla yöntem eklemek oldukça sıkıcı olabileceğinden, bu çok geçerli bir endişedir:



abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
  Future<Forecast> getHourlyForecast({required String city});
  Future<Forecast> getDailyForecast({required String city});
}

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // tüm API ayrıntılarını tanımlayan özel sınıf
  final OpenWeatherMapAPI api;
  // API'ye çağrı yapmak için client
  final http.Client client;

  Future<Weather> getWeather({required String city}) { ... }
  Future<Forecast> getHourlyForecast({required String city}) { ... }
  Future<Forecast> getDailyForecast({required String city}) { ... }
}



Enter fullscreen mode Exit fullscreen mode

Yazılım tasarımında sıklıkla olduğu gibi, cevap şudur:

Öyleyse, her yaklaşımın bazı artılarına ve eksilerine bakalım

Abstract (soyut) sınıfları kullanma:

• Repository'mizin arayüzünü karışıklık olmadan tek bir yerden görebiliriz.

• Repository'i tamamen farklı bir uygulama ile değiştirebiliriz.
(örneğin DioWeatherRepositoryyerine HttpWeatherRepository)

• Daha fazla ortak kod.

Yalnızca concrete (somut) sınıfları kullanma:

• Daha az ortak kod

• "jump to reference" (referansa atla), respository methodları yalnızca bir sınıfta bulunacağı için çalışır.

• Repository adını değiştirirsek farklı bir uygulamaya geçmek daha fazla değişiklik gerektirir (ancak VSCode ile tüm projedeki isimleri yeniden adlandırmak kolaydır).

Hangi yaklaşımı kullanacağımıza karar verirken, kodumuz için nasıl test yazacağımızı da bulmalıyız.

Repositoriy'lerle test yazma:

Abstract (soyut) sınıflar burada bize herhangi bir avantaj sağlamaz, çünkü Dart'ta tüm sınıflar örtük bir arayüze sahiptir (implicit interface) .

Bu, şunu yapabileceğimiz anlamına gelir:



// not: Dart'ta her zaman somut bir sınıf uygulayabiliriz
class FakeWeatherRepository implements HttpWeatherRepository {

  // hemen bir değer döndüren sahte bir uygulama
  Future<Weather> getWeather({required String city}) { 
    return Future.value(Weather(...));
  }
}



Enter fullscreen mode Exit fullscreen mode

Başka bir deyişle, testlerde repository'lerimize hazır yanıtlar vermek istiyorsak, abstract(soyut) sınıflar oluşturmaya gerek yoktur.

Aslında, mocktail gibi paketler bunu kendi avantajlarına kullanır ve biz de onları şu şekilde kullanabiliriz:



import 'package:mocktail/mocktail.dart';

class MockWeatherRepository extends Mock implements HttpWeatherRepository {}

final mockWeatherRepository = MockWeatherRepository();
when(() => mockWeatherRepository.getWeather('London'))
          .thenAnswer((_) => Future.value(Weather(...)));


Enter fullscreen mode Exit fullscreen mode

Testlerinizi yazarken, depolarınıza yukarıda yaptığımız gibi hazır yanıtlar verebilirsiniz.

Ancak başka bir seçenek daha var ve bu, temel alınan veri kaynağıyla ilişkilidir.

HttpWeatherRepository'nin Nasıl tanımlandığını hatırlayalım :



import 'package:http/http.dart' as http;

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // tüm API ayrıntılarını tanımlayan özel sınıf
  final OpenWeatherMapAPI api;
  // API'ye çağrı yapmak için istemci
  final http.Client client;

  // yöntemi soyut sınıfta uygular
  Future<Weather> getWeather({required String city}) {
    // TODO: istek gönder, yanıtı ayrıştır, Hava durumu nesnesini döndür veya hata at
  }
}


Enter fullscreen mode Exit fullscreen mode

Bu durumda, HttpWeatherRepository constructor'ına iletilen http.Client nesnesini taklit etmeyi seçebiliriz. Bunu nasıl yapabileceğinizi gösteren örnek bir test:



import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';

class MockHttpClient extends Mock implements http.Client {}

void main() {
  test('repository with mocked http client', () async {
    // setup
    final mockHttpClient = MockHttpClient();
    final api = OpenWeatherMapAPI();
    final weatherRepository =
        HttpWeatherRepository(api: api, client: mockHttpClient);
    when(() => mockHttpClient.get(api.weather('London')))
        .thenAnswer((_) => Future.value(/* some valid http.Response */));
    // run
    final weather = await weatherRepository.getWeather(city: 'London');
    // verify
    expect(weather, Weather(...));
  });
}


Enter fullscreen mode Exit fullscreen mode

Repository'lerin nasıl test edileceğini bulduktan sonra, abstract sınıflar hakkındaki ilk sorumuza geri dönelim.

Repository'ler abstract bir sınıfa ihtiyaç duymayabilir

Genel olarak, aynı arayüze uyan birçok uygulamaya ihtiyacınız varsa , abstract bir sınıf oluşturmak mantıklıdır.

Örneğin, hem StatelessWidget hem de StatefulWidget Flutter SDK'da abstract sınıflardır , çünkü alt sınıflara ayrılmaları amaçlanır .

Ancak repository'ler ile çalışırken, belirli bir repository için muhtemelen yalnızca bir implementasyona ihtiyacınız olacaktır.

Belirli bir repository için yalnızca bir implementasyona ihtiyacınız olacak ve bunu tek, abstract(somut) bir sınıf olarak tanımlayabilirsiniz.

En Düşük Ortak Payda

Her şeyi bir arayüzün arkasına koymak, sizi farklı yeteneklere sahip API'ler arasında en düşük ortak paydayı seçmeye itebilir.

Bir API veya backend, Akış tabanlı (Stream-based) bir API ile modellenebilen gerçek zamanlı (realtime) güncellemeleri destekler.

Ancak saf REST kullanıyorsanız (websocketler olmadan), yalnızca bir istek gönderebilir ve Future-based bir API ile en iyi modellenen tek bir yanıt (single response) alabilirsiniz.

Bununla başa çıkmak oldukça kolaydır: sadece stream-based bir API kullanın ve REST kullanıyorsanız tek değerli bir akış döndürün.

Ancak bazen daha geniş API farklılıkları olabilir.

Örneğin, Firestore transaction'ları ve toplu yazmaları (batched writes) destekler. Bu tür API'ler , genel bir arabirimin arkasında kolayca soyutlanamayacak şekilde, builder pattern'ı kullanır.

Repository'ler yatay olarak ölçeklenir

Uygulamanız büyüdükçe, belirli bir repository'e giderek daha fazla method eklediğinizi görebilirsiniz.

Bu, backend büyük bir API yüzeyine sahipse veya uygulamanız birçok farklı veri kaynağına (data source) bağlanırsa gerçekleşebilir.

Bu senaryoda, ilgili yöntemleri bir arada tutarak birden çok repository oluşturmayı düşünün. Örneğin, bir e-Ticaret uygulaması oluşturuyorsanız, ürün listeleme, alışveriş sepeti, sipariş yönetimi, kimlik doğrulama, ödeme vb. için ayrı repository'leriniz olabilir.

Basit tutun

Her zamanki gibi, işleri basit tutmak her zaman iyi bir fikirdir. Bu nedenle, API'lerinizi fazla düşünmekten kendinizi alıkoymayın.

Çözüm

Veri katmanınızın (data layer) tüm uygulama ayrıntılarını (örn. JSON serileştirme) gizlemek için repository modelini kullanın. Sonuç olarak, uygulamanızın geri kalanı (domain ve presentation-sunum katmanı) doğrudan type-safe model sınıfları/entities ile ilgilenebilir. Ayrıca kod tabanınız, bağımlı olduğunuz paketlerdeki değişiklikleri bozmaya karşı daha dirençli hale gelecektir.

resource

💖 💪 🙅 🚩
gulsenkeskin
Gülsen Keskin

Posted on January 27, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related