Notification kullanarak resim hatırlatma uygulaması nasıl yapılır?

Abdulsamet İLERİ
8 min readMay 19, 2021

Nedir bu uygulama?

Kullanıcıların resim, saat ve zaman (pazartesi, salı, … her gün) seçtikleri ve o saat ve zaman geldiğinde telegram botumuzun onlara yüklediği resmi mesaj olarak gönderdikleri bir nevi alarm kurma uygulaması diyebiliriz.

Uygulama mimarisi

  • Veritabanı olarak Mongo DB Atlas, (500MB’e kadar free çünkü)
  • Kullanıcıların yüklediği resimleri tutmak için AWS S3,
  • Cron job (zamanlanmış görevleri) yönetmek için https://github.com/jasonlvhit/gocron adlı kütüphane.
  • Frontend tarafında Vue.js (BootstrapVue, Global Mixins, i18n)
  • Backend tarafında Golang (net/http)
  • Build edilen Vue.js dosyalarını Golang tarafında gömüp, sunmak için go embed
  • VM olarak AWS EC2
  • CI/CD için Github secrets ile birlikte Github Actions (Self Hosted).
Mimari Görseli

Kaynak kod: https://github.com/Abdulsametileri/cron-job-vue-go

Site: http://ec2-18-194-208-116.eu-central-1.compute.amazonaws.com:3000

Uygulama Mobil Ekran Görüntüleri

Yazıdaki amacım okuyucuya yalnızca fikir vermek yönündedir. Malesef her satırı her dosyayı bu makalede ele alamadım. Soru, tavsiye, yanlış kullanım vs. olursa geri dönüşlerinizi beklerim. Sonuçta hepimiz her gün her saat yeni şeyler deneyip öğrenmeye çalışıyoruz.

Hadi başlayalım…

API İçin Frontend Tarafında Proxy Ayarlama

Geliştirme yaparken backend tarafını :3000 ile vue tarafını ise :8080 ile farklı portlarda çalıştıracağımızdan vue tarafındaki API isteklerini proxy etmemiz daha kolay development yapmamıza olanak sağlıyor. Bunun için vue.config.js (yoksa oluşturun) içine devServer proxyi tanımlıyoruz.

client/vue.config.js

Bu ayarı yaptıktan sonra rahatlıkla fetch(/api/list-alarm/, /api/create-alarm) diyerek API’mize call yapabiliyoruz. Absolute URL, cors vs. gibi dertlerle uğraşmaya gerek yok.

i18n İle Global Mixinleri Tanımlama

client/plugins/i18n.js

i18n ayarlamak için, locales adlı klasörümüzde tanımladımız en.json ve tr.json nı okuyup i18n’in messages property’sine map ediyoruz. Sonrasında bu dosyamızı main.js tarafında import edip global vue instancemize property olarak geçiyoruz. Böylelikle json dosyalarımızda tanımladığımız keylere global olarak ister javascripten this.$t(‘key’) ister htmlden {{ $t(‘key’) }} şeklinde erişebiliyoruz.

API durumları veya uygulama içerisindeki mesajlar için messages.js adlı dosyayı global bir mixin olarak tanımlıyorum. Bunu global yapmamım sebebi ise genelde her componentin bir API call yapması gerekiyor ve sonuçları dönerken kullanıcıya mesaj verme gereksimide duyuluyor. İçinde çok da özel bir şey yok. Bootstrap vuenin modal componentini programmatic olarak çağırıp kendimce özelleştiriyorum.

Bu uygulamada vue tarafında test yazmadım fakat nasıl vuex, component vs. testlerinin yapıldığını anlattığım eski yazıma göz atabilirsiniz.

Go Embed İle Statik Dosyaları Gonun İçine Eklemek

Uygulamamızın main.go dosyasına gelip

//go:embed client/dist
var clientFS embed.FS

