Simple Go Microservice Architecture to begin your journey with Go!

Part 1

I started learning Go a year ago as I wanted to develop a microservice for Kugelblitz for providing various financial services to their clients in go which was clean and simple to understand with not much complexity in the structure or data flow of the service. And after researching through many online articles I came up with a structure which in my opinion is simple enough to understand. I am gonna divide this article into parts to explain each segment that I have added to the architecture. I will be discussing making connections with relational and non-relational databases, adding an encryption layer to jwt authentication, connecting with AWS services like S3, and so on as we progress in this series.

Let's dive in without wasting any more time!

Pre-Requisites

1. go version 1.11 or higher installed

Setup

1. Create a folder for your go project

$ mkdir product

$ cd product

2. Enable dependency for tracking your code

$ go mod init api/product


Note :In order to add any go module, just add the code and import and then run `go mod tidy`.

Project Structure

The project structure is going to be fairly simple!

Project-

--------Auth

--------Data

--------Handlers

--------Main.go

Now let's dive into the explanations and content of these folders!

Auth

Here we will add files regarding our authentication for the service. For the scope of this article, we will add the logic for JWT authentication and encryption logic will be added here later in the series.

Now create a token.go file and add the following code

package auth

import (
	"fmt"
	"time"

	jwt "github.com/dgrijalva/jwt-go"
)

var mySigningKey = []byte("4zx8u44md43f696217b41rn969fac8c1")

func GetJWT(audience string) (map[string]string, error) {
	token := jwt.New(jwt.SigningMethodHS256)

	claims := token.Claims.(jwt.MapClaims)

	claims["authorized"] = true
	claims["client"] = "access"
	claims["aud"] = audience
	claims["iss"] = "jwtgo.io"
	claims["exp"] = time.Now().Add(time.Hour * 1).Unix()

	tokenString, err := token.SignedString(mySigningKey)

	if err != nil {
		fmt.Errorf("Something Went Wrong: %s", err.Error())
		return nil, err
	}

	refreshToken := jwt.New(jwt.SigningMethodHS256)
	rtClaims := refreshToken.Claims.(jwt.MapClaims)
	rtClaims["aud"] = audience
	rtClaims["client"] = "refresh"
	rtClaims["exp"] = time.Now().Add(time.Hour * 24).Unix()

	rt, err := refreshToken.SignedString(mySigningKey)
	if err != nil {
		fmt.Errorf("Something Went Wrong: %s", err.Error())
		return nil, err
	}

	return map[string]string{
		"access_token":  tokenString,
		"refresh_token": rt,
	}, nil
}
        

Here we are generating access and refresh tokens with claims like audience, issuer, and expiry for the token.

Data

Here we will add all our data models and all the operation related to them.

Let's look at the example, create a file products.go

package data

import (
	"encoding/json"
	"fmt"
	"io"
	"regexp"
	"time"

	"github.com/go-playground/validator"
)

//Product defines the structure for an api product
type Product struct {
	// the id for the product
	//
	// required: false
	// min: 1
	ID int `json:"id"`
	// the name for this poduct
	//
	// required: true
	// max length: 255
	Name string `json:"name" validate:"required"`
	// the description for this poduct
	//
	// required: false
	// max length: 10000
	Description string `json:"description"`
	// the price for the product
	//
	// required: true
	// min: 0.01
	Price float32 `json:"price" validate:"gt=0"`
	// the SKU for the product
	//
	// required: true
	// pattern: [a-z]+-[a-z]+-[a-z]+
	SKU       string `json:"sku" validate:"required,sku"`
	CreatedOn string `json:"_"`
	UpdatedOn string `json:"_"`
	DeletedOn string `json:"_"`
}        

Here we have a structure of a Product which we will be playing with.

Now let's add validations to the product and logic to convert the struct to json or the opposite,

func (p *Product) Validate() error {
	validate := validator.New()
	validate.RegisterValidation("sku", validateSKU)

	return validate.Struct(p)
}

func validateSKU(fl validator.FieldLevel) bool {
	//sku is of format abc-sdf-fvdv
	re := regexp.MustCompile(`[a-z]+-[a-z]+-[a-z]+`)
	matches := re.FindAllString(fl.Field().String(), -1)

	if len(matches) != 1 {
		return false
	}

	return true
}

func (p *Product) ToJSON(w io.Writer) error {
	e := json.NewEncoder(w)
	return e.Encode(p)
}

