Microservice menggunakan Go dan gRPC
Tags: go, grpc
Published: 2023-09-01 10:29:42

Tutorial ini akan menjelaskan cara untuk membuat aplikasi dengan arsitektur microservice. Disini kita akan membuat 2 service, yaitu:

1. account-service (Register dan Authenticate)
2. log-service (WriteLog)

Service yang akan kita buat hanya akan menampilkan log pada console, agar kode menjadi sederhana, sehingga lebih memudahkan dalam memahami konsep microservice.

Saya berasumsi pada laptop/komputer kalian telah terinstall golang, protobuf, text editor, postman, dan docker. Mari kita mulai.

## account-service
Buat folder account-service, lalu masuk ke dalam folder tersebut. jalankan perintah dibawah untuk menginisialisasi go modul.

```
go mod init account-service
```

Buat folder cmd/api, lalu buat file main.go di dalam folder tersebut. Buat konstanta bertipe string dengan nama webPort dengan value 80. Lalu buat struct bernama Config.

```go
const webPort = "80"

type Config struct{}
```

Selanjutnya buat fungsi main untuk menjalankan server.

```go
log.Println("starting account service")

app := Config{}

// configure server 
srv := &http.Server{
	Addr:    fmt.Sprintf(":%s", webPort),
	Handler: app.routes(),
}

// start server 
err := srv.ListenAndServe()
if err != nil {
	log.Panic(err)
}
```

Kode lengkapnya akan menjadi seperti di bawah.

```go
package main

import (
	"fmt"
	"log"
	"net/http"
)

const webPort = "80"

type Config struct{}

func main() {
	log.Println("starting account service")

	app := Config{}

	// configure server 
	srv := &http.Server{
		Addr:    fmt.Sprintf(":%s", webPort),
		Handler: app.routes(),
	}

	// start server 
	err := srv.ListenAndServe()
	if err != nil {
		log.Panic(err)
	}
} 
```

Jalankan perintah dibawah untuk mendownload package yang diperlukan untuk mengkonfigurasi endpoint.

```
go get github.com/go-chi/chi/v5
go get github.com/go-chi/cors
```

Selanjutnya kita buat file routes.go di dalam folder cmd/api. Buat fungsi route dengan nilai balikan http.Handler. Masukan kode untuk mengkonfigurasi router.

```go
mux := chi.NewRouter()

mux.Use(cors.Handler(cors.Options{
	AllowedOrigins:   []string{"http://*", "http://*"},
	AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
	AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
	AllowCredentials: true,
	MaxAge:           300,
}))
```

Dan juga kode untuk membuat endpoint. Kita akan membuat 2 endpoint, yaitu authenticate dan register.

```go
// configure endpoint
mux.Post("/authenticate", app.Authenticate)
mux.Post("/register", app.Register)
```

Kode lengkapnya akan menjadi seperti di bawah.

```go
package main

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/cors"
)

func (app *Config) routes() http.Handler {
	// configure router 
	mux := chi.NewRouter()

	mux.Use(cors.Handler(cors.Options{
		AllowedOrigins:   []string{"http://*", "http://*"},
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
		AllowCredentials: true,
		MaxAge:           300,
	}))

	// configure endpoint
	mux.Post("/authenticate", app.Authenticate)
	mux.Post("/register", app.Register)

	return mux
}
```

Selanjutnya kita buat file helper.go di dalam folder cmd/api. Buat kode untuk mendefinisikan struct bernama jsonResponse.

```go
type jsonResponse struct {
	Error   bool   `json:"error"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}