olarak vue.js dosyamızın build artifact klasörünü belirtiyoruz (default dist). Bu dosyaları main.go içerisinde clientFS şeklinde erişeceğiz. Bu dosyaları file system olarak okuyup anasayfamızdan (/) serve etmemiz için yapmamız gereken birkaç satır..

distFS, err := fs.Sub(clientFS, "client/dist")
if err != nil {
log.Fatalf("error dist %v", err)
}
http.Handle("/", http.FileServer(http.FS(distFS)))

Artık static dosyalarımızı sunmak için ayrıyeten bir web servere ihtiyacımız kalmadı. Doğrudan go içerisinden serve edebiliriz.

Nedir bu Repository, Service, Controllers Katmanları?

Controllers Katmanı: Öncelikle controllers dediğimiz isteğin geleceği ilk yer burada API metotlarımız bulunuyor. Bu katmanda doğrudan DB’ye erişim sağlanamıyor. DB’ye erişim dolaylı yoldan service diye ayrı bir katman ile yapılıyor. Burada kendimize iş çıkarmıyoruz, test yazmada inanılmaz kolaylık sağlıyor. Doğrudan repository kullansaydık içindeki db instanceyi de controller testi yazarken mocklamak zorunda kalacaktık. Biz zaten repository katmanının testini yazarken db instacemizi mocklayacağız neden burada tekrardan mocklayalım? Amacımız bu değil ki..

Örnek olarak TokenController’e bir bakalım. Go da tavsiye edilen şöyle bir yaklaşım var (interfaceleri kabul edin structları return edin). TokenControllerdan bir instance almak istiyorsan bana BaseController ve UserService interfacelerinin içerisindeki metodları implement eden herhangi bir obje vermen yeterli.

Eğer ben token controller’e test yazacaksam ayrı bir mock tipler oluşturup UserService ve BaseController interfacelerini implement ediyorum ve kolaylıkla TokenController’ı testlerim için initialize etmiş oluyorum.

Mesela UserService nasıl mockladığıma bir bakalım.

İşte interfaceleri parametre almanın sağladığı fayda.

Testimiz için mock implementation’larımızın bulunduğu tipi doğrudan NewTokenController’e verip nasıl kolaylıkla initialize ettiğimi görüyorsunuz.

Başta biraz karışık gelebilir ama elleri kirlettiğiniz zaman gerçekten ne kadar kolay olduğunu göreceksiniz. Kaynak koddaki controller testlerini incelemenizi tavsiye ederim.

Helper metodlarınızı yazdıktan sonra test yazmaktan gerçekten keyif alıyorsunuz.

Service Katmanı: Açıkcası bu katmanda hiç test yazmaya ihtiyaç duymadım çünkü zaten metodlar tek satır herhangi bir business logic vs. içermiyor. Ama nasıl yazardık acaba diye merak ederseniz farklı bir repomda nasıl mockladığımı görebilirsiniz.

Repository Katmanı: DB ile konuşacak katmanımız burası. Buradaki testlerimizi yazarken mongo kullandığımız için en basit yöntem localinizde bir mongo dbsi ve test collectionu oluşturup her test sonunda oluşturduğunuz collection’u drop etmenizdir. Zaten mongo-go-driver reposununda bu yaklaşım kullanılmış. Tabii farklı bir şekilde de mocklanabilir. Mesela bu durumda değil ama postgresql i sqlmock kütüphanesi kullanarak farklı bir repomda mocklamıştım merak ediyorsanız bi incelemenizi öneririm.

Bu katmandaki testlerim için db ve collectionları initialize edecek helper metodlarımı çağırıyorum ve her test metodunda defer ile cleanCollection yaparak temiz bir şekilde unit testimi yazmış oluyorum. Bu katmana test yazmanın sağladığı en güzel avantajlardan biride mongodaki pipelineları filterları çok rahat bir şekilde deneyebilmem olmuştu.

EC2 Instance Oluşturulması, Docker Kurulması, Github Actions’da Self Hosted Olarak Ayarlanması

