The Beauty of Go: Scalable Services
I love writing and building beautiful things. Now you can support me by subscribing to this blog. Thanks a lot for doing this!
Since I moved to Amsterdam, I started writing Go code, and I was doing it quite extensively during the last year or so. Go became my primary language, and I love almost every detail of it.
To be honest, I've been very skeptical aboutGo in the beginning, probably because I've heard a lot of things about its repetitive nature. However, after a while I found out that all I've heard about was not true — Go code could be beautiful, and the language itself provides you with everything you need to write nice-looking and scalable code with a minimum number of repetitions.
Of course, when I started, my code was really simple, and the way I wrote it was quite dumb. So this article will mostly be about how not to repeat my mistakes and write scalable code from the beginning. It should also be interesting for those who have no idea about how Go code looks like and are simply curious about this language.
The dumb way
Coming from other languages, you may assume the Go is not object-oriented. However, it is, and here is one of the most standard things you will see in any Go project:
package models
type NewMessage struct {
ID uuid.UUID `json:"-"`
Text string `json:"text" binding:"required"`
}
type Message struct {
ID uuid.UUID `json:"id" gorm:"primary_key"`
Text *string `json:"text" gorm:"not null"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at"`
CreatedBy *uuid.UUID `json:"created_by" gorm:"type:uuid"`
DeletedAt *time.Time `json:"deleted_at,omitempty" sql:"index"`
}
type MessageService interface {
Insert(newMessage *NewMessage) (Message, error)
Delete(id uuid.UUID) error
}
In the snippet above, we define the MessageService interface, and the only thing we've left is the actual implementation. Implementing the interface in Go means creating a struct and defining all the methods from the desired interface. Let's say we're building a provider for messages that uses a database, and we want to implement Insert and Delete based on the interface above.
Also notice the json and sql struct tags we added to the fields in our structure — some of them are supported by default, but it's easy enough to define your own tags. Isn't it beautiful?
Here is the possible implementation of the interface. I skipped some irrelevant details but you can easily imagine how the missing parts might look like.
package db
type MessageProvider struct {
DB *DB
}
func (p *MessageProvider) Insert(m *models.NewMessage) (models.Message, error) {
fmt.Println("Insert called!")
...
}
func (p *MessageProvider) Delete(id uuid.UUID) error {
return p.DB.Where("id = ?", id.String()).Delete(models.Message{}).Error
}
The next thing beginners usually do is they pick up some HTTP framework like gin or go-chi, write a set of endpoints, and simply call the desired methods from the defined services. Easy-peasy!
package http_routing
// This example uses Gin framework.
// I prefer go-chi or net/http to gin just because they are more ideomatic.
router.POST("/messages", func(c *gin.Context) {
var newMessage models.NewMessage
if err := c.ShouldBindJSON(&newMessage); err != nil {
// don't return error like that in production applications!
c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
if _, err := messageService.Insert(&newMessage); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "OK"})
}
However, I called this method dumb for a reason: while it will certainly work in many situations, it's not scalable enough — adding your own logging or instumentation to the methods inside your services will make your code much more complicated and not so beautiful at all.
But there is a solution
First of all, let's define a struct for all our providers (imagine we have several of them):
package services
type Providers struct {
MessageProvider models.MessageProvider
...
}
Let's then rewrite our service a bit. Here we did one important thing — we made our service private (everything starting with the lowercase letter is private in Go) and defined a public function to create an instance of a service (let's call it a factory function).
package messages
func (s *service) Insert(m *NewMessage) (Message, error) {
fmt.Println("Insert called!")
...
}
func (s *service) Delete(id uuid.UUID) error {
...
}
type service struct {
providers *services.Providers
}
func NewService(providers *services.Providers) Service {
return &service{providers: providers}
}
And here is the magical trick: we can define one more struct that extends our service struct, but implements only some of its methods (let's say we don't want to log calls to Delete).
type loggingService struct {
logger log.Logger
Service
}
func (s *loggingService) Insert(m *models.NewMessage) (models.Message, error) {
defer func(begin time.Time) {
s.logger.Println(
"method", "insert_draft_to_repository",
"time", begin,
)
}(time.Now())
return s.Service.Insert(m)
}
func NewLoggingService(logger log.Logger, s Service) Service {
return &loggingService{logger, s}
}
What's also interesting in the snippet above is the usage of defer.
Here is what's happening here: we extend the method Insert, and the last line in the definition of this method means that we simply call the method with the same signature previously defined in our service (which means at least "Insert called!" will be printed out). But what we also do is we inject the function that will be called after the execution of this method. It means that the s.logger.Log method will print to the log after the insertion of a message to the database, but the time indicated there will be correct.
Defer is an extremely powerful Go mechanism, and together with struct inheritance it allows you to write some really beautiful code.
The only thing we have to do now is to build our service. Here is how it can be done:
providers := services.Providers{
MessageProvider: &db.MessageProvider{DB: DB}
}
messagesService := messages.NewService(providers)
messagesService = messages.NewLoggingService(logger, messagesService)
//messagesService = messages.NewInstrumentingService(messagesService)
//...
Diving deeper
This way of writing code can be especially useful when writing microservices. You can go further and start using go-kit to write microservices (or elegant monoliths, as they say) with logging, monitoring and instrumentation.
Here is what I did in my project:
usersService := users.NewService(providers)
usersService = users.NewLoggingService(logger, usersService)
usersService = users.NewInstrumentingService(
kitPrometheus.NewCounterFrom(prometheus.CounterOpts{
Namespace: "api",
Subsystem: "users_service",
Name: "request_count",
Help: "Number of requests received.",
}, []string{"method"}),
kitPrometheus.NewSummaryFrom(prometheus.SummaryOpts{
Namespace: "api",
Subsystem: "users_service",
Name: "request_latency_microseconds",
Help: "Total duration of requests in microseconds.",
}, []string{"method"}),
usersService,
)
router.Mount("/users", users.MakeHandler(usersService))
func (s *instrumentingService) GetUserByIdFromRepository(id uuid.UUID) (*models.User, error) {
defer func(begin time.Time) {
s.requestCount.With(
"method", "get_user_by_id_from_repository",
).Add(1)
s.requestLatency.With(
"method", "get_user_by_id_from_repository",
).Observe(time.Since(begin).Seconds())
}(time.Now())
return s.Service.GetUserByIdFromRepository(id)
}
All the data become processed and stored by Prometheus, and then displayed in Grafana. Go-kit includes a complete example that shows you how it can be done.
Conclusion
I hope you enjoyed the article and started seeing beauty in Go. I also hope that the examples I shared helped some of you to improve the quality of your Go code. If you have any suggestions and thoughts, please, let me know by writing to hi@alwx.me or my Telegram.
You can also support me by subscribing to this blog.