Günümüzde uygulama geliştirme dendiği zaman çoğumuzun aklına iki tip uygulamadan biri geliyor: Mobil uygulamalar veya Web uygulamaları. Bunu masaüstü uygulama piyasası öldü anlamında söylemiyorum tabi ki fakat eskisi kadar "hip" olmadığı da aşikar. Web uygulaması denince pek çok insanın aklına da tabi ki Ruby on Rails veya Django gibi dinamik dillerin geliştirme çerçeveleri geliyor. İşte bu dinamik ortamda, biraz son 4 yılımı Java ile web uygulamaları geliştirerek geçirmiş olmanın etkisiyle, biraz da son yıllarda, özellikle Java 8 ile birlikte, Java tabanlı geliştirme çerçevelerinin yeniden hareketlenmeye başlaması ile "neden insanları güncel platformlar hakkında bilgilendirmiyorum ki?" diye vazife uydurarak bu yazıyı yazmaya giriştim. Umarım faydası dokunur.
Bu devirde Java ile web uygulaması mı?
Pek çok insan için Java ile web uygulaması demek bolca XML, .war
, .ear
gibi tuhaf dosya uzantıları, gözünüzü bozacak uzunlukta URL'ler ve yavaş açılan sayfalar demek. Bu önyargı tabi ki desteksiz değil. Özellikle 2000'li yılların başında geliştirilen pek çok Java uygulamasının haline baktığınızda aşağıdaki şekilde geliştirildiğini varsaymak çok da yanlış değil.
Bugün hala o şekilde geliştirilen uygulamalar olsa da Java ekosistemi aslında gayet modern uygulamalar geliştirmeniz için gerekli bütün araçları içeriyor. Günümüzde Java ile gayet hızlı çalışan, güzel URL'lere sahip, dilediğiniz ortamda (uygulama sunucusu falan yüklemeden) kolayca çalıştırabileceğiniz web uygulamaları üretmek gayet mümkün. İşte bu seride Dropwizard kullanarak nasıl bu tip uygulamaları geliştirebileceğinizi inceleyeceğiz.
Konsept
Bu yazı dizisi boyunca yapacağımız şey Dropwizard kullanarak bir fazlamesai.net klonu inşa etmek. Tabi ki bu sadece eğitimsel bir belge olduğu için asıl fazlamesai.net'te bulunan pek çok özelliği içermeyeceğiz fakat bir oyuncak değil, kullanılabilir bir web uygulaması geliştireceğiz. Veri depolaması için Elasticsearch kullanacağız. Bunun temel sebebi Dropwizard'ın ontanımlı veri saklama kütüphanesi JDBI'ı pek sevmiyor ve Elasticsearch'u seviyor olmam. Ve tabi ki "buzzword dostu" olmak için uygulamayı microservice mimarisi kullanarak gerçekleştirecek ve servislerimizi Docker üzerinde çalıştırıp Docker Compose ile birbirine bağlayacağız. Uygulamayı tamamladığımız zaman aşağıdaki özelliklere sahip bir web uygulamasına sahip olmayı umuyoruz:
- Kullancılar kayıt olabilecekler
- Kayıtlı kullanıcılar yazı ve yorum yollayabilecekler
- Kayıtlı/kayıtsız bütün kullanıcılar yazıları ve yorumları okuyabilecekler
- Çirkin, ama gerçekten çok çirkin görünecek
Yazı boyunca ben geliştirme için IntelliJ İdea Community Edition kullanacağım ama içerik herhangi bir platforma bağımlı olmayacağı için siz dilediğiniz editör ile yazabilirsiniz.
Dropwizard nedir?
Spring Boot gibi "herşey dahil" geliştirme çerçevelerinin aksine Dropwizard aslında bir geliştirme çerçevesi değil. Spring Boot'un "herşey Spring olsun" yaklaşımının aksine Java eksosisteminde bir web uygulaması geliştirmek için ihtiyaç duyulabilecek parçaları bir araya getirip öntanımlı olarak birbirleriyle entegre olarak çalışmalarını sağlayarak kolayca geliştirme yapmanızı sağlayan bir "japon yapıştırıcısı". Tabi ki halihazırda bulunan araçların yetersiz kaldığı noktalarda dropwizard-metrics gibi kendi geliştirdikleri araçları da entegre etmekten çekinmiyorlar. Son olarak tabi ki kendi sundukları araçların yanında topluluğun da geliştirdikleri modüller ile yeni çıkan araçlarla entegre olması mümkün.
Gradle ile başlangıç
Kişisel olarak Java projelerinde maven kullanmayı tercih etsem de Gradle'in application eklentisi uygulamamızı paketlemeyi çok kolay kıldığı için Gradle kullanacağız. Microservice mimarisinde olacağımız için tabi ki birden fazla projemiz olacak ve bunları yanyana geliştirebilmek adına hepsini birlikte toparlayacak bir dizin yaratmak faydalı olacaktır. Ben geliştirme sürecinin tamamını fm-in-java
dizini içinde yürüteceğim.
fazlamesai.net'in en önemi yapısı tabi ki yazılar. O yüzden bizim yazı göndermemizi ve gönderilmiş yazıları okumamızı sağlayacak bir microservice ile başlamak mantıklı. Bu yüzden ilk projemiz olan posts-api
projesini yaratarak başlayabiliriz. Bunun için basitçe posts-api
dizinini yaratın ve bu dizine geçtikten sonra gradle init
komutu ile yeni bir Gradle projesi yaratabilirsiniz.
Şimdi ilk iş olarak build.gradle
dosyasını açalım ve içeriğini ihtiyacımıza göre düzenleyelim:
plugins {
// Bu bir Java projesi oldugu icin Java derleyicisi ile etkilesecegiz
id 'java'
// Eger IntelliJ Idea kullanmiyorsaniz bunu kaldirabilirsiniz
id 'idea'
// Bu eklenti uygulamamizi hos bir sekilde paketlememizi saglayacak
id 'application'
}
group 'net.fazlamesai'
version '1.0-SNAPSHOT'
// Tabi ki Java 8 kullanacagiz
sourceCompatibility = 1.8
// Uygulamamiz calistiginda baslangic olarak bu sinifin icindeki main metodu tetiklenecek
mainClassName = 'net.fazlamesai.posts.api.PostsApi'
repositories {
mavenCentral()
}
dependencies {
// Temel Dropwizard modulu
compile group: 'io.dropwizard', name: 'dropwizard-core', version: '1.0.5'
}
Evet, kod yazmaya hazırız!
Dropwizard'a giriş
Bir Dropwizard uygulaması yazmak oldukça kolay. İhtiyacınız olan sadece iki şey var: Uygulamanızın ana sınıfı ve uygulamanızın ayarlarını saklayacak olan sınıf. Bu iki sınıfı hazırladıktan sonra uygulamanız hazır olacaktır. Bizim durumumuzda ayarlar sınıfını şimdilik boş bırakabiliriz:
package net.fazlamesai.posts.api;
import io.dropwizard.Configuration;
public class PostsApiConfiguration extends Configuration {
}
Asıl işin döneceği sınıfımız ise PostsApi
sınıfı. Başlangıçta o da gayet basit:
package net.fazlamesai.posts.api;
import io.dropwizard.Application;
import io.dropwizard.setup.Environment;
// Uygulamamiz Dropwizard'in Application sinifindan turetilecek
public class PostsApi extends Application<PostsApiConfiguration> {
// Burasi Dropwizard uygulamalarinda asil sihirin gerceklestigi yer
@Override
public void run(final PostsApiConfiguration configuration, final Environment environment) throws Exception {
}
// Tabi ki JVM'in uygulamamizi calistirabilmesi icin bir main metoduna ihtiyacimiz var
public static void main(final String[] args) throws Exception {
// Uygulamamizin bir ornegini yaratip run metodunu calistirdigimizda
// sunucuyu ayaga kaldirmak icin gereken isleri Dropwizard bizim icin halledecektir
new PostsApi().run(args);
}
}
Bütün Dropwizard uygulamalarının başlangıç noktası işte bu Application
sınıfından türetilen sınıf oluyor. Application
sınıfının parametresi ise kulllanılacak konfigürasyon sınıfını belirliyor. Bu sayede Dropwizard uygulamanızın konfigürasyonunu otomatik olarak yükleyip size, sizin ihtiyaçlarınıza uygun bir şekilde sunuyor.
PostsApi
sınıfının run
metodu Dropwizard uygulamamızın başlangıç noktası olacak. Birazdan onun içini sunacağımız API ile doldurmaya başlayacağız fakat ondan önce dilerseniz uygulamamızı bir çalıştıralım: ./gradlew clean build
Bu size uygulamanın tar
ve zip
olarak paketlenmiş versiyonlarını build/distributions
dizini altında üretecektir. Dilediğiniz paketi açıp içinde ./bin/posts-api server
komutunu çalıştırarak sunucuyu ayağa kaldırabilirsiniz. curl http://localhost:8080
komutu ile henüz herhangi bir URL tanımlamadığımız için 404 hatasını görebilirsiniz. Haydi şimdi URL'lerimizin bir sonuç döndürmesini sağlayalım.
İlk URL'imizi tanımlamak
Dropwizard web uygulamalarını sunmak için Java ile REST uygulamaları geliştirmek amacıyla geliştirilmiş olan JAX-RS standardını, daha spesifik olmak gerekirse JAX-RS'ın standart implementasyonu olan Jersey'yi kullanır. Bunun anlamı: Eğer JAX-RS ile uygulama geliştirme deneyiminiz varsa daha başka hiçbir şey yapmadan Dropwizard ile uygulamalar geliştirmeye başlayabilirsiniz. Eğer JAX-RS ile hiç tecrübeniz yoksa da merak etmeyin, yazının devamında bu bilginin varolduğunu varsaymayacağız.
JAX-RS'te URL'leri uygulamamıza Kaynak (Resource) olarak tanımlanan sınıflar aracılığıyla bağlarız. Kaynak sınıfları herhangi bir Java sınıfından farklı değildir. Herhangi bir Java sınıfını JAX-RS işaretleri (annotation) ile işaretleyerek bir Kaynak haline getirebiliriz. Bu işaretler o sınıfın veya sınıfın metodlarının hangi URL'lerden sunulacaklarını, hangi HTTP metodlarına karşılık verecekleri, hangi URL parçalarının, hangi URL parametrelerinin hangi metod parametrelerine eşleştirileceğini tanımlar. Örneğin bizim Posts URL'imiz için şöyle bir sınıf ile başlayabiliriz:
package net.fazlamesai.posts.api.resources;
import javax.ws.rs.Path;
@Path("/posts")
public class PostsResource {
}
Bu sınıf her ne kadar henüz hiçbir metod içermese de @Path
işareti sayesinde /posts
altındaki bütün URL'ler bu sınıfa bağlanır. Şimdi bir adım daha öteye giderek bu URL'e bir istek geldiği zaman birşey döndürmesini sağlayacak bir metod ekleyelim. Örneğin:
// Bu metod HTTP GET isteklerine cevap verecegi icin @GET isaretini ekliyoruz
@GET
@Path("/ping")
public String ping(){
return "Pong!"
}
Son olarak tabi ki sistemin bu kaynağın varlığından haberdar olması için PostsApi
sınıfının run
metodunu güncellememiz gerekiyor:
@Override
public void run(final PostsApiConfiguration configuration, final Environment environment) throws Exception {
environment.jersey().register(new PostsResource());
}
Uygulamayı derleyip çalıştırdıktan sonra curl http://localhost:8080/posts/ping
adresine gittiğinizde nurtopu gibi bir HTTP 500 - Internal Server Error hatası sizi bekliyor olacak. Peki neden? Hani Dropwizard kolaydı? Hani güzel uygulamalar görecektik? XML mi yazmak gerekecek?
Hemen paniklemeyin. Bu hata benim her yeni yazdığım uygulamada en az bir defa yaptığım bir dalgınlık olduğu için burada yer vermek istedim. JAX-RS standardı REST kurallarını takip etmeye dikkat ettiği için kullanıcıya veriyi Accept
HTTP başlığı ile hangi biçimi isterse o biçimde göndermeye çalışır. Yani istemci Accept
başlığını ayarlayarak veriyi XML, JSON, YAML veya sunucunun desteklediği diğer herhangi bir biçimde alabilir. Örneğimizde curl
komutu öntanımlı olarak text/plain
tipinde içerik istediği ve sunucu da bunu desteklemediği için bu hatayı aldık. Bunu iki şekilde düzeltebiliriz:
- curl komutumuza veriyi JSON biçiminde istemesini söyleyebiliriz:
curl -H 'Accept: application/json' http://localhost:8080/posts/ping
- Alternatif olarak eğer HTTP isteklerine istemcinin talebinden bağımsız olarak hep belirli bir biçimde (örneğin JSON) cevap vermek istiyorsak
PostsResource
sınıfını veyaping
metodunu@Produces(MediaType.APPLİCATION_JSON)
şeklinde işaretleyebiliriz.
Bu değişikliklerden birini yaptığımız zaman URL'in bize "Pong!" cevabını (tırnaklar dahil) döndürdüğünü görebiliriz. Peki tırnaklar neden? Çünkü dönen veri bir JSON objesi! Tebrikler, ilk API'ınızı yazdınız. Şimdi sırada bunu biraz daha detaylandırıp gerçekçi objeler döndürmek var.
Veri Transfer Objeleri
Genellikle bir REST API geliştiriyorsanız dikkat edilmesi gereken noktalardan biri bu API üzerinden sunduğunuz objelerin iş mantığı içeren objeler yerine daha hafif Veri Transfer Objeleri (Data Transfer Object - DTO) olmasıdır. Bu sayede uygulamamızın mantığını API için kullandığımız objelerden ayrı tutabiliriz. Genelde Veri Transfer Objeleri için yapılan bir diğer öneri de bu objelerin değiştirilemez (immutable) olmalarıdır. İşte bu sebeple biz de sitedeki gönderileri içerecek veri yapımızı Immutables kütüphanesi yardımıyla tamamen değiştirilemez şekilde tasarlayacağız.
Immutables kütüphanesi işaretlediğiniz arayüz tanımlarını kullanan ve kodunuz derlenmeden önce otomatik olarak o arayüzleri gerçekleyen, değiştirilemez Java sınıfları üreten bir kütüphane. Bunun için Gradle'a sadece Immutables bağımlılığını eklememiz yetmiyor, aynı zamanda Immutables'in işaretlenmiş arayüzleri işleyip gerekli sınıfları üretmesini sağlayacak bir Gradle eklentisini de eklememiz gerekiyor. Önce build.gradle
dosyasında işaretleri işleyecek apt
(annotation processing tool) eklentisini aktıve edelim:
plugins {
id 'java'
id 'idea'
id 'application'
// Isaretleri islemek icin gerkli eklenti. Standart bir eklenti olmadigi
// icin versiyon numarasi ile birlikte eklemek gerekiyor.
id "net.ltgt.apt" version "0.8"
}
İkinci adımımız da bu Immutables kütüphanesine olan bağımlılığı ve bu bağımlılığın içerindeki işaret işleyicilerin tetiklenmesini ayarlamak:
dependencies {
compile group: 'io.dropwizard', name: 'dropwizard-core', version: '1.0.5'
apt group: 'org.immutables', name: 'value', version: '2.4.0'
compile group: 'org.immutables', name: 'value', version: '2.4.0'
}
Şimdi ilk veri transfer objemizi tanımlayabiliriz:
package net.fazlamesai.posts.api.dto;
import org.immutables.value.Value.Immutable;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
@Immutable
public interface Post {
UUID getId();
LocalDateTime getCreatedDate();
Optional<LocalDateTime> getPublishedDate();
String getTitle();
String getBody();
static ImmutablePost.Builder builder(){
return ImmutablePost.builder();
}
static ImmutablePost copyOf(Post post){
return ImmutablePost.copyOf(post);
}
}
Bu veri yapısı bir gönderiyi tanımlamak için (şimdilik) yeterli bilgiyi içeriyor gibi duruyor. Şimdi yapmamız gereken bu objeyi bir kaynak üzerinden sunmak. Bunun için de herhalde daha önce tanımladığımız PostsResource
gayet uygun bir yer olacaktır. Şimdilik basitçe bir Post objesi yaratıp döndürecek bir metod ile açılışı yapalım:
@GET
public List<Post> getPosts() {
return Collections.singletonList(Post.builder()
.id(UUID.randomUUID())
.title("Title")
.body("Body")
.createdDate(LocalDateTime.now())
.publishedDate(Optional.empty())
.build());
}
Genelde REST uygulamalarında bir kaynak parametresiz istendiği zaman bir liste halinde döndürmek bir gelenek olduğu için de onu bozmayıp tek elemanlı da olsa bir liste döndürelim. Eğer dikkat ettiyseniz bu metodu @GET
ile işaretledik. Bir @Path
işareti de olmadığı için bu metod /posts
URL'ine yapılan her GET
isteğinde çalışacaktır. Şimdi uygulamamızı çalıştırıp bir istek yaptığımızda bir JSON objesi ile karşılanacağız.
Bu JSON objesine dikkatli baktığınızda gözünüze tarih bilgisinin biraz "ilginç" bir şekilde biçimlendirildiği çarpacaktır. Bunu daha anlaşılır bir şekle sokmak için PostsApi
uygulamamızın run
metodunu aşağıdaki şekilde güncellemek gerekiyor. Bunun Dropwizard'ın ilerleyen sürümlerinde öntanımlı olarak gelmesini umuyoruz.
@Override
public void run(final PostsApiConfiguration configuration, final Environment environment) throws Exception {
environment.jersey().register(new PostsResource());
environment.getObjectMapper().setDateFormat(DateFormat.getDateInstance());
}
Tabi ki sadece böyle bir metod işimize yaramayacaktır. Önce kaynağımızda olması gerekecek diğer metodları hazırlayalım:
@GET
// Bu metodu belirli bir gönderiyi almak icin kullanacagimizdan
// id isimli bir URL parametresi tanimliyoruz.
@Path("/{id}")
// id parametresini metodumuzun id isimli parametresine bagliyoruz.
// Bir metodun Optional turunde veri döndurmesi halinde Jersey bizim
// icin "bulunamadiginda 404 kodu ver" isini hallediyor. Donus degeri bos
// ise otomatik olarak HTTP 404 sonucu dönuyor
public Optional<Post> getPost(@PathParam("id") final UUID id) {
return Optional.empty();
}
@GET
public List<Post> getPosts() {
return Collections.emptyList();
}
// Yeni gönderiler yaratmak icin bu URL'e POST istegi göndermek yeterli.
@POST
public Post createPost(final Post post) {
return post;
}
Şimdi iş bu metodların içini doldurmaya geldi. Bu yazının kapsamında verilerin kalıcı saklanması ile ilgilenmediğimiz için basitçe tüm veriyi bellekte saklayan bir veri deposu (repository) yaratarak kolaya kaçalım. İlerleyen aşamalarda bu servisi gerçekten veriyi saklayan bir depo ile değiştireceğiz.
Verileri bellekte saklayan basit bir depoyu aşağıdaki şekilde hazırlayabiliriz:
package net.fazlamesai.posts.api.repositories;
import net.fazlamesai.posts.api.dto.Post;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import static java.util.stream.Collectors.toList;
public class PostsRepository {
private static final ConcurrentMap<UUID, Post> posts = new ConcurrentHashMap<>();
public Optional<Post> getPost(final UUID id) {
return Optional.ofNullable(posts.get(id));
}
public List<Post> getPosts(final int limit) {
return posts.values()
.stream()
.sorted(Comparator.comparing(Post::getCreatedDate))
.limit(limit)
.collect(toList());
}
public Post createPost(final Post post) {
return posts.putIfAbsent(post.getId(), post);
}
}
Şimdi tek yapmamız gereken bu depoyu REST kaynağımız ile bağlamak. Neyse ki bu da çok zor değil. Önce depoyu REST kaynağımızın içine ekliyoruz:
private final PostsRepository postsRepository;
public PostsResource(final PostsRepository postsRepository) {
this.postsRepository = postsRepository;
}
Bunu yaparken PostsApi::run
metodunda yarattığımız objeye parametreyi eklemeyi de unutmuyoruz tabi ki. Ardından da tek yapmamız gereken metodlarımızı depoya yönlendirmek:
@GET
@Path("/{id}")
public Optional<Post> getPost(@Nonnull @PathParam("id") final UUID id) {
return postsRepository.getPost(id);
}
@GET
public List<Post> getPosts() {
return postsRepository.getPosts(25);
}
@POST
public Post createPost(@Valid final Post post) {
final Post storedPost = Post.copyOf(post)
.withId(UUID.randomUUID())
.withCreatedDate(LocalDateTime.now())
.withPublishedDate(Optional.empty());
return postsRepository.createPost(storedPost);
}
Bunun ardından uygulamamızı çalıştırdığımız zaman elimizde tüm fonksiyonlarını güzelce yerine getiren bir API olacaktır. Test etmek için aşağıdaki curl
komutunu kullanabiliriz:
echo '{"id":"b31592ad-8684-4c3f-ae14-4395719b4443","createdDate":"2017-01-15T20:18:24.835","publishedDate":null,"title":"This is the Borg","body":"You will be assimilated"}' | curl -XPOST -H 'Content-type: application/json' -d @- http://localhost:8080/posts
Bir diğer HTTP 500 hatası mı? Neden? Aslında nedeni basit. İstemci'nin yolladığı HTTP isteğinden yola çıkarak bir Post
objesi yaratmaya çalışıyoruz. Lakin Post
sınıfı bir arayüz olduğu için Jackson kütüphanesi nasıl bir Post
gerçeklemesi kullanması gerektiğini bilemiyor. Bunun için bizim Jackson'a yol göstererek Post
sınıfını onu gerçekleyen sınıf ile eşleyen bir işaret koymamız gerekiyor. Bunu da basitçe aşağıdaki gibi yapabiliriz:
@Immutable
@JsonDeserialize(as = ImmutablePost.class)
public interface Post {
...
}
Bunun ardından POST
istegini yeniden gönderip, ardından curl http://localhost:8080/posts
komutu ile gönderileri çektiğimizde az önce yolladığımız gönderiyi listede görebiliriz.
Son bir nokta: Konfigurasyon objesinin kullanımı
Aslında bu bölümü burada bitirecektim fakat dikkat ederseniz yukarıdaki PostsResource
sınıfının son halinde gönderi listesini çekerken 25
gibi bir sayı kullanıyoruz. Sizi bilmem ama, ben önüme böyle rastgele serpiştirilmiş sayılar geldiği zaman çok rahatsız oluyorum. İşte bunu çözmek için bu bilgiyi konfigürasyon sınıfımıza taşıyarak bir sabit yollama derdinden kendimizi kurtarabiliriz.
Bunun için öncelikle konfigürasyon objemizin içine bu ayarı saklayacak int
tipinde bir alan ekleyelim:
private int maxPostsLimit = 25;
public int getMaxPostsLimit() {
return maxPostsLimit;
}
public void setMaxPostsLimit(final int maxPostsLimit) {
this.maxPostsLimit = maxPostsLimit;
}
Şimdi tek yapmamız gereken konfigürasyon objemizi PostsResource
sınıfına göndermek ve oradaki değeri kullanmaya başlayabiliriz. Önce PostsResource
sınıfını konfigürasyon objesini de içerecek şekilde güncelleyelim:
private final PostsApiConfiguration configuration;
public PostsResource(final PostsRepository postsRepository, final PostsApiConfiguration configuration) {
this.postsRepository = postsRepository;
this.configuration = configuration;
}
...
@GET
public List<Post> getPosts() {
return postsRepository.getPosts(configuration.getMaxPostsLimit());
}
Son olarak da uygulamamızı ayağa kaldırdığımız noktada küçük bir güncelleme yaparak konfigürasyon objemizin yerine gittiğinden emin olalım:
@Override
public void run(final PostsApiConfiguration configuration, final Environment environment) throws Exception {
environment.jersey().register(new PostsResource(new PostsRepository(), configuration));
environment.getObjectMapper().setDateFormat(DateFormat.getDateInstance());
}
Evet, böylece o gereksiz sabit sayıdan da kurtulmuş olduk. Bundan sonra uygulamamızı çalıştırırken ./bin/posts-api server config.yaml
şeklinde çalıştırabilir, eğer öntanımlı ayarı değiştirmek istersek ayar dosyamızın içine aşağıdaki satırı yazabiliriz:
maxPostsLimit: 42
Sıradaki adım
Böylece ilk adımımız olan gönderi yaratma ve saklama işlemini tamamladık. Tabi ki gönderileri sadece bellekte tutup uygulama kapandığı zaman kaybetmek pek de mantıklı bir çözüm değil. Bunun için önümüzdeki yazıda Elasticsearch kullanarak bu veriyi kalıcı bir şekilde saklamanın yollarına bakacağız.
Uygulamanın bu halinin kodlarına GitHub üzerinden ulaşabilirsiniz.
Kapak görseli kaynak: Annie Pilon