Go Api Gateway — Using Go’s Reverse Proxy

Prithu Adhikary
7 min readNov 1, 2022

--

Yipee!

Why Go!

Performance! As simple as it gets. It compiles to native, hence amazingly fast. The closer you get to the machine language, the faster it gets. For example, C++ is way faster than Java and so is Go! Has a garbage collector that doesn’t pause the application. Yes, comparing it to the other extremely popular microservices framework i.e. Spring Boot, even though it is not as developer friendly in terms of lines of code written and you may end up writing a little bit more than what you would write for the same functionality in a Spring Boot application, the tradeoff is worth the pain. Fortunately, the Go ecosystem has evolved quite a bit in the years bygone and you now have a suite of really developer friendly frameworks at your disposal that accelerate your development cycle.

Go actually bridges the gap between being fast and being easy. It is developer friendly and yet enough low level to give you that added advantage of performance.

With way fewer nuts and bolts to get a web server up compared to Java, memory consumption is reduced by a factor of 30. That means, lesser infrastructure cost for the same performance or magnitudes of better performance with the same infrastructure.

Concurrency in Go is another icing on the cake. It is efficient, easy and is based on the principles of Communicating Sequential Processes(CSP). Analogous to a thread, Go has something called a goroutine. Even though Java has managed to ease the API for multithreaded processing especially for collections by introducing functional programming and the stream API, you would still end up using the Executors framework if you need to do something more than just processing collections concurrently(though Spring has “@Async” methods) that can save you some time.

Unlike Java, executing a function call asynchronously in Go is as easy as:

go functionCall()

And this does not mean starting a thread! What it schedules is a goroutine to be executed by an underlying pool of threads, where the default pool size equals the number of logical processors on the system. Go’s runtime scheduler maintains a run queue per thread. The count of the threads can go up if a goroutine invokes a blocking system call. In which case, the scheduler detaches the thread and the goroutine from the processor, spawns a new thread and attaches the rest of the queue to the new thread. Goroutines are extremely lightweight, even lighter than threads and the OS has no idea about it. Like the OS scheduler schedules threads against physical processors, the go runtime scheduler schedules goroutines against logical processors. And yes, the number of local processors can be more thn the actual number of CPU cores when Hyperthreading is enabled(like the Intel Hyperthreading that enables a single CPU core to act as two).

For example, on my system with an i5 processor, if I:

cat /proc/cpuinfo

I get the following output reporting the number of CPUs is four.

But the number of CPUs reported by Go’s runtime.NumCPU() function is eight! Because it reports the number of logical processors.

Enough Talk — Show Me The Codez!

The steps to setup a gateway are quite simple and obvious:

  • Initialze a go module.
  • Add the necessary libraries: Gorilla Mux (a more sophisticated http multiplexer that can handle wildcard paths) and Viper(for dealing with configuration).
  • Write a main function to load the configuration using viper, initialise SingleHostReverseProxy instances with the configuration for the microservices.
  • Create a Router(provided by the Gorilla Mux library).
  • Map the microservice context roots to the corresponding reverse proxy’s ServeHttp(rw http.ResponseWriter, req *http.Request).
  • Start up an http.Server with the previously created mux.

Initialise a go module

That’s pretty simple.

go mod init github.com/prithuadhikary/api-gateway

Yes, you can create a repository and replace the module path with yours!

Add Gorilla Mux And Viper

That’s also pretty simple.

go get -u github.com/gorilla/mux

And

go get  -u github.com/spf13/viper

Let’s Code — Loading The YAML Configuration

Viper is a versatile go library that can load configuration from a variety of config file formats ranging from yaml to json, toml to even java properties files.

YAML is an uncluttered and easy to read format. So, let’s stick to it.

Let us create the types to which we will load the configuration.

package maintype Route struct {
Name string `mapstructure:"name"`
Context string `mapstructure:"context"`
Target string `mapstructure:"target"`
}
type GatewayConfig struct {
ListenAddr string `mapstructure:"listenAddr"`
Routes []Route `mapstructure:"routes"`
}

Even though, pretty self explanatory, we have declared:

  • A Route struct that will contain the context root that will be mapped to the target. The target is basically an http url to which the requests will be forwarded. It also holds a name just for logging purposes.
  • A GatewayConfig struct that will hold the host:port combination the server will listen on, and a slice of Route structs holding the mappings between a context root and target url.

