How do we want to store our chat history?
As is common with every web application that wants to store data, we need a database. But there are many kinds of databases which differ based on the storage needs of an application. The main database types fall within relational and non-relational categories, also sometimes known as SQL and NoSQL.
The primary requirements for our database are:
retrieving data in an ordered fashion
retrieving data that relates to other data we are storing
So we are going to opt for a relational database.
We could have chosen some variety of NoSQL database, but not all of them meet both our requirements. Key-value stores, for example, often don't allow one to access data in an ordered fashion. Their main purpose is to provide specific pieces of data as fast as possible, rather than letting you search for a collection of data that meets certain criteria.
Document stores, another type of NoSQL database (the most popular of which is probably MongoDB) do allow for the ordered retrieval of documents, but don't always have the ability to retrieve data relating to other data in the database. We could certainly use a document store for our chat server, but due to the way our data will be stored, a SQL database matches our needs best.
Among SQL databases, we have a number of options: PostgreSQL, MySQL and SQL Server are the most common server variants of SQL, while SQLite is the most common embedded variant. Any of them would be fine choices, but because we are erring on the side of simplicity here, we will use SQLite.
Having an embedded database will make the setup easier. You will not need to set up a database server, or an admin account, or ensure that there is a user with the proper privileges available. You only need to know what file you want SQLite to use.
Now that we know what database we will be using, we can talk about how we will be using it.
Object-relational mapping (ORM)#
Object-relational mappings, or ORMs, are an incredibly useful tool in the web development kit. They create a common interface to interact with any flavor of SQL, and set up a mapping between data objects as they exist in the program and data objects as they exist in the database. One could still use SQL to do this mapping, and many ORMs even have a RAW SQL concept, allowing the user to write a SQL query that will be directly executed on the database, rather than generated from the ORM constructs. But more typically that option is only used for more complicated SQL queries, and most of the common cases are very well covered by the ORM.
ORMs are also useful for other features they offer for the data storage lifecycle. You can generally add hooks to various points when changing data, for example:
you could set up some code to run directly after a user has been created to complete other parts of your user setup flow
you could run a check before a user is saved to ensure that the data being added passes validation that is outside of the database
Hooks around lifecycle events are a great reason to use an ORM. In this chapter, we will be using GORM. It is one of the most popular Go ORMs, with all of the features we need. It's also relatively straightforward and easy to work with.
Although there are a large number of solid Go ORMs, another very popular one that deserves a specific mention is Beego. It's definitely worth taking a look at if you find that GORM is missing a feature you particularly like, or if you just want a slightly different take on what an ORM written in Go can look like.
Writing our database code#
Now that we have our general feature set and know what we're building, it's time to put together most of our database-related code.
Start with a
The first thing we need is a general
Config struct. It will store every piece of state that we will need in our handlers. The reason for setting up this struct is to avoid needing to have global state. For the moment, the only thing we need to store is a database connection, making our
Config struct rather simple:
As mentioned, we will be using GORM for our ORM, so the database connection is a
*gorm.DB, and we need to add
gorm.io/gorm to our imports at the top of the file.
Our user model#
Moving on, we need to define our
User struct. Because GORM will use this struct to create the database table that users will be stored in, we want this definition to include everything that the table does, including relationships to other tables. In this case, the only relationship will be to
Message, because all messages are sent by users, so a
User connects to every
Let's go through the user model. As mentioned, it has a foreign key relationship to
Message (a struct we will write next). It also uses
gorm.Model, which automatically includes an ID, and CreatedAt, UpdatedAt, and DeletedAt fields. The ID is a
uint, DeletedAt is a
sql.NullTime, and the remaining fields are
time.Time. GORM explicitly shows the definition of
gorm.Model in their documentation.
We are adding a
gorm:"uniqueIndex" on the
Name field. That way, we ensure all names are unique and we won't run into any user name confusion in our chat app.
It also includes
Messages, which is a slice of
Message. GORM will recognize this as a foreign key relationship, as defined in the has many section of their documentation. By default it will use a
UserID field included in the
Message struct. However, because it is important to know who sends any particular message, and not just their ID, we can set the foreign key to be the
An aside on the foreign key decision#
Having a string foreign key is uncommon because of the size of the index that is created for the foreign key. Typically foreign keys are numbers which allows the index on the key to be smaller, because the key itself will typically be smaller. Strings are quite variable in size and frequently larger than integers.
However, this decision is convenient because our
Message model will be able to have all the necessary information to display a message within itself, so we won't need to do a SQL JOIN in order to retrieve the contents of the message. That could be nice for a potential future where the database is sharded and JOINs become much more complicated.