func (p *Product) FromJSON(r io.Reader) error {
	e := json.NewDecoder(r)
	return e.Decode(p)
}

Now add some dummy data to play with,

var productList = []*Product{
	&Product{
		ID:          1,
		Name:        "Latte",
		Description: "Frothy milky cofee",
		Price:       2.45,
		SKU:         "abc323",
		CreatedOn:   time.Now().UTC().String(),
		UpdatedOn:   time.Now().UTC().String(),
	},
	&Product{
		ID:          2,
		Name:        "Espresso",
		Description: "Short and strong cofee without milk",
		Price:       1.99,
		SKU:         "fjd34",
		CreatedOn:   time.Now().UTC().String(),
		UpdatedOn:   time.Now().UTC().String(),
	},
}        

So now let's write some operation logic for the data,

Get Product:

var ErrProductNotFound = fmt.Errorf("Product not found")

func FindProduct(id int) (*Product, int, error) {
	for i, p := range productList {
		if p.ID == id {
			return p, i, nil
		}
	}

	return nil, -1, ErrProductNotFound
}        

Add Product:

func getNextId() int {
	lp := productList[len(productList)-1]
	return lp.ID + 1
}

func AddProducts(p *Product) {
	p.ID = getNextId()
	productList = append(productList, p)
}        

Update Product:

func UpdateProduct(id int, p *Product) error {
	_, pos, err := FindProduct(id)
	if err != nil {
		return err
	}

	p.ID = id
	productList[pos] = p
	return nil
}        

Delete Product:

func DeleteProduct(id int) error {
	_, i, err := FindProduct(id)

	if err != nil {
		return err
	}

	if i == -1 {
		return ErrProductNotFound
	}

	productList = append(productList[:i], productList[i+1])

	return nil
}        

Handlers

As the name suggests, here you will write your API request handlers.

Now Let's create handlers in order to manage the above data and its operations

create a file handlers.go

Here we will create a Product wrapper with the go lang's default logger

type Products struct {
	l *log.Logger
}

//NewProducts creates a product handler with the given logger
func NewProducts(l *log.Logger) *Products {
	return &Products{l}
}

type KeyProduct struct{}        

Now, create another file and name it middleware.go

Here we will add the middleware logic where we will validate and sanitize the data coming in the request.

func (p *Products) MiddlewareProductValidation(next http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		prod := data.Product{}

		err := prod.FromJSON(r.Body)
		if err != nil {
			http.Error(rw, "Unable to unmarshal json", http.StatusBadRequest)
			return
		}

		//validate product
		err = prod.Validate()
		if err != nil {
			http.Error(
				rw,
				fmt.Sprintf("Error validating product: %s", err),
				http.StatusBadRequest,
			)
			return
		}

		ctx := context.WithValue(r.Context(), KeyProduct{}, prod)
		req := r.WithContext(ctx)

		next.ServeHTTP(rw, req)
	})
}        

The Above code will take the request body and will try to parse the body into the product structure that we have defined in the data package.

And after successful parsing, it will validate the data based on the conditions we have written in product tags.

Now, we will add our JWT implementation here too as every request needs to go through the same security check.

var MySigningKey = []byte("4zx8u44md43f696217b41rn969fac8c1")
func IsAuthorized(endpoint func(http.ResponseWriter, *http.Request)) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header["Token"] != nil {

			clientId := r.Header["Client_id"][0]

			jtoken := r.Header["Token"][0]

			token, err := jwt.Parse(jtoken, func(token *jwt.Token) (interface{}, error) {
				if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
					return nil, fmt.Errorf(("Invalid Signing Method"))
				}
				aud := clientId
				checkAudience := token.Claims.(jwt.MapClaims).VerifyAudience(aud, true)
				if !checkAudience {
					return nil, fmt.Errorf(("invalid aud"))
				}
				// verify iss claim
				iss := "jwtgo.io"
				checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(iss, true)
				if !checkIss {
					return nil, fmt.Errorf(("invalid issuer"))
				}

				// verify token access
				client := "access"
				checkClient := token.Claims.(jwt.MapClaims)["client"].(string)
				if client != checkClient {
					return nil, fmt.Errorf(("invalid access"))
				}

				return MySigningKey, nil
			})
			if err != nil {
				fmt.Fprintf(w, err.Error())
				return
			}

			if token.Valid {
				endpoint(w, r)
			}
		} else {
			fmt.Fprintf(w, "No Authorization Token provided")
			return
		}
	})
}        