Açıkcası bu kısım ile alakalı çok fazla kaynak var. Dökümantasyonları takip etmek yeterli olduğundan link verip geçmekte fayda görüyorum.

EC2 instance mizi oluştururken kendi adıma t2-micro free tier available bir makine alıp kurulumları yapıyorum. Uygulama 3000 porttan serve edildiği için security group da 3000/tcp portunu anywhere yapmanız gerekiyor.

Bu arada cron job uygulama içerisinde çalışması gerekeceğinden uygulamam 7/24 ayakta durması gerekiyor. Bundan dolayı fazla maliyet çıkaramayak bir vm ile yoluma devam ediyorum. Fakat Time Scheduler farklı bir kaynaktan alsaydım ve saati gelince benim fonksiyonlarım vs. tetiklenseydi çok rahat bir şekilde serverless ya da paas işimi görürdü. Bu tarz sistemler istek alınca ayağa kalkıp belli bir süre ayakta duran sonrasında ise kapandıklarından dolayı bu case de işimize yaramıyor.

Docker Install in ec2

Self hosted GitHub Action

Ayrıyeten systemctl ile startup configure vs. yapmak istiyorsanız şu videoyu incelemenizde fayda var.

Uygulamayı Dockerize Edip, GitHub Actions Pipeline’ı Kurma

Evet gelelim uygulamamızı nasıl dockerize edeceğimize.

Dockerfile

Uygulamamızda go embed kullandığımız için statik dosyaları rahatlıkla godan serve edebiliriz bundan dolayı nginx + go stacke vs. girmeye gerek yok. Yani docker-compose vs. ihtiyaç duymuyorum. Fakat bu stacki nasıl dockerize ederdik derseniz şu repoma bakabilirsiniz.

Burada multi-stage build kullanıyoruz amacımız sonuç imageimizin boyutunu küçük tutmak. Şimdi sırası ile bakalım.

FROM node:lts-alpine as VueBuilder
COPY client/ client/
RUN cd client && yarn install --frozen-lockfile && yarn build

Öncelikle vue kısmını build etmemiz gerekiyor bunun için node bir base image alıp yarn install komutunu çalıştırıyoruz. — frozen-lockfile argümanı için dökümantasyona bakmanızı öneririm.

FROM golang:alpine AS GoBuilder
WORKDIR /app
COPY go.sum go.mod ./
RUN go mod download
COPY --from=VueBuilder client/dist client/dist
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build --tags prod -o main main.go

Burada goyu build etmek ve cache den yararlanmak için go sum ve go mod dosyalarını kopyalayıp go mod download çalıştırıyoruz. Ve dikkat ederseniz ilk build stagemiz olan VueBuilder kısmından client/dist dosyamızı alıp kopyalıyoruz neden? Hatırlayın go embed ile bu klasörü go ya gömecektik ya işte bunun için fiziksel olarak o klasörün docker contextin (file systeminin) içinde olması gerekiyor.

FROM alpine:3
RUN apk update \
&& apk upgrade
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
COPY --from=GoBuilder /app/.env .
COPY --from=GoBuilder /app/main .
EXPOSE 3000
CMD ["./main"]

Son kısımda ise küçük bir alphine imageini base alıp tzdata ca-certificates gibi paketleri ekliyoruz.

tzdata (timezone data) uygulamamız cron job işi yapacağı için önemli.

location, _ := time.LoadLocation("Europe/Istanbul")
scheduleClient := gocron.NewScheduler(location)
scheduleClient.StartAsync()

cronClient := &cronClient{
js: js,
tc: tc,
sch: scheduleClient,
}

Eğer bunu eklemezsek time.LoadLocation fonksiyonu çalışmıyor.

Ayrıyeten ca-certificates vs. de eklenmezse telegram x509 certificate error veriyor.

Şimdi GitHub action’a girmeden önce kendimiz için bir Makefile oluşturarak docker build ve run steplerini deniyoruz. Ya da elle de yazabilirsiniz ben genelde komutları sık unuttuğum için bu tarz bir yöntem izliyorum.

