HTTP Servers Persisting Data in MongoDB
Rate this tutorial
In the previous article and the corresponding video, we wrote a basic HTTP server from scratch. We used Go 1.22's new capabilities to deal with different HTTP verbs and we deserialized data that was sent from an HTTP client.
Exchanging data is worthless if you forget it right away. We are going to persist that data using MongoDB. You will need a MongoDB Atlas cluster. The free one is more than enough. If you don't have an account, you can find guidance on how this is done on this workshop or YouTube. You don't have to do the whole lab, just the parts "Create an Account" and "Create a Cluster" in the "MongoDB Atlas" section. Call your cluster "NoteKeeper" in a FREE cluster. Create a username and password which you will use in a moment. Verify that your IP address is included. Verify that your server's IP address is allowed access. If you use the codespace, include the address 0.0.0.0 to indicate that access is allowed to any IP.
- So far, we have used packages of the standard library, but we would like to use the MongoDB driver to connect to our Atlas cluster. This adds the MongoDB Go driver to the dependencies of our project, including entries in
go.mod
for it and all of its dependencies. It also keeps hashes of the dependencies ingo.sum
to ensure integrity and downloads all the code to be able to include it in the program.1 go get go.mongodb.org/mongo-driver/mongo - MongoDB uses BSON to serialize and store the data. It is more efficient and supports more types than JSON (we are looking at you, dates, but also BinData). And we can use the same technique that we used for deserializing JSON for converting to BSON, but in this case, the conversion will be done by the driver. We are going to declare a global variable to hold the connection to MongoDB Atlas and use it from the handlers. That is not a best practice. Instead, we could define a type that holds the client and any other dependencies and provides methods –which will have access to the dependencies– that can be used as HTTP handlers.
1 var mdbClient *mongo.Client - If your editor has any issues importing the MongoDB driver packages, you need to have these two in your import block.
1 "go.mongodb.org/mongo-driver/mongo" 2 "go.mongodb.org/mongo-driver/mongo/options" - In the
main
function, we initialize the connection to Atlas. Notice that this function returns two things. For the first one, we are using a variable that has already been defined at the global scope. The second one,err
, isn't defined in the current scope, so we could potentially use the short variable declaration here. However, if we do, it will ignore the global variable that we created for the client (mdbClient
) and define a local one only for this scope. So let's use a regular assignment and we neederr
to be declared to be able to assign a value to it.1 var err error 2 mdbClient, err = mongo.Connect(ARG1, ARG2) - The first argument of that
Connect()
call is a context that allows sharing data and cancellation requests between the main function and the client. Let's create one that is meant to do background work. You could add a cancellation timer to this context, among other things.1 ctxBg := context.Background() - The second argument is a struct that contains the options used to create the connection. The bare minimum is to have a URI to our Atlas MongoDB cluster. We get that URI from the cluster page by clicking on "Get Connection String." We create a constant with that connection string. Don't use this one. It won't work. Get it from your cluster. Having the connection URI with user the and password as a constant isn't a best practice either. You should pass this data using an environment variable instead.
1 const connStr string = "mongodb+srv://yourusername:yourpassword@notekeeper.xxxxxx.mongodb.net/?retryWrites=true&w=majority&appName=NoteKeeper" - We can now use that constant to create the second argument in place.
1 var err error 2 mdbClient, err = mongo.Connect(ctxBg, options.Client().ApplyURI(connStr)) - If we cannot connect to Atlas, there is no point in continuing, so we log the error and exit.
log.Fatal()
takes care of both things.1 if err != nil { 2 log.Fatal(err) 3 } - If the connection has been successful, the first thing that we want to do is to ensure that it will be closed if we leave this function. We use
defer
for that. Everything that we defer will be executed when it exits that function scope, even if things go badly and a panic takes place. We enclose the work in an anonymous function and we call it because defer is a statement. This way, we can use the return value of theDisconnect()
method and act accordingly.1 defer func() { 2 if err = mdbClient.Disconnect(ctxBg); err != nil { 3 panic(err) 4 } 5 }()
- Then, we want to use the collection (roughly equivalent to a table in a relational database) that will contain our notes in the
NoteKeeper
database. The first time we refer to this collection, it gets created. And this can be done because there is no need to define the schema of that collection before adding data to it. We are going to access the collection from within the HTTP handler implemented inCreateNote()
right before we write to the response writer of the previous code.1 notesCollection := mdbClient.Database("NoteKeeper").Collection("Notes") - In the next line, we insert the note that has been obtained by deserializing the data in the request. Also from the HTTP request, we obtain the context that was used with it, to extend its use to the request to Atlas.
1 result, err := notesCollection.InsertOne(r.Context(), note) - Should there be any problems with the
InsertOne()
request, the handler will have to return an err and the proper HTTP status. This has to be done before anything is written to the response writer or it will be ignored. It is not a best practice to return the database error to the user. You might be revealing too much information.1 if err != nil { 2 http.Error(w, err.Error(), http.StatusInternalServerError) 3 return 4 } - And if all goes well, we print the ID of the new entry, at the bottom of the HTTP handler.
1 log.Printf("Id: %v", result.InsertedID) - Compile and run. Then, we use the same request that we had before, only this time, the data will also be persisted to the database in the cloud.
1 curl -iX POST -d '{ "title": "Master plan", "tags": ["ai","users"], "text": "ubiquitous AI", "scope": {"project": "world domination", "area":"strategy"} }' localhost:8081/notes
In this article, we have covered:
- The use of packages to easily incorporate additional functionality (such as the MongoDB driver).
- The implementation of error handling in an HTTP handler.
- The persistence of moderately complex data in a cloud service.
These ideas can be trivially extended to implement a full-featured REST API, a back-end server, or a microservice, so you might consider this your first step to real Go super-powers.
In the next article of this series, we will pay attention to a hairy detail: concurrency. We will use goroutines and channels to implement a graceful shutdown of our server. The repository has all the code for this series and the next ones so you can follow along.
Stay curious. Hack your code. See you next time!
Top Comments in Forums
There are no comments on this article yet.