My approach to learning a new language as an experienced developer (Go)
I’d like to share my thoughts on learning a new language in this article. Recently I’ve decided to give Go a try. It will be my experience, so take the advice with a pinch of salt. It won’t work if that is the first language you are learning. You should be familiar with software development and know one or two languages.
Starting with basics
It’s important to start with the basics and make yourself comfortable with the language and most importantly with the workflow. The goal is to create an environment where you can quickly iterate and try new code.
To be clear, I’m not suggesting spending much time or reading a huge book. I think it should be the opposite, you should minimize the time until you write your first line of code and make sure you can easily iterate and learn more.
Steps here:
Setup the editor so it’s helpful (for most people it will probably be VSCode, but I was exploring Zed recently and found it’s good enough to work with Go with `gopls` LSP)
Create your first module and make sure you can run it. Classic “Hello World” from the main function is what we need.
Trying code from `main` could be challenging as you go, so we need a better way. So here we should learn how to create unit tests, so we can test any function that we write.
And here was the first discovery, built-in features of the language regarding tests and not that great, so you probably will need a 3rd party library for assertions. Feels like the `testify` package is a popular one.
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSomething(t *testing.T) {
assert.Equal(t, "expected", YourFnToTest(1), "should work!")
}
Once we are here, you can spend a bit of time trying different things, solving some problems, etc. But to get a real feel of the language we need to check those areas:
create a web server
define routes
working with JSON (serialize and deserialize)
querying relational database
making HTTP requests
Of course in reality there is more, but those are the most commonly used things in back-end development, so by exploring all those areas - you’ll get a good understanding of the language ecosystem.
Web server
Being able to create a web server, and register some endpoints will be a huge leap forward. Now our application will start to feel real!
The first step is to check how it looks to create a web server without any 3rd party libraries, and due to the `net/http` package, it’s really easy in Go!
package main
import (
"fmt"
"net/http"
)
func hello(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "hello\n")
}
func main() {
http.HandleFunc("/hello", hello)
http.ListenAndServe(":8090", nil)
}
It’s already something! The next question is how we create a more complex application, here we will need a router.
I’ve done a bit of research on what are popular libraries and frameworks in this area, here are some: Gin, Echo, Fiber, Chi, Gorilla.
As I come from a Clojure background, I really hate heavy and overcomplicated frameworks with lots of magic.
So my choice here so far (and remember I’m only starting my Go journey) is Chi: https://github.com/go-chi/chi. It is small, basically just a router, but at the same time is flexible, by allowing different sets of middleware for groups of routes.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
})
http.ListenAndServe(":3000", r)
}
OK, that’s something! Unfortunately, nowadays we don’t talk plain text, so the next area to focus on is working with JSON, how to serialize and deserialize data.
Working with JSON
Again, Go has a standard package: encoding/json
(https://gobyexample.com/json)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
user := User{
ID: 1,
Name: "Andrey",
}
jsonText, _ := json.Marshal(user)
fmt.Println("As JSON", string(jsonText))
As I’ve picked Chi as a web component library, it makes sense, what is the recommended way to return JSON from handlers, there is a render package available: https://github.com/go-chi/render.
func (u *User) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
func GetUser(w http.ResponseWriter, r *http.Request) {
user := User{
ID: 1,
Name: "Andrey",
}
render.Status(r, http.StatusOK)
render.Render(w, r, &user)
}
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/user", GetUser)
http.ListenAndServe(":8787", r)
}
Working with SQL
SQL and relational databases are still the most commonly used persistent storage nowadays, so it’s crucial to know how to work with them from any language.
I’ll be using PostgreSQL for all examples. To work with it we will need an SQL package https://pkg.go.dev/database/sql and a driver https://github.com/lib/pq.
And while this should work for simple applications:
err := pool.QueryRowContext(ctx, "select p.name from people as p where p.id = :id;", sql.Named("id", id)).Scan(&name)
We really need a better way. But, I hate ORMs! I think this is a trauma from my old Java days with Hibernate. In my opinion, ORMs just bring so much magic and complexity without much value, it’s usually an application that is too small and simple for an ORM or already too complex.
I was looking for some kind of SQL query builder library and found https://github.com/go-jet/jet. It really reminded me of a type-safe Java library jOOQ, which I really enjoyed many years ago!
It’s not an ORM but it relies on code generation, it will generate tables and model definitions in Go based on your database schema. After that, you can write type-safe queries and benefit from auto-completion and additional checks from the compiler.
stmt := SELECT(
Actor.ActorID, Actor.FirstName, Actor.LastName, Actor.LastUpdate,
Film.AllColumns,
Language.AllColumns.Except(Language.LastUpdate),
Category.AllColumns,
).FROM(
Actor.
INNER_JOIN(FilmActor, Actor.ActorID.EQ(FilmActor.ActorID)).
INNER_JOIN(Film, Film.FilmID.EQ(FilmActor.FilmID)).
INNER_JOIN(Language, Language.LanguageID.EQ(Film.LanguageID)).
INNER_JOIN(FilmCategory, FilmCategory.FilmID.EQ(Film.FilmID)).
INNER_JOIN(Category, Category.CategoryID.EQ(FilmCategory.CategoryID)),
).WHERE(
Language.Name.EQ(String("English")).
AND(Category.Name.NOT_EQ(String("Action"))).
AND(Film.Length.GT(Int(180))),
).ORDER_BY(
Actor.ActorID.ASC(),
Film.FilmID.ASC(),
)
Looks nice!
I’ve used a database schema provided (https://github.com/go-jet/jet-test-data/blob/master/init/postgres/dvds.sql) to create my local database. And generated Go code from it:
jet -dsn=postgresql://user:pass@localhost:5432/jetdb?sslmode=disable -schema=dvds -path=./.gen
Finally, it’s time to link our web application with the database, so a handler should make a call to the database and return data as JSON.
A question I usually have at this point is how to pass dependencies to handlers, it is just global state or any dependency injection patterns. I’ve picked in my example just a global var to store my database connection handler, but I’ve found this link really useful to get the feel of other approaches: https://www.alexedwards.net/blog/organising-database-access
var DB *sql.DB
type FilmResponse struct {
*model.Film
}
func (f *FilmResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
func GetFilm(w http.ResponseWriter, r *http.Request) {
stmt := SELECT(
Film.AllColumns,
).FROM(
Film,
).WHERE(
Film.Length.GT(Int(150)),
).ORDER_BY(
Film.Title.ASC(),
)
var films []struct {
model.Film
}
err := stmt.Query(DB, &films)
if err != nil {
log.Fatal(err)
}
firstFilm := films[1]
render.Status(r, http.StatusOK)
render.Render(w, r, &FilmResponse{&firstFilm.Film})
}
func main() {
// I'm using TestContainers to start PostgreSQL
// see the code snippet at the end of the blog post
DB, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/film", GetFilm)
http.ListenAndServe(":8787", r)
}
Making HTTP requests
The final bit for the first round of learning is to figure out how to make HTTP requests. Again, looks like we have a standard package, so it’s simple!
Also, I’ve decided to wrap that in a new handler:
r.Get("/proxy", func(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get("https://pokeapi.co/api/v2/pokemon/ditto")
if err != nil {
log.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
w.Write([]byte(string(body)))
})
Final thoughts
I think an exercise like this will give you a good overview of the language and the ecosystem as well as hands-on experience. Of course, there is more to learn: concurrency, non-relational databases, working with AWS, etc. Also usually I prefer to read one or two books just to have more structured knowledge.
Bonus
I’m really enjoying working with TestContainers and Go is supported (https://golang.testcontainers.org/). I wasn’t using TestContainers for tests here, but just to avoid writing Docker Compose files to start the local database, I’ve done it from code in the main function:
ctx := context.Background()
dbName := "jetdb"
dbUser := "jet"
dbPassword := "jet"
postgresContainer, err := postgres.RunContainer(ctx, testcontainers.WithImage("docker.io/postgres:16-alpine"),
postgres.WithInitScripts("dvds.sql"),
postgres.WithDatabase(dbName),
postgres.WithUsername(dbUser),
postgres.WithPassword(dbPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
if err != nil {
log.Fatalf("failed to start container: %s", err)
}
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable", "application_name=test")
fmt.Println("PostgreSQL connection string", connStr)
DB, err = sql.Open("postgres", connStr)