Gelelim GitHub actions kısmına..

Öncelikle burada şunu belirtmem gerekiyor. Mongodb connection string, telegram bot tokeni gibi gizli bilgiler projede pushlanmaması gereken .env dosyasında tutuluyor ve uygulama içerisinde viper le kullanılıyor. Bu dosyayı pushlayamayız. Bunu şöyle çözebiliriz: GitHub secrets ile bu değişkenleri tanımlayıp kendi workflowumuzda kullanabiliriz. Peki neden kullanmaya ihtiyacımız var? Dikkat ederseniz docker file ın son stageinde .env dosyasını da kopyalıyoruz ki viper uygulama çalıştığında o yolu bulup değişkenleri yüklesin ve uygulamada rahatlıkla kullanabilelim.

Uygulamamızın bulunduğu Repository > Settings > Secrets e girersek

Bunlara workflowumuzda {{ secrets.MONGODB_DATABASE }} tarzında erişim sağlıyabiliriz.

Öncelikle workflow’umuz yalnızca master’e pushlanınca tetiklenebilecek ve istersek kendimizde manuel (workflow_dispatch) olarak tetikleyebileceğiz.

Öncelikle kullanılmayan orphan containerlar, networkler vs. için prune ile bir temizleme işlemi yapıyoruz. Diskte yer açıyoruz ki no space to write tarzında hatalar almayalım.

Sonrasında eğer containerimiz çalışıyorsa onu stop ediyoruz. (cjvg projenin cron-job-vue-go kısaltması kolay referans için containerimize bu ismi verdik) Fakat eğer ortada çalışan bir container yoksa bu komut hata döneceğinden workflow başarısız olarak sonuçlanacak bunu istemediğim için || true ile workflowumuz her halükarda devam ettirebiliyorum.

Dockerfile steplerini hatırlarsanız orada .env dosyasını COPY ile docker context içine alıyordum fakat ben bu dosyayı pushlamadım bu dosya fiziksel olarak benim repomda yok. Bunun için https://github.com/SpicyPizza/create-envfile adında workflow adımında tanımlamış olduğunuz github secrets e göre kendi bir .env dosyası oluşturan güzel bir actiona denk geldim ve işimi çok rahatlıkla halledebildim. envkey_ ile .env dosyasında belirlenen envkey_ den sonraki ismi koy değerini de secrets den oku diyorum. (Burada makineye ssh açıp doğrudan localimde .env dosyasını da kopyalayabilirsiniz fakat ben action/checkout kısmından dolayı o .env dosyasını hep workflow çalıştığında ilginç bir şekilde kaybediyordum . Bundan dolayı böyle bir yöntem izledim. os.GetEnv vs. de denebilir, tercih meselesi.)

Sonrasında image’imizi build edip run ediyoruz. Burada ilginç olan — dns seçeneği olabilir hatırlayın Mongo DB atlas kullandığımız için docker containerimiz içerisinden Atlas clusterına erişim sağlamamız gerekiyor. Bundan dolayı hem docker dns ayarına şu tarz url i resolve edebilsin diye (cluster0.jew2r.mongodb.net) google public dns vermenizi hem de Atlas panelinizde veritabanıza erişim izni vermeniz gerekiyor. (0.0.0.0 Anywhere) verebilirsiniz.

Son olarak

Uygulamamızda s3 upload ve delete gibi işlemleri, cron jobu nasıl kullandığımı testleri vs. nasıl yazdığımı koda bakarak anlaşılacağını düşünüyorum zaten hazır SDK (s3,telegram) ve library (go-cron) kullandığım için çok iyi dökümente edilmiş ve birden fazla samplei olan case’ler. Yazı da epey uzun oldu 😀. Teşekkürler :)

Kaynak kod: https://github.com/Abdulsametileri/cron-job-vue-go

--

--