How To Build A Concurrent Webserver In Go
The basics of constructing a webserver, introducing concurrency, and avoiding race conditions
👋 Welcome
A warm welcome to the 4 new subscribers joining us this week.
Let me know what you think of this post in a comment, if it’s no trouble. If you prefer Twitter, drop me a DM.
Now, let’s get started with this issue.
How To Build A Concurrent Webserver In Go
A simple hello world webserver begins as follows: we use the net/http
standard library to define an endpoint, and serve at a particular port:
func main() {
http.HandleFunc("/", hello)
http.ListenAndServe(":8080", nil)
}
where hello
is a function made just to write a “Hello, World!” response:
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
}
So what exactly is happening here? Well, a few things:
http.HandleFunc operates Golang’s default HTTP router, also called a ServeMux, in official terms.
Every time a new request comes at
http://localhost:8080
, we get a new Go goroutine that invokes the function “hello
” to write a response viahttp.ResponseWriter
.
Let’s test it:
$ curl http://localhost:8080
Hello, world!
Querying a real API
A much better practise would be to try out a third-party API, from something like the CoinAPI service (https://coinapi.io). This website provides a Free API to query real-time exchange rates for different cryptocurrencies.
A typical response while querying a single crypto price looks like this:
{
"time": "2017-08-09T14:31:18.3150000Z",
"asset_id_base": "BTC",
"asset_id_quote": "USD",
"rate": 16260.3514321215056208129867667
}
For developers coming from Python, it is important to note that we need to define a structure that can take in this kind of response, as Go is a statically typed language.
We want everything except the time, so let’s make the following struct
:
type singleCryptoPrice struct {
ID string `json:"asset_id_base"`
Currency string `json:"asset_id_quote"`
MarketPrice float32 `json:"rate"`
}
The ID, Currency, and MarketPrice are the Tags that allow us to get mirrored API response into the respective properties.
Once we have this structure, if you want to follow along, do get a free API key from the website first. Keep it in a .env
file as a secret.
Now, install the library GoDotEnv (https://github.com/joho/godotenv) and read the secret within the init function:
$ go get github.com/joho/godotenv
func init() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
api_key = os.Getenv("API_KEY")
}
Now, back in the main function, we make a new endpoint to handle querying the API endpoint to get a particular cryptocurrency’s realtime price:
func main() {
http.HandleFunc("/price/", func(w http.ResponseWriter, r *http.Request) {
begin := time.Now()
cryptoName := strings.SplitN(r.URL.Path, "/", 3)[2]
data, err := querySinglePrice(cryptoName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]interface{}{
"response": data,
"took": time.Since(begin).String(),
})
})
We define the localhost:8080/price/ endpoint and do the following:
we get the name of the cryptocurrency after the price/ in the URL
we pass the crypto to the
querySinglePrice
function (displayed in bold) in order to query the price and return it (to be written)we do a simple error check
we take the returned struct body and with the help of the json.NewDecoder which leverages interfaces, we JSON-encode the
singleCryptoPrice
data automatically.
Next, let’s define the querySinglePrice
function which takes the following input:
the name of the cryptocurrency as a string
and outputs:
a singleCryptoPrice type response data
func querySinglePrice(cryptoName string) (singleCryptoPrice, error) {
c := http.Client{Timeout: time.Duration(3) * time.Second}
req, err := http.NewRequest("GET", "https://rest.coinapi.io/v1/exchangerate/"+cryptoName+"/USD", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Add("X-CoinAPI-Key", api_key)
resp, err := c.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var cryptoPricePlaceholder singleCryptoPrice
err = json.NewDecoder(resp.Body).Decode(&cryptoPricePlaceholder)
if err != nil {
return singleCryptoPrice{}, err
}
return cryptoPricePlaceholder, nil
}
Here, we first make an http Client
and a new http.Get
request with the API URL to query the cryptocurrency’s price in US Dollars. We supply the API key with the req.Header.Add
function. Finally, we form the request via the Client.Do
function.
If the http.Get
succeeds, we defer a call to close the response body, which will execute when we leave the function scope (which is when we return from the querySinglePrice
function) and is an elegant form of resource management in Go.
If you are reading this article and haven’t subscribed (for FREE) yet, what’s stopping you?
Meanwhile, we allocate a singleCryptoPrice
struct, and use a json.Decoder
to unmarshal the response received from the response body directly into our singleCryptoPrice
struct.
Perfect! We’re ready to go make a request!
$ curl http://localhost:8080/price/BTC
{"response":{"asset_id_base":"BTC","asset_id_quote":"USD","rate":16969.97}, "took":"33.93799ms"}
One more time while querying another asset:
$ curl http://localhost:8080/price/DOGE
{"response":{"asset_id_base":"DOGE","asset_id_quote":"USD","rate":0.10207047}, "took":"32.72136ms"}
A slightly more complex query
Now that we know the fundamentals of constructing a Go webserver, we can go do something a little more interesting.
In this section, let’s delve into building:
A detailed crypto asset exchange query endpoint, and
Making the server concurrent
Protecting the concurrency against race conditions
Let’s get into it!
Consider the scenario in which we want to query a list of crypto asset prices and display their current prices in USD.
We can construct a slice of those asset tickers and run the API query on them via a for
loop.
Having said that, let’s add another endpoint to the main
function:
http.HandleFunc("/allprices/", func(w http.ResponseWriter, r *http.Request) {
begin := time.Now()
var sliceOfResponses []singleCryptoPrice
listOfCrypto := [4]string{"BTC", "ETH", "DOGE", "SOL"}
for _, crypto := range listOfCrypto {
data, err := querySinglePrice(crypto)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sliceOfResponses = append(sliceOfResponses, data)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"response": sliceOfResponses,
"took": time.Since(begin).String(),
})
})
Here, we start with a slice (a list) of crypto tickers (bitcoin, ethereum, dogecoin, and solana). We run a for
loop on them, then query the price of the asset, and finally append the response struct to the sliceOfResponses
variable.
Later, like earlier, we produce a response along with the time taken to fetch all the coin prices.
Let’s try it out:
$ curl http://localhost:8080/allprices/
{"response":[{"asset_id_base":"BTC","asset_id_quote":"USD","rate":17028.38},{"asset_id_base":"ETH","asset_id_quote":"USD","rate":1260.02},{"asset_id_base":"DOGE","asset_id_quote":"USD","rate":0.10379009},{"asset_id_base":"SOL","asset_id_quote":"USD","rate":13.446569}],"took":"105.612597ms"}
Perfect! Note the time it takes to run the query one by one on each coin.
Making the server concurrent
Go makes it easy to write multi-threaded architectures. Assuming that each iteration of the loop does not rely on the previous one, multi-threading them is a safe and simple way to boost our program’s efficiency.
In order to multi-thread our for
loop easily, we can use Go’s built-in goroutines combined with a sync.WaitGroup
datatype.
So what are WaitGroups used for, you may ask? To wait for multiple invoked goroutines to finish, we can use a wait group.
Let’s see the full concurrent endpoint first and then look at the components one by one:
http.HandleFunc("/allprices-concurrent/", func(w http.ResponseWriter, r *http.Request) {
begin := time.Now()
listOfCrypto := [4]string{"BTC", "ETH", "DOGE", "SOL"}
var responses sync.Map
wg := sync.WaitGroup{}
for idx, crypto := range listOfCrypto {
wg.Add(1)
go func(crypto string, idx int) {
data, err := querySinglePrice(crypto)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
responses.Store(idx, data)
wg.Done()
}(crypto, idx)
}
wg.Wait()
var sliceOfResponses []singleCryptoPrice
for i := 0; i < 4; i++ {
data, _ := responses.Load(i)
sliceOfResponses = append(sliceOfResponses, data.(singleCryptoPrice))
}
json.NewEncoder(w).Encode(map[string]interface{}{
"response": sliceOfResponses,
"took": time.Since(begin).String(),
})
})
We define two variables first:
wg
as the sync.WaitGroup instance, andresponses
as the sync.Map instance which helps contain the responses during the multi-threaded call inside thego func.
Then, as you’ll see, the go func
function takes the loop variables to define as a concurrent model. We query the price inside it, and the data returned is stored into the responses variable with the sync.Map.Store
function.
One this process is done and the go func can be concluded, we terminate the WaitGroup
instance with wg.Done()
Specified after the go func
, the wg.Wait()
function makes sure that all concurrent calls are finished before proceeding further.
Finally, we need to construct our response. This is done by querying from the responses variable using the sync.Map.Load
function. We form the slice of responses with the sliceOfResponses
variable, and return it after Encoding it with the JSON Encode function.
Let’s test it:
$ curl http://localhost:8080/allprices-concurrent/
{"response":[{"asset_id_base":"BTC","asset_id_quote":"USD","rate":17029.14},{"asset_id_base":"","asset_id_quote":"","rate":0},{"asset_id_base":"","asset_id_quote":"","rate":0},{"asset_id_base":"SOL","asset_id_quote":"USD","rate":13.447446}],"took":"19.846708ms"}
Great! This proves that concurrent calls makes our function about ~5 times more runtime efficient!
A few parting words
If you’ve made it this far, give yourself a pat on the back for a job well done! :) In this post, we saw with some practical use cases of concurrency in Go webserver architectures.
You can see the full code in my GitHub repository here.
Finally, if you found this post valuable, share it with a friend maybe?
Find me on Twitter.