```

Buat fungsi readJson dengan receiver Config untuk mengubah json dari request menjadi value pada go.

```go
func (app *Config) readJSON(w http.ResponseWriter, r *http.Request, data any) error {
	maxBytes := 1048576 // 1MB

	r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))

	dec := json.NewDecoder(r.Body)
	err := dec.Decode(data)
	if err != nil {
		return err
	}

	err = dec.Decode(&struct{}{})
	if err != io.EOF {
		return errors.New("body must have only a single JSON value")
	}

	return nil
}
```

Buat fungsi writeJson dengan receiver Config untuk mengeset header untuk balikan endpoint.

```go
func (app *Config) writeJSON(w http.ResponseWriter, status int, data any, headers ...http.Header) error {
	out, err := json.Marshal(data)
	if err != nil {
		return err
	}

	if len(headers) > 0 {
		for key, value := range headers[0] {
			w.Header()[key] = value
		}
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, err = w.Write(out)
	if err != nil {
		return err
	}

	return nil
}
```

Buat fungsi errorJson dengan receiver Config untuk mengeset header untuk balikan endpoint yang error.

```go
func (app *Config) errorJSON(w http.ResponseWriter, err error, status ...int) error {
	statusCode := http.StatusBadRequest

	if len(status) > 0 {
		statusCode = status[0]
	}

	var payload jsonResponse
	payload.Error = true
	payload.Message = err.Error()

	return app.writeJSON(w, statusCode, payload)
}
```

Kode lengkapnya akan menjadi seperti di bawah.

```go
package main

import (
	"encoding/json"
	"errors"
	"io"
	"net/http"
)

type jsonResponse struct {
	Error   bool   `json:"error"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

func (app *Config) readJSON(w http.ResponseWriter, r *http.Request, data any) error {
	maxBytes := 1048576 // 1MB

	r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))

	dec := json.NewDecoder(r.Body)
	err := dec.Decode(data)
	if err != nil {
		return err
	}

	err = dec.Decode(&struct{}{})
	if err != io.EOF {
		return errors.New("body must have only a single JSON value")
	}

	return nil
}

func (app *Config) writeJSON(w http.ResponseWriter, status int, data any, headers ...http.Header) error {
	out, err := json.Marshal(data)
	if err != nil {
		return err
	}

	if len(headers) > 0 {
		for key, value := range headers[0] {
			w.Header()[key] = value
		}
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, err = w.Write(out)
	if err != nil {
		return err
	}

	return nil
}

func (app *Config) errorJSON(w http.ResponseWriter, err error, status ...int) error {
	statusCode := http.StatusBadRequest

	if len(status) > 0 {
		statusCode = status[0]
	}

	var payload jsonResponse
	payload.Error = true
	payload.Message = err.Error()

	return app.writeJSON(w, statusCode, payload)
}
```

Selanjutnya kita buat file handler.go di dalam folder cmd/api. File ini berisi kode untuk menghandle logic endpoint. Pertama kita buat struct dengan nama requestPayload.

```go
var requestPayload struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}
```

Lalu, kita buat fungsi Authentication dengan receiver Config.

```go
func (app *Config) Authenticate(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("Logged in user %s", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}
```

Dan juga fungsi Register dengan receiver Config.

```go
func (app *Config) Register(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("%s registered", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}
```

Kode lengkapnya akan menjadi seperti di bawah.

```go
package main

import (
	"fmt"
	"log"
	"net/http"
)

var requestPayload struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func (app *Config) Authenticate(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("Logged in user %s", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}

func (app *Config) Register(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("%s registered", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}
```

Terakhir, buat file account-service.dockerfile di dalam folder account-service. Masukan script seperti di bawah.

```dockerfile
# build
FROM alpine:latest

RUN mkdir /app

COPY accountApp /app

CMD ["/app/accountApp"]
```

## Docker

Buat folder yang sejajar dengan account-service dengan nama project, lalu buat file docker-compose.yml dan Makefile.

Untuk docker-compose.yml masukan script seperti di bawah.

```yaml
version: '3'

services: 

  account-service:
    build:
      context: ./../account-service
      dockerfile: ./../account-service/account-service.dockerfile
    restart: always
    ports:
      - "8080:80"
    deploy:
      mode: replicated
      replicas: 1
```

Untuk Makefile masukan script seperti di bawah.

```makefile
ACCOUNT_BINARY=accountApp

## up: starts all containers in the background without forcing build
up:
	@echo "Starting Docker images..."
	docker-compose up -d
	@echo "Docker images started!"

## up_build: stops docker-compose (if running), builds all projects and starts docker compose
up_build: build_account
	@echo "Stopping docker images (if running...)"
	docker-compose down
	@echo "Building (when required) and starting docker images..."
	docker-compose up --build -d
	@echo "Docker images built and started!"

## down: stop docker compose
down:
	@echo "Stopping docker compose..."
	docker-compose down
	@echo "Done!"

## build_account: builds the account binary as a linux executable
build_account:
	@echo "Building account binary..."
	cd ../account-service && env GOOS=linux CGO_ENABLED=0 go build -o ${ACCOUNT_BINARY} ./cmd/api
	@echo "Done!"
```

Masuk ke dalam folder project, lalu jalankan perintah untuk menjalankan container docker.

```
make up_build
```

![docker-project-1](https://raw.githubusercontent.com/drajatisme/images-repository/main/docker-project-1.png "docker-project-1")

Kita tes API dengan menggunakan Postman. Hit url http://localhost:8080/register dan http://localhost:8080/authenticate. Jika berhasil maka hasilnya akan seperti gambar di bawah.

![postman-result-1](https://raw.githubusercontent.com/drajatisme/images-repository/main/postman-result-1.png "postman-result-1")

![postman-result-2](https://raw.githubusercontent.com/drajatisme/images-repository/main/postman-result-2.png "postman-result-2")

Dan pada docker log akan muncul log dari account-service.
![docker-project-log-1](https://raw.githubusercontent.com/drajatisme/images-repository/main/docker-project-log-1.png "docker-project-log-1")

Service account-service sudah selesai dibuat. Kita lanjut ke service berikutnya.

## log-service

Buat folder log-service sejajar dengan account-service. Jalankan perintah untuk menginisialisasi modul.

```
go mod init log-service
```

Buat folder cmd/api, lalu buat file main.go di dalam folder tersebut. Buat konstanta bertipe string dengan nama gRpcPort dengan value 50001. Lalu buat struct bernama Config.

```go
const webPort = "50001"

type Config struct{}
```

Selanjutnya buat fungsi main untuk menjalankan server.

```go
func main() {

	app := Config{}

	go app.gRPCListen()
}
```

Kode lengkapnya akan menjadi seperti di bawah.

```go
package main

const (
	gRpcPort = "50001"
)

type Config struct{}

func main() {
	app := Config{}

	go app.gRPCListen()
}
```

Buat folder proto di dalam log-service, lalu buat file logs.proto. Kode dibawah berfungsi untuk mendefiniskan versi dari sintaks proto.

```protobuf
syntax = "proto3";
```

Kode dibawah berfungsi untuk mendefinisikan nama package pada file hasil generate.

```protobuf
package proto;

option go_package = "/proto";
```

Kode di bawah berfungsi untuk mendefinisikan model atau tipe data.

```protobuf
message Log{
    string name = 1;
    string data = 2;
}

message LogRequest{
    Log logEntry = 1;
}

message LogResponse {
    string result = 1;
}
```

Dan kode yang terakhir berfungsi untuk mendefinisikan method yang akan di consume oleh service yang lain.

```protobuf
service LogService {
    rpc WriteLog(LogRequest) returns (LogResponse);
}
```

Kode lengkapnya akan menjadi seperti di bawah.

```protobuf
syntax = "proto3";

package proto;

option go_package = "/proto";

message Log{
    string name = 1;
    string data = 2;
}

message LogRequest{
    Log logEntry = 1;
}

message LogResponse {
    string result = 1;
}

service LogService {
    rpc WriteLog(LogRequest) returns (LogResponse);
}
```

Masuk ke dalam folder proto, jalankan perintah untuk menggenerate kode RPC. Dengan menjalankan perintah tersebut, protobuf akan menggenerate 2 file.

```
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative logs.proto 
```

![generate-proto-1](https://raw.githubusercontent.com/drajatisme/images-repository/main/generate-proto-1.png "generate-proto-1")

## Update account-service

Buat folder proto di dalam folder account-service. lalu buat file logs.proto di dalam folder tersebut. Untuk mempersingkat waktu, salin seluruh code pada file log-service/proto/logs.proto ke file account-service/proto/logs.proto, dan jalankan perintah generate kode RPC seperti di atas.

Buat folder services di dalam folder account-service, dan buat file log-service.go. Buat struct bernama LogService.

```go
type LogService struct{}
```

Buat fungsi dengan receiver LogService bernama connect. Fungsi ini akan mengkoneksikan ke service log.

```go
func (s *LogService) connect() (*grpc.ClientConn, error) {
	conn, err := grpc.Dial(
		"log-service:50001",
		grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(),
	)
	if err != nil {
		return nil, err
	}

	return conn, nil
}
```

Buat fungsi dengan receiver LogService bernama LogItemViaGRPC. Fungsi inilah yang akan dipanggil ketika akan melakukan hit ke service log.

```go
func (s *LogService) LogItemViaGRPC(message string) error {
	conn, err := s.connect()
	if err != nil {
		return err
	}
	defer conn.Close()

	c := proto.NewLogServiceClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	_, err = c.WriteLog(ctx, &proto.LogRequest{
		LogEntry: &proto.Log{
			Name: "account-service",
			Data: message,
		},
	})
	if err != nil {
		return err
	}

	return nil
}
```

Tambahkan kode pada handler Authenticate dan Register tepat sebelum app.WriteJson di panggil.

```go
srv := services.LogService{}
srv.LogItemViaGRPC(payload.Message)
```

Sehingga kode pada cmd/api/handler.go akan menjadi seperti di bawah.

```go
package main

import (
	"account-service/services"
	"fmt"
	"log"
	"net/http"
)

var requestPayload struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func (app *Config) Authenticate(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("Logged in user %s", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	srv := services.LogService{}
	srv.LogItemViaGRPC(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}

func (app *Config) Register(w http.ResponseWriter, r *http.Request) {
	err := app.readJSON(w, r, &requestPayload)
	if err != nil {
		app.errorJSON(w, err, http.StatusBadRequest)
		return
	}

	payload := jsonResponse{
		Error:   false,
		Message: fmt.Sprintf("%s registered", requestPayload.Email),
		Data:    requestPayload,
	}

	log.Println(payload.Message)
	srv := services.LogService{}
	srv.LogItemViaGRPC(payload.Message)
	app.writeJSON(w, http.StatusAccepted, payload)
}
```

## Docker

Tambahkan script pada file project/Makefile

```makefile
LOG_BINARY=logApp

build_log:
	@echo "Building log binary..."
	cd ../log-service && env GOOS=linux CGO_ENABLED=0 go build -o ${LOG_BINARY} ./cmd/api
	@echo "Done!"
```

Update script up_build.

```makefile
up_build: build_account build_log
```

Sehingga scriptnya akan menjadi seperti di bawah.

```makefile
ACCOUNT_BINARY=accountApp
LOG_BINARY=logApp

## up: starts all containers in the background without forcing build
up:
	@echo "Starting Docker images..."
	docker-compose up -d
	@echo "Docker images started!"

## up_build: stops docker-compose (if running), builds all projects and starts docker compose
up_build: build_account build_log build_mail
	@echo "Stopping docker images (if running...)"
	docker-compose down
	@echo "Building (when required) and starting docker images..."
	docker-compose up --build -d
	@echo "Docker images built and started!"

## down: stop docker compose
down:
	@echo "Stopping docker compose..."
	docker-compose down
	@echo "Done!"

## build_account: builds the account binary as a linux executable
build_account:
	@echo "Building account binary..."
	cd ../account-service && env GOOS=linux CGO_ENABLED=0 go build -o ${ACCOUNT_BINARY} ./cmd/api
	@echo "Done!"

## build_log: builds the log binary as a linux executable
build_log:
	@echo "Building log binary..."
	cd ../log-service && env GOOS=linux CGO_ENABLED=0 go build -o ${LOG_BINARY} ./cmd/api
	@echo "Done!"
```

Terakhir tambahkan script pada file docker-compose.yml

```yaml
  log-service:
    build:
      context: ./../log-service
      dockerfile: ./../log-service/log-service.dockerfile
    restart: always
    ports:
      - "8081:80"
    deploy:
      mode: replicated
      replicas: 1
```

Masuk ke folder project, lalu jalankan perintah untuk menjalankan container docker. 

```
make up_build
```

![docker-project-2](https://raw.githubusercontent.com/drajatisme/images-repository/main/docker-project-2.png "docker-project-2")

Kita tes microservice yang telah kita buat. Hit url register dan authenticate menggunakan postman. Jika log pada docker terdapat log dari masing-masing service, maka kita telah berhasil membuat 2 service saling terhubung.

![docker-project-log-2](https://raw.githubusercontent.com/drajatisme/images-repository/main/docker-project-log-2.png "docker-project-log-2")

## Penutup

Kita telah membuat 2 service dan membuat account-service memanggil log-service dengan menggunakan gRPC. Bagaimana menurut kalian?