It's a pretty simple boilerplate code for verifying the jwt token present in the request header.

And then we will add the handler for JWT tokens

func (p *Products) GetAdminToken(rw http.ResponseWriter, r *http.Request) {

	p.l.Println(r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), "Token")

	validToken, err := auth.GetJWT(r,Header["Audience"][0])
	if err != nil {
		p.l.Println(r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), http.StatusInternalServerError, "Could not generate token")
		return
	}

	tokenKeys, err := json.Marshal(validToken)
	if err != nil {
		p.l.Println(r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), http.StatusInternalServerError, err.Error())
		return
	}
	p.l.Println(r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), http.StatusOK, "OK")
	rw.Header().Add("Content-Type", "application/json")
	rw.WriteHeader(http.StatusOK)
	rw.Write(tokenKeys)
}
        

The above handler will return access and refresh tokens as a response.

Now everything is set for all the handlers related to your product API requests, create a file product.go

Here I am just adding Get and Post, I am sure you will get the gist of it.

// Create handles POST requests to add new products
func (p *Products) AddProducts(rw http.ResponseWriter, r *http.Request) {
	p.l.Println("Handle POST Products")

	prod := r.Context().Value(KeyProduct{}).(data.Product)

	p.l.Println(&prod)
	data.AddProducts(&prod)
}

// ListSingle handles GET requests
func (p *Products) ListSingle(rw http.ResponseWriter, r *http.Request) {

	vars := mux.Vars(r)
	id, err := strconv.Atoi(vars["id"])
	if err != nil {
		http.Error(rw, "Unable to covert id", http.StatusBadRequest)
		return
	}

	p.l.Println("[DEBUG] get record id", id)

	prod, _, err := data.FindProduct(id)

	switch err {
	case nil:

	case data.ErrProductNotFound:
		p.l.Println("[ERROR] fetching product", err)

		rw.WriteHeader(http.StatusNotFound)
		return
	default:
		p.l.Println("[ERROR] fetching product", err)

		rw.WriteHeader(http.StatusInternalServerError)
		return
	}

	err = prod.ToJSON(rw)
	if err != nil {
		p.l.Println("[ERROR] serializing product", err)
	}
}        

Till now we have finished the data, its handlers, request authentication, now all we have to do is set up the server and manage the endpoints,

which we will be doing in main.go.

Add the following code in your main.go

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"time"

	"github.com/go-openapi/runtime/middleware"
	"github.com/vermavashish/try/handlers"

	"github.com/gorilla/mux"
)

func main() {

	l := log.New(os.Stdout, "product-api", log.LstdFlags)

	//Create the handlers
	ph := handlers.NewProducts(l)

	//create a new server mux and register the handlers
	sm := mux.NewRouter()

	getRouter := sm.Methods("GET").Subrouter()
	getRouter.Handle("/product/{id:[0-9]+}", handlers.IsAuthorized(ph.ListSingle))

	postRouter := sm.Methods("POST").Subrouter()
	postRouter.Handle("/", handlers.IsAuthorized(ph.AddProducts))
	postRouter.Use(ph.MiddlewareProductValidation)

	//create a new server
	s := &http.Server{
		Addr:         ":9000",
		Handler:      sm,
		ErrorLog:     l,
		IdleTimeout:  120 * time.Second,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	//Start the server
	go func() {
		err := s.ListenAndServe()
		if err != nil {
			l.Fatal(err)
		}
	}()

	//trap the sigterm or interupt and gracefully shutdown the server
	sigChan := make(chan os.Signal)
	signal.Notify(sigChan, os.Interrupt)
	signal.Notify(sigChan, os.Kill)

	sig := <-sigChan

	l.Println("Received Terminate, Graceful Shutdown", sig)

	tc, _ := context.WithTimeout(context.Background(), 30*time.Second)
	s.Shutdown(tc)
}        

Here We have created an instance for our logger, and then we have configured our endpoints with their respective handlers and middlewares.

And finally, we are done with our small demonstration of the go structure.

I started with this very same architecture and slowly was able to build a microservice with a logging system, connection with Postgres and MongoDB database, encryption layer over Jwt token, and other stuff which I will be sharing in the upcoming part.

Hope this will be useful for you!

This content was originally published on Kugelblitz's Blog.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了