
Building an HTTPS server in Go
It's finally time to dig into some code. Since Go is very well-suited for building modern web software, writing an HTTPS web server is easy. Let's begin by reviewing the piece of code we wrote in the preceding chapter to establish an HTTP web server:
http.ListenAndServe(endpoint, r)
It was a single line of code, a function called ListenAndServe(), which belongs to the HTTP Go package in the standard library. The first argument to ListenAndServe() was the endpoint to which we would like our web server to listen to. So, for example, if we would like our web server to listen to local port 8181, the endpoint would be :8181 or localhost:8181. The second argument is the object that describes the HTTP routes and their handlers—this object was created by the Gorilla mux package. The code to create it from the preceding chapter is as follows:
r := mux.NewRouter()
To convert the web server from the preceding chapter from HTTP to HTTPS, we will need to perform one simple change—instead of calling the http.ListenAndServer() function, we'll utilize instead another function called http.ListenAndServeTLS(). The code will look as follows:
http.ListenAndServeTLS(endpoint, "cert.pem", "key.pem", r)
As shown in the preceding code, the http.ListenAndServeTLS() function takes more arguments than the original http.ListenAndServe() function. The extra arguments are the second and third arguments. They are simply the digital certificate filename and the private key filename. The first argument is still the web server listening endpoint, whereas the last argument is still the handler object (which, in our case, is a Gorilla *Router object). We have already generated the certificate and private key files from the preceding step, so all we need to do here is to ensure that the second and third arguments point to the correct files.
That's it. This is all what we need to do in order to create an HTTPS web server in Go; the Go HTTP standard package will then take the certificate and private key and utilize them as required by the TLS protocol.
However, what if we would like to support both HTTP and HTTPS in our microservice? For this, we will need to get a little creative. The first logical step would be to run both the http.ListenAndServe() and the http.ListenAndServeTLS() functions in our code, but then we come across an obvious challenge: how would both functions listen on the same local port? We simply solve this by picking a listening port for HTTPS that is different than the listening port of HTTP. In the preceding chapter, we used a variable called endpoint to hold the value of the local HTTP server listening address. For HTTPS, let's assume that the local listening address is stored in a variable called tlsendpoint. With this, the code will look as follows:
http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r)
That sounds great, but now we are faced with another hurdle, both of http.ListenAndServeTLS() and the http.ListenAndServe() are blocking functions. This means that whenever we call them, they block the current goroutine indefinitely until an error occurs. This means that we can't call both functions on the same goroutine.
A goroutine is a vital language component in Go. It can be considered as a lightweight thread. Go developers make use of goroutines everywhere to achieve efficient concurrency. To communicate information between multiple goroutines, we use another Go language components called Go channels.
So, the solution for this is simple. We call one of the functions in a different goroutine. This can be simply achieved by placing the word go before the function name. Let's run the http.ListenAndServe() function in a different goroutine. Here is what the code would look like:
go http.ListenAndServe(endpoint,r)
http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r)
Perfect! With this, our web server can function as an HTTP server for clients who would like to use HTTP or an HTTPS server for clients who prefer to use HTTPS. Now, let's address another question: both of the http.ListenAndServe() and the http.ListenAndServeTLS() functions return error objects to report any issues in case of failure; so, can we capture errors produced from either function in case of failure, even though they run on different goroutines? For this, we'll need to make use of Go channels, which is the Go idiomatic way to communicate between two goroutines. Here is how the code will look like:
httpErrChan := make(chan error)
httptlsErrChan := make(chan error)
go func() { httptlsErrChan <- http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r) }()
go func() { httpErrChan <- http.ListenAndServe(endpoint, r) }()
In the preceding code, we create two Go channels, one called httpErrChan and the other one called httptlsErrChan. The channels will hold an object of type error. One of the channels will report errors observed from the http.ListenAndServe() function, whereas the other will report errors returned from the http.ListenAndServeTLS() function. We then use two goroutines with anonymous functions in order to run the two ListenAndServe functions and push their results into the corresponding channels. We use anonymous functions here because our code entails more than just calling the http.ListenAndServe() or the http.ListenAndServeTLS() functions.
You may note that we now run both of the ListenAndServe functions in goroutines instead of just one. The reason we do that is to prevent either of them from blocking the code, which will allow us to return both of the httpErrChan and the httptlsErrChan channels to the caller code. The caller code, which is the main function in our case, can then handle the errors as it pleases if any errors occur.
In the preceding chapter, we placed this code in a function called ServeAPI(); let's now look at the completed code of this function after our changes:
func ServeAPI(endpoint, tlsendpoint string, databasehandler persistence.DatabaseHandler) (chan error, chan error) {
handler := newEventHandler(databaseHandler)
r := mux.NewRouter()
eventsrouter := r.PathPrefix("/events").Subrouter() eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.FindEventHandler) eventsrouter.Methods("GET").Path("").HandlerFunc(handler.AllEventHandler) eventsrouter.Methods("POST").Path("").HandlerFunc(handler.NewEventHandler)
httpErrChan := make(chan error)
httptlsErrChan := make(chan error)
go func() { httptlsErrChan <- http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r) }()
go func() { httpErrChan <- http.ListenAndServe(endpoint, r) }()
return httpErrChan, httptlsErrChan
}
The function now takes a new string argument called tlsendpoint, which will hold the HTTPS server listening address. The function will also return two error channels. The function code then proceeds to define the HTTP routes that our REST API supports. From there, it will create the error channels we discussed, call the HTTP package ListenAndServe functions in two separate goroutines, and return the error channels. The next logical step for us is to cover the code that will call the ServeAPI() function and see how it handles the error channels.
As discussed earlier, our main function is what calls the ServeAPI() function, so this will put the burden of handling the returned error channels on the main function as well. Here is what the code in the main function will look like:
//RESTful API start
httpErrChan, httptlsErrChan := rest.ServeAPI(config.RestfulEndpoint, config.RestfulTLSEndPint, dbhandler)
select {
case err := <-httpErrChan:
log.Fatal("HTTP Error: ", err)
case err := <-httptlsErrChan:
log.Fatal("HTTPS Error: ", err)
}
The code will call the ServeAPI() function, which will then capture the two returned error channels into two variables. We will then use the power of the Go's select statement to handle those channels. A select statement in Go can block the current goroutine to wait for multiple channels; whatever channel returns first will invoke the select case that corresponds to it. In other words, if httpErrChan returns, the first case will be invoked, which will print a statement in the standard output reporting that an HTTP error occurred with the error found. Otherwise, the second case will be invoked. Blocking the main goroutine is important, because if we don't block it then the program will just exit, which is something we don't want happening if there are no failures. In the past, the http.ListenAndServe() function used to block our main goroutine and prevent our program from exiting if no errors occurred. However, since we now have run both of the ListenAndServe functions on separate goroutines, we needed another mechanism to ensure that our program does not exit unless we want it to.
In general, whenever you try to receive a value from a channel or send a value to a channel, the goroutine will be blocked till a value is passed. This means that if no errors are returned from the ListenAndServe functions, then no value will pass through the channels, which will block the main goroutine till an error happens.
There is another type of channels in Go beside regular channels called buffered channels, which can allow you to pass values without blocking your current goroutine. However, in our case here, we use regular channels.
The last piece of code we need to cover here is to update the configuration. Remember—in the previous chapter—that we used a configuration object in order to process configuration information for our microservice. The configuration information entailed database addresses, HTTP endpoints, and so on. Since we now also need an HTTPS endpoint, we need to add it to the configuration. The configuration code existed in the ./lib/configuration.go file. Here is what it should now look like:
package configuration
import (
"encoding/json" "fmt"
"gocloudprogramming/chapter3/myevents/src/lib/persistence/dblayer"
"os"
)
var (
DBTypeDefault = dblayer.DBTYPE("mongodb")
DBConnectionDefault = "mongodb://127.0.0.1"
RestfulEPDefault = "localhost:8181"
RestfulTLSEPDefault = "localhost:9191"
)
type ServiceConfig struct {
Databasetype dblayer.DBTYPE `json:"databasetype"`
DBConnection string `json:"dbconnection"`
RestfulEndpoint string `json:"restfulapi_endpoint"`
RestfulTLSEndPint string `json:"restfulapi-tlsendpoint"`
}
func ExtractConfiguration(filename string) (ServiceConfig, error) {
conf := ServiceConfig{
DBTypeDefault,
DBConnectionDefault,
RestfulEPDefault,
RestfulTLSEPDefault,
}
file, err := os.Open(filename)
if err != nil {
fmt.Println("Configuration file not found. Continuing with default values.")
return conf, err
}
err = json.NewDecoder(file).Decode(&conf)
return conf, err
}
In the preceding code, we did three main things from the last chapter:
- We added a constant called RestfulTLSEPDefault, which will default to localhost:9191.
- We added a new field to the ServiceConfig struct. The field is called RestfulTLSEndPint; it will be expected to correspond to a JSON field called restfulapi-tlsendpoint.
- In the ExtractConfiguration() function, we set the default value of the RestfulTLSEndPint field of the initialized ServiceConfig struct object to RestfulTLSEPDefault.
With those three changes, our configuration layer will be able to read the HTTPS endpoint value from a configuration JSON file if a configuration override exists. If either no configuration file exists, or no restfulapi-tlsendpoint JSON field is set in the configuration file, then we will take the default value, which is localhost:9191.
Any code that will call the ExtractConfiguration() function will get access to this functionality and be able to obtain either a default or a configured value for the HTTPS endpoint. In our code, the main function will call the ExtractConfiguration() function and will obtain the necessary information to call the ServeAPI() function, which will run our RESTful API.
Perfect! With this last piece, we conclude our chapter.