Gerçek Senaryolarla CQRS Nedir?
Geçen haftalarda İstanbul Teknopark topluluğu ile CQRS üzerine bir meetup düzenlemiştim. Orada bahsettiğimiz konuları bir de yazıya dökmek istedim. CQRS’e sıfırdan girip amacı, faydaları, doğru kullanım şekillerinden bahsedeceğim, sonunda ise bir örnek üzerinden küçük bir demo çalışması yapacağız. Zaten yazının sonunda hem projenin hem de meetup’ın linkleri bulunmaktadır. Baştan uyarıyorum Uzun bir yazı bizi bekliyor ama dayanabilirseniz sizin için faydalı olacağını umuyorum :)
Son yıllarda microservice, DDD gibi kavramlarla birlikte CQRS’in popülerliği ve kullanımı artsa bile aslında geçmişi çok daha eskiye dayanıyor.
1980'li yıllarda CQS kavramı ile ortaya atılan ardından 2000'li yılların başında CQRS olarak şekillenen bu kavramın ana fikri, model üzerinde gerçekleştirdiğimiz işlemleri iki kategoriye bölmemiz üzerinedir.
CQS(Command-Query Separation) Nedir?
1988 yılında Bertrand Meyer tarafından ortaya atılan kavramın temelinde yatan prensip aslında çok basit. Bir veri/state üzerinde değişiklik(create, update, delete) gerçekleştirecek işlemler ile veri/state üzerinde bir değişiklik yapmadan sadece okuma işlemi yapacak işlemleri birbirinden ayrılması gerektiğini söylemektedir. Kısacası Write ve Read işlemlerini birbirinden ayrılması gerekmektedir. Write işlemlerini Command’lar üzerinden read işlemlerini ise Queryler üzerinden yapmalıyız. Peki query ve command nedir?
Query: Sadece read işlemleri için kullanılır. Veri üzerinde herhangi bir değişiklik yapmaz ve geriye ilgili verinin DTO modelini döner.
Command: Write(Create/Update/Delete) işlemleri ile data/state üzerinde değişiklik yapılacağı zaman kullanılmalıdır. Geriye herhangi bir değer dönmemektedir.
Bu sayede artık tek bir dto model üzerinden tüm işlemlerimizi gerçekleştirmeyeceğiz her işlem sadece ihtiyacı olan propertyleri barındıran dto modeller üzerinden işlem gerçekleştirecektir. Böylelikle SoC(Separation of Concerns) ilk adımımızı atmış bulunuyoruz.
SoC(Separation of Concerns) farklı işlere/amaçlara yönelik kullanılan bölümlerin birbirinden ayrılması diyebiliriz. Bunu bir model için de uygulayabilirsiniz genel olarak mimariniz için de uygulayabilirsiniz.
Tabi CQS ile sadece DTO modellerimizi ayırmıyoruz application katmanımızdaki methodlarımızı da ayırıyoruz.
CQRS(Command Query Responsibility Segregation) Nedir
2005 yılında Martin Fowler, CQS her zaman uygun olmayabilir diyerek bir örnek veriyor. Create amaçlı bir command işlemi gerçekleştirildikten sonra response olarak verinin Id bilgisinin dönülmesi gerektiğini ve bu id bilgisi ile query isteğinde bulunabileceğini dile getiriyor. Gerçek hayattaki örneklere de baktığımızda write işlemlerini one-way düşünemeyiz çünkü validasyon kontrollerinden veya herhangi bir sebepten dolayı error message dönmemiz gerekebilir ya da başarılı bile gerçekleşse id bilgisini dönmemiz gerekir.
CQS’in eksiklerini kapatarak 2010 yılında Greg Young tarafından CQRS kavramı ortaya atılmıştır. Her iki patternin temel amacı aynı write ve read işlemlerini birbirinden ayırmak.
Ekstradan sadece command ve query’leri ayırmakla kalmayıp api, domain model ve hatta veritabanlarını ayırabiliriz. CQRS uygularken 3 farklı aşamadan bahsedeceğiz ve orada bu ayrımları nasıl yaptığımızı ve ne avantaj sağladığından bahsedeceğiz.
Tabi ki CQRS tüm sorunlarımızı çözecek diye bir şey yok. Bir çok farklı pattern bulunmaktadır ve bunların her biri farklı bir soruna çözüm olarak ortaya atılmıştır.
Peki CQRS bize ne avantaj sağlıyor?
Scalability
Çoğunlukla Read işlemleri Write işlemlerine göre çok daha fazla gerçekleşmektedir 10x desek sanırım abartı olmaz. O zaman biz read işlemlerini ayrı bir API olarak ayırırsak scale edeceğimiz zaman sadece bu api’larımızı arttırmamız bize daha doğru bir kaynak tüketimi sağlamaktadır.
Performance
Servislerimizi doğru bir şekilde scale edebilmemiz zaten performansımızı etkileyecektir. Bunun dışında write ve read işlemlerinin temelde farklı şeylere ihtiyacı vardır. Örneğin Write işlemlerinde Relational Database tercih edilirken Read işlemlerinde NoSQL veritabanları üzerinden veriye erişmek çok daha hızlı olmaktadır. Veya aynı veritabanı üzerinden işlem gerçekleştirmek istedeğimizde bile read/write işlemleri için farklı data structure’lar oluşturabilir performansımızı arttırabiliriz. Ayrıca bize Loosely Coupled bir yapı sunmaktadır.
Loosely Coupled, modellerin/servislerin birbirine olan bağlılığının azalmasıdır.
Simplicity
CQS kavramından bahsederken Seperation of Concerns sağladığını bahsetmiştik. Bu sayede ürününüz yeni feature’lar ile olgunlaştığı zaman kaostan, karmaşık businesslardan daha uzak olacaktır. Bunun dışında SOLID prensiplerine daha da yaklaşıyoruz. Mesela her bir command veya query sadece tek bir işlemi gerçekleştirmek amaçlı bölünmektedir. Bunun detaylarına task based’e değineceğimiz zaman tekrar konuşacağız.
Effective Teamwork
Command/Query’leri ve buna bağlı olarak modellerinizi/validasyonlarını vb. doğru bir şekilde parçaladığınızda godclasslardan kurtulmuş olacaksınız böylelikle aynı domain üzerinde geliştirim yaparken çok daha rahat bağımsız bir şekilde ilerleyebilirler. Conflictsiz günler bizleri bekler :)
Peki dezavantajı yok mu? Tabi ki var öncelikle geleneksel(n-tier mimari vb.) mimarilere göre çok daha kompleks bundan dolayı hızlı bir şekilde mimariyi tasarlayıp ürün geliştirmek istiyorsanız size külfet gibi gelebilir. Ama ilerleyen zamanlarda karşılaşacağınız bir çok kompleks businesslardan ve god classlar/methodlar ile uğraşmıyor olacaksınız.
CQRS tek başına bahsetmek yeterli olmuyor tabi çoğunlukla Mediator ve Event Sourcing ile anlatılmaktadır. Peki bu patternleri kullanmadan CQRS’i kullanamaz mıyız tabi ki kullanırız ama daha efektif olması için birlikte bahsedilmektedir. Yapacağım örnek uygulamada olmayacağı için ve kapsamı çok genişletmemek için Event Sourcing’den bahsetmeyeceğim (ayrı bir yazıda inceleriz) ama Mediator’a kısa bir değinebiliriz.
Mediator
Objeler arasındaki kompleks/karmaşık dependency’leri yok etmek için ortaya atılmış bence önemi çok büyük bir kavram. Mediator kavramı genellikle örnek olarak uçak ve kule üzerinden atılmaktadır ben de bu geleneği bozmayayım :) Havalimanında 10 uçak olduğunu varsayalım ve bunlar iniş kalkışlarda birbiri ile iletişim kurup mesajlarını birbirine iletmek istediği zaman kaos çıkacağını tahmin edebiliriz. İşte burada iletişim görevini kule üstlenmektedir. 10 uçak birbirleri ile iletişim kuracağı zaman tek bir kanal üzerinden ileterek koas’u engelleyebilir. Mediator kavramı da objelerimiz arasında kule görevi görmektedir. Bize sağladığı en büyük avantaj ise dependecy’leri azaltıyor loosely coupled bir sistem bize sunuyor.
Örnek CQRS projemizde command-command handler ve query-query handler arasındaki ilişki mediatR kütüphanesini kullanarak sağlayacağız.
CQRS uygularken 3 farklı aşama şeklinde gideceğimizden bahsetmiştik.
Separate Read/Write API
Read(command) ve Write(query) işlemlerimizi iki farklı api üzerinde ayırırız, avantajlardan bahsederken söylediğimiz scalability özelliğimizi kazanmış oluruz. Böylelikle read işlemleri için 10 tane api ayağa kaldırırken write işlemleri için 1 tane api ayağa kaldırıp ihtiyacımıza göre kaynaklarımızı doğru bir şekilde kullanabiliriz.
Şuana kadar CRUD base üzerinden yazılım geliştirmeye yönelik konuştuk ama bu aşamada bakış açımızı biraz daha değiştirmemiz gerekiyor. Command ve Query’leri ayırırken her biri bir amaca hizmet edecek demiştik. Aşağıdaki örnek üzerinden ilerleyerek CRUD base’den Task base geçiş aşamasını inceleyelim ve neden buna ihtiyacımız olduğunu örnek ile görelim.
Kendi çalıştığım sektörden bir örnek vermek istedim. Bir depodaki stok bilgisi üzerinde update işlemi yapmak istediğimiz zaman farklı farklı businessrule kontrolleri yapmamız gerekmektedir.
Örneğin lokasyon bilgisini değiştirecekseniz bu ürün değiştireceğiniz lokasyon için uygun mu? Bunun ayrı bir iş süreci var ve bu işlem aslında stock update yerine transfer olarak ayrı bir işlem olması daha açıklayıcı oluyor.
Veya stoktaki ürünün adet miktarını değiştirmek istediğinizde depoya o üründen giriş yapılmış ya da o üründen satış yapılmış olması gerekmektedir. Ve ürün giriş/satış sürecinin de yine kendine ayrı kuralları validasyonları bulunmaktadır. Sonuç olarak aslında UpdateStock işlemi dahilinde gerçekleştirdiğimiz farklı farklı işler bulunmaktadır. Biz bunu aşağıdaki gibi 2 ya da daha fazla şekilde parçalayabiliriz.
UpdateStock (Sadece stok statü bilgisini veya o stok kaydına özgün bazı özellikleri değiştirebilir.)
Transfer (Stoğun bulunduğu lokasyon bilgisini değiştirir ve transfer edilecek lokasyona yönelik validasyonları gerçekleştirir.)
EntryProduct (Stoğa mal kabul işlemleri gerçekleştirir validasyonlardan geçerse stoktaki ürün miktarını arttır.)
SaleProduct (Stoktaki malın satışı gerçekleşir ve validasyonlardan geçerse stoktaki ürün miktarı azalır.)
Bu yaptığımız ayrım sayesinde işlemlerimizi CRUD base düşünmek yerine Task base düşünmeye başladık. Böylelikle sadece read/write işlemlerini ayırmayıp commandlarımızı sadece tek bir işten sorumlu olacak şekilde doğru bir şekilde parçalamayı başardık. Tabi ki bize tek kazancı bu değil Ubiquitous Language güçlendiriyoruz.
Ubiquitous Language, projedeki servislerin domainde bir karşılığı olması gerekiyor. Böylelikle projede yer alan herkes bu ortak dili konuşabilir gerçekleştirilecek işlem hakkında fikir sahibi olabilir. Örneğimizde olduğu gibi stoğun lokasyonu değişeceği zaman updatestock yerine transfer dediğimizde ya da stoğa ürün girip miktarı artacağı zaman updatestock yerine EntryProduct dediğimizde domain üzerine çalışan herkesin anlayabileceği ortak bir dilimiz oluyor.
Separate Read/Write Model
Bir önceki aşamada api’leri read/write işlemleri olmak üzere iki parçaya ayırdık hatta bununla birlikte doğru command’lar elde etmek için CRUD base’den Task base düşünmeye başladık. Tabi bu yeterli değil aslında işlerimizi task bazlı böldüğümüzde modellerimizi değiştirmemiz gerektiğini de anlıyoruz. Bize ister istemez domain modellerinin de ayrılması gerektiğini gösteriyor. Read/Write modellerimizi ayrırak aslında en başta bahsettiğimiz gereksiz property kullanımları ve god classlardan kurtuluyoruz. Her bir işlem ihtiyacına yönelik modeller oluşturuyor olacak. Açıkçası bu iki aşamayı ben birbirinden ayrı düşünemiyorum.
Separate Read/Write Database
Veritabanlarını ayırmak işleri daha da kompleks hale getiriyor, biraz daha ihtiyaçlara göre değişecek bir karar diyebiliriz. Bu başlık altında Tek Veritabanı ve Ayrılmış Veritabanları olmak üzere iki alt başlık üzerinden incelemeye devam edeceğiz.
Tek Database
Stock ile ilgili read/write işlemlerimizi ayırdık artık projemiz daha basit anlaşılır kaostan uzak bir yapıda ayrıca kaynaklarımızı doğru bir şekilde kullanıp scale edebiliyoruz. Sorgularımızı aynı veritabanına attığımızda (RDBMS olduğunu varsayıyorum) çok fazla joinlerimiz olabiliyor ve bu sorgularımızı hızlandırmak için index atıyoruz. Ama indexler write işlemlerimizi yavaşlatıyor sebebi ise siz bir kayıt eklediğinizde ya da düzenlediğinizde index için oluşturulan virtual table’lar üzerinde de modifikasyon yapıyor.
Kısacası write işlemleri için ne kadar index o kadar efor oluyor. Peki avantajı yok mu? Tabi ki var. Consistincy için ekstra bir efor harcamanız gerekmiyor. Ayrıca isterseniz veritabanınızda read işlemleri için ayrı bir tablo oluşturup write işlemleri sonrasında hem ilgili tabloyu hem de read için oluşturduğunuz diğer tabloyu besleyebilirsiniz.
Madem böyle de ilerleyebiliyoruz neden işi daha da kompleks haline getirip veritabanlarını ayırıyoruz?
Çoklu Database
Performans açısından read işlemleri için NoSql önerilmektedir ve kesinlikle RDBMS’lere göre okuma işlemlerinde çok daha hızlı. Ama bir türlü RDBMS’lerden de kopamıyoruz çünkü karmaşık domainlerde tablolar arası ilişkilere ihtiyacımız oluyor. Aslına bakarsanız veritabanı aşamasında da read ve write işlemlerinin beklentileri/ihtiyaçları birbirinden farklı bundan dolayı biz de her birinin ihtiyacına yönelik veritabanı kullanabiliriz.
Write işlemleri için kullanacağınız db tek bir adet olur ve master db bu veritabanınız olur. İsterseniz yine read işlemleri için RDBMS kullanıp replica oluşturabilirsiniz ama burada önemli bir detay var data structure yapıları birbirinden farklı olacaklar ve her iki tarafın da ihtiyacına yönelik tabloları olması gerekmektedir.
Sonuç olarak read db tercihinde RDBMS ya da NoSql karar verdik ve ayırdık peki read veritabanını nasıl besleyeceğiz consistincy sorununu nasıl ortadan kaldıracağız?
Bunun için bir çok yöntem var en basitinden snapshot bile alabilirsiniz ya da daha çok tercih edilen Projection yapabilirsiniz.
Projections
Şahsen benim için tanım ile açıklaması zor ama temelinde bir değişiklik yaptığınız zaman bu değişikliğin diğer ortamlara(bizim için veritabanı) yansıtılmasını sağlamaktadır. Genellikle Event Sourcing ile birlikte anlatılmaktadır sebebi ise CQRS’de must değil ama Event Sourcing pattern kullanacaksanız must. Projection için Event-driven ve State-driven olmak üzere 2 farklı yöntem vardır.
- State-Driven, ilgili veriniz üzerinde bir değişiklik yapıldığında bunun bilgisini tutabileceğiniz bir flag ile saklayabilirsiniz. Bu flag ile isterseniz sync/async diğer db’lerdeki değişikliği yapacak requesti/event’ı gönderebilirsiniz. Ya da database trigger gibi çözümler uygulayabilirsiniz.(Şahsen benim kaçındığım bir yöntem)
Tabi sync yapmak isteseniz response sürenizi uzatacaktır ama bir yandan imediately consistent’ı garanti ediyorsunuz. - Event-Driven, genellikle Event Sourcing ile birlikte kullanılmaktadır. İlgili command başarılı bir şekilde işlendikten sonra event fırlatılır. Event’lar genelde bir bus sistemi üzerinde tutulur. Eventları yakalayan handler/consumer’lar ise read işlemleri için kullanılan rdbms/nosql veritabanlarını beslerler.
Peki ya Consistency? yukarıda işlem(process’in kapsamına göre değişir) çok uzun sürmeyecektir ama yapılan değişiklik diğer db’lere(read) anında yansımayıp belli bir süre sonra kesinlikle yansıyacaktır. Biz de buna Eventual Consistency diyoruz.
Son olarak CAP teoreminde 3 koşulu aynı anda sağlamanız imkansızdır ama CQRS ile db leri ayırdığınızda her iki tarafın da (read/write) ihtiyaç duyduğu 2 koşulu sağlayabilir. Böylelikle read işlemleri write işlemlerine ya da tam tersi birbirine dezavantaj sağlamamış olur.
Bana teori verme, bana pratik göster!
Sıra geldi örnek uygulamamıza. Öncelikle her bir katmanın özelliklerinden hiyerarşisinden bahsedeceğim. Ardından örnek bir case inceleyerek sürecin nasıl ilerlediğini göreceğiz.
Öncelikle 2 web api servisimiz ve bir tane consumer’ımız bulunmaktadı.
ReadApi, sadece okuma işlemlerimizi yaptığımız endpointlerimizin yer aldığı controller’ımız bulunmaktadır.
Features klasörünün altında response modellerimizi ve request model olarak kullandığımız Query’ler bulunmaktadır. Ayrıca bu query’leri yakalayıp gerçekleştirilecek eylemin yapıldığı bölüm handlerlarımız bulunmaktadır. Kısacası read işlemi için oluşturulan endpointlerimiz ve Application katmanımız burada yer almaktadır.
ReadDomain, ReadApi projesindeki application katmanı tarafından erişilen domainler burada bulunmaktadır. ReadDomain katmanına sadece ReadApi ve ProjectionConsumer erişebilmektedir.
WriteApi, sadece yazma işlemlerini gerçekleştiren endpointlerimiz ve bu endpointlerin kullandığı application katmanı yer almaktadır. Bu Application katmanında ise command’larımız ve commandları yakalayıp işlemin yapılacağı handler’lar yer almaktadır. Ayrıca commandlar için validasyon kontrollerinin yapıldığı fluent validation kütüphanesi kullanılarak oluşturulan validator’lar yer almaktadır. Ayrıca write işlemlerinde kullandığımız response model(sadece id bilgisini içermektedir) yer alıyor.
WriteDomain, katsamında ise ORM çatısı için oluşturulan Entity’ler yer almaktadır. ORM çatısı için EFCore kullandım. WriteDomain katmanına sadece WriteApi ve ProjectionDomain erişebilmektedir.
Core, katmanı diğer katmanlar tarafından kullanılan ortak enumlar, modeller, servisler vb. bulunmaktadır. Burada modelleri mapleyebilmek için AutoMapper kullanılmıştır. Read işlemlerinin daha hızlı bir şekilde yapılabilmesi için RDBMS yerine Redis tercih edilmiştir. Burada önemli bir nokta var tüm read işlemlerinizi NoSql veritabanları üzerinden yapmanız belli bir yerden sonra işinizi zorlaştırabilir. Burada simple sorgular için (getById, getList, getByBlaBla vb.) NoSql diğer kompleks karmaşık read işlemleriniz için RDBMS tercih edebilirsiniz. Ama RDBMS write veritabanı ile aynı değil ayrı olması daha uygun olur çünkü data structure farklı olacak ve ihtiyaç olan index’ler farklı olacaktır.
RabbitMq kullanılmıştır çünkü write işlemi gerçekleştirildikten sonra kuyruğa event fırlatılmaktadır. Bu event read işlemlerinin yapıldığı veritabanlarını beslemek amaçlı oluşturulan consumer’ın işleyeceği mesajlardır.
ProjectionConsumer, aslında daha deminde değindiğim gibi write işlemleri başarılı bir şekilde gerçekleştikten sonra kuyruğa Event fırlatılmaktadır. Bu Event’ı ProjectionConsumer yakalayıp işlemektedir. Temel görevi write modeli Redise kaydedilecek modele çevirip redise kaydetmekdir. Böylelikle senkronizasyon sağlanmaktadır.
Command, bir eylem gerçekleştirilmeden önce fırlatılan mesajdır. O eylemin gerçekleştirilmesi amacıyla fırlatılır. Örneğin stok transferi gerçekleştirmek amacı ile fırlatılan StockTransferCommand’ı düşünebiliriz.
Event, bir eylem gerçekleştirildikten sonra fırlatılan mesajdır. Eylemden sonra yapılacak işlemler için kullanılır örnek olarak stok transferi gerçekleştirildikten sonra fırlatılan StockTransferedEvent düşünebiliriz.
Öncelikle write ardından bir read işlemi gerçekleştirip sürecin nasıl işlediğini görelim. Ürün satışı ile stoktan ürün çıkışını sağlayan SaleProduct endpointine istekte bulunuyoruz.
Dipnot: Özellikle gits örneği koymuyorum çünkü yazının sonunda örnek projenin github linkini bırakacağım.
Request model olarak Commandı kullandım fakat endpointinize gelen property’leriniz ile command içerisindeki property’leriniz aynı değil ise ayrı bir request model oluşturup ardından o ara süreçteki işlemlerinizi gerçekleştirip(parse işlemi vb.) command’a mapleyebilirsiniz.
Ardından command’ı mediator nesnesine gönderiyoruz ve ilgili command handler yakalayıp business için gerekli olan işlemleri gerçekleştiriyor.
Burada stockEntity(WriteDomain katmanında yer alıyor) üzerindeki methodlarımızdan yararlanıyoruz. Ortak olarak kullanacağımız fonksiyonları mümkün olduğunca domain modelleri üzerindeki methodlar ile yaparsak kod tekrarından kurtuluruz.
Son olarak ise savechange işlemini gerçekleştirip write işlemleri için kullandığımız RDBMS veritabanına değişikliklerimizi yansıtıyoruz. Ardından başarılı bir şekilde SaveChange işlemini gerçekleştirdikten sonra bahsettiğimiz Event nesnesini fırlatıyoruz. SaveChange işleminden sonra yapma sebebimiz ise Event’lar gerçekleştirilecek eylem başarılı bir şekilde sonlandıktan sonra fırlatılmalıdır.
Kuyruğa fırlatılan bu mesajımızı ProjectionConsumer yakalıyor. Model içerisindeki propertyleri read modele uygun bir şekilde mapledikten sonra redis’e(read işlemlerini buradan yapacağız) kaydediyor.
Sıra geldi Read işlemine. Stok bilgimize erişmek için kullandığımız ReadApi servisinde yer alan stocks/{id} endpointine istekte bulunuyoruz. Benzer mantıkta Query’imizi oluşturup fırlatıyoruz. Ardından ilgili handler tarafından yakalanıp redis üzerinden id(redis üzerindeki key bilgimiz) ile ilgili modeli getirip endpointe geri dönüyor.
Son olarak iki önemli noktayı vurgulamak isterim. CQRS özelikle Application Layer katmanına yönelik getirilmiş bir çözümdür. Bir diğeri ise veritabanlarını ayırmanız şart değil ihtiyacınıza göre bunu belirlemelisiniz ve eğer ayıracaksanızda Read tarafında yine ihtiyaca yönelik bir çok db tercih edebilirsiniz.
Uzun bir yazı oldu umarım yararlı olmuştur. Projenin ve daha detaylı bilgi edinmek isterseniz meetup’ın linkini aşağıya bırakıyorum.