You will notice, the mapstructure tags. It is because, viper uses the mapstructure library under the hood to map configuration keys to structure fields.

Let us keep our gateway configuration in a default.yml file under a directory named config beneath the root of our module directory.

gateway:
listenAddr: localhost:8080
routes:
- name: Service A
context: /service-a
target: http://localhost:8082
- name: Service B
context: /service-b
target: http://localhost:8081

Let us initialise viper to load the configuration file.

viper.AddConfigPath("./config") //Viper looks here for the files.
viper.SetConfigType("yaml") //Sets the format of the config file.
viper.SetConfigName("default") // So that Viper loads default.yml.err := viper.ReadInConfig()
if err != nil {
log.Println("Warning could not load configuration", name)
}
viper.AutomaticEnv() // Merges any overrides set through env vars.

Now viper can unmarshall the configuration it has read to the structures we have defined. We just need to declare a GatewayConfig structure variable and pass its address to the viper.UnmarshalKey function specifying the key under which the configuration is present. In our case, the key is gateway. So,

gatewayConfig := &GatewayConfig{}  // declare and get address.
viper.UnmarshalKey("gateway", gatewayConfig) //Pass the address

It is often advisable to declare and capture the address of the structure at one go instead of just using the structure itself because it makes it easy to pass it around by reference instead of value(which will create a copy every time).

If everything went well, you should now have a pointer to a fully populated GatewayStructure struct.

Wiring It Up!

Once the config is read, we create the Gorilla Mux Router and set it up with paths prefixed with the context roots associated with HandlerFunc typed functions delegating the processing of the incoming requests to the target reverse proxy instances. Well that’s a mouthful, so all it boils down to:

Function To Create A Reverse Proxy Given A Target URL

//Returns a *httputil.ReverseProxy for the given target URL
func NewProxy(targetUrl string) (*httputil.ReverseProxy, error) {
target, err := url.Parse(targetUrl)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ModifyResponse = func(response *http.Response) error {
dumpedResponse, err := httputil.DumpResponse(response, false)
if err != nil {
return err
}
log.Println("Response: \r\n", string(dumpedResponse))
return nil
}
return proxy, nil
}

The above function parses the target url, creates a standard httputil.ReverseProxy pointing to target url and returns a pointer to it(along with any error that might have occured). It also adds an interceptor just to log the outgoing response. There is a utility function httputil.DumpResponse that dumps the response(and the body too optionally if you pass true as the second argument, useful for debugging purposes ONLY!).

We will iterate through all the Route structs in the Routes field and call the above mentioned function with the target for each route.

But, first let’s create the Gorilla Mux Router like so:

r := mux.NewRouter()

Now r holds a pointer to a mux.Router .

Now, lets look at the function that will return us a function conforming to theHanderFunc function type that will delegate an incoming request to the proxys’ ServeHTTP method.

func NewHandler(proxy *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = mux.Vars(r)["targetPath"]
log.Println("Request URL: ", r.URL.String())
proxy.ServeHTTP(w, r)
}
}

The above function just takes a pointer to the ReverseProxy and delegates the incoming request to the ServeHTTP method of the ReverseProxy pointed by the pointer proxy.

Do note that it extracts the path segment of the incoming request URL matching the regular expression .*. The aforementioned regex matches any sequence of characters except a newline. So, everything after the context root will be matched, extracted, appended to the target url and passed on to the target.

So, finally let’s loop through the Routes[] slice , initialise the reverse proxy for each context root and map it to the HandlerFunc returned by the NewHandler() function like so:

for _, route := range gatewayConfig.Routes {
// Returns a proxy for the target url.
proxy, err := NewProxy(route.Target)
if err != nil {
panic(err)
}
// Just logging the mapping.
log.Printf("Mapping '%v' | %v ---> %v", route.Name, route.Context, route.Target)
// Maps the HandlerFunc fn returned by NewHandler() fn
// that delegates the requests to the proxy.
r.HandleFunc(route.Context+"/{targetPath:.*}", NewHandler(proxy))
}

And finally, we just start the http server passing in the *Router r to the log.Fatal(http.ListenAndServe(gatewayConfig.ListenAddr, r)) call.

We can do so because mux.Router actually implements the http.Handler interface!

What’s left is running the code. CD into the go module root and:

go run .

Or build and run the binary instead:

go build . && ./api-=gateway

And you will see something like:

The code for the go module is available at:

https://github.com/prithuadhikary/api-gateway

That’s All Folks!

--

--