Go Quirks

栏目: IT技术 · 发布时间: 4年前

内容简介:I've recently started working with Go on a full time basis. It's a fun language to use, and it comes with a rich standard library. Shipping a production ready HTTP server in the standard library is no small feat. It's not free from issues and quirks though

I've recently started working with Go on a full time basis. It's a fun language to use, and it comes with a rich standard library. Shipping a production ready HTTP server in the standard library is no small feat. It's not free from issues and quirks though.

In this post, I'll discuss some of the issues and quirks I've encountered in my journey with Go. I've deliberately chosen to not talk about often raised issues such as the lack of generics and the err != nil error handling pattern because they've been discussed at length and they're being addressed right now by the Go team for Go 2.

Zero initialization

Go permits variables and struct fields to not be explicitly initialized with a value, in which case it gives the variables or fields a zero value. I believe that this is quite dangerous. It can be a subtle source of bugs and unexpected behavior.

I first encountered issues related to this when one of our micro services started running out of file descriptors and having spurious errors because of it. This is the code that caused the issue:

client := &http.Client {
    Transport: &http.Transport{
        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
    }
}

At first glance this looks pretty inoffensive, but it actually contains an error that causes TCP sockets to be leaked.

What happens is that we create a new http.Client where the transports' timeouts are all left unspecified. Since they are unspecified, Go initializes them to their zero value. This is the issue.

In the docs for http.Transport ,

// IdleConnTimeout is the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing
// itself.
// Zero means no limit.
IdleConnTimeout time.Duration // Go 1.7

you can see that a zero value means that the timeout is infinite, so the connections are never closed.

Over time the sockets accumulate and you end up running out of file descriptors. The amount of time it takes to manifest depends on how much activity your service gets and the ulimit setting for file descriptors.

The fix for this problem is simple: provide non-zero timeouts when initializing an http.Transport . This stackoverflow answer demontrates how to copy the default values from the http library.

Still, this is an easy trap to fall into, and to my knowledge there is no lint to help with this kind of issue at the moment.

There are other pervert side effects to this. For instance, unexported fields will always be initialized to their zero values since the fields cannot be initialized from outside the package.

Here's an example package:

package utils

type Collection struct {
    items map[string]string
}

func (c *Collection) Set(key, val string) {
    c.items[key] = val
}

Here's an example use of this package:

package main

func main() {
    col := utils.Collection{}
    col.Set("name", "val") // panic: assignment to nil map
}

The solution to this in not elegant. It's defensive programming. Before accessing the map, the package author must check if it has been initialized:

func (c *Collection) Set(key, val string) {
    if c.items == nil {
        c.items = make(map[string]string)
    }
    c.items[key] = val
}

This can get hairy quickly if the struct has multiple fields.

A solution to this is to provide a constructor function for the type, such as utils.NewCollection() which always initializes the fields, but even with this constructor, nothing prevents the user from initializing their struct with utils.Collections{} and causing a heap of issues down the line.

Over zealous linting

I believe that the compiler is too strict about unused variables. It's not rare for me to comment a function call and end up having to modify multiple lines above the call. I'll demontrate this with an example.

Here, I have an API client on which I can send requests and receive responses.

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

resp, err := client.GetSomething()
if err != nil {
    return err
}

process(resp)

Now let's say that I want to debug my code and that I comment out the call to the process function.

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

resp, err := client.GetSomething()
if err != nil {
    return err
}

//process(resp)

Now the compiler complains that resp declared and not used . Ok, I'll use _ instead of resp .

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

_, err := client.GetSomething()

// process(resp)

Now the compiler complains that no new variables on left side of := . Ah! Right! err is declared before. I'll use = instead of := .

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

_, err = client.GetSomething()

// process(resp)

Finally it compiles! But I had to change my code twice just to comment out a line. It's not rare that I have to do more edits than this before my program compiles.

I wish the compiler had a development mode where unused variables are simply warnings and do not prevent compilation, so that the edit-compile-debug cycle is not as excruciating as it currently is.

Returning errors

There's been a lot of talk about error management in Go. I personally don't mind the if err != nil { return err } pattern. It can be improved upon, and there's been proposals to improve it in Go 2.

What bugs me the most is the tuple style returns. When a function can produce an error, you must still provide a valid dummy value when an error occurs. For instance if your function returns (int, error) then you must return 0, err , i.e. still provide a value for the int we normally return if everything goes right.

I believe this is fundamentally wrong. Firstly, I shouldn't have to figure out some dummy value to return when there's an error. This leads to the over-use of pointers, because it's much easier and much cleaner to return nil, err than to return an empty struct with zero values and the error such as return User{}, err .

Secondly, having provided a valid dummy value makes it easy to not handle the error by mistake on the calling side, and move on assuming the dummy value is the right one.

// The fact that err is declared and used here makes it so
// there's no warnings about it being unused below.
err := hello()
if err != nil {
    return err
}
x, err := strconv.ParseInt("not a number", 10, 32)
// Forget to check err, no warning
doSomething(x)

This kind of error is even harder to find than if we had just returned nil. Because if we had returned nil, we'd hopefully have a nil pointer panic somewhere down the line.

I believe languages with support for sum types, such as Rust, Haskell or OCaml solve this more elegantly. When an error occurs there is no need to provide a value for the non-error return value.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

A result is either Ok(T) or Err(E) , never both.

fn connect(port u32) -> Result<Socket, Error> {
    if port > 65536 {
        // note that I don't have to provide a value for Socket
        return Err(Error::InvalidPort);
    }
    // ...
}

The nil slice and JSON

The recommended way of creating a slice in Go is using a var declaration such as var vals []int . This statement creates a nil slice, meaning that there's no array backing this slice: it's just a nil pointer. The append function supports appending to a nil slice, which is why you can use the pattern vals = append(vals, x) . The len function also supports nil slices, returning 0 when a slice is nil. In practice this works well in most cases but it can lead to weird behavior.

For instance, imagine that we are building a JSON API. We query things from a database and convert them to objects so they can be serialized as JSON. Here's how the service layer could look like:

package models

import "sql"

type Customer struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func GetCustomers(db *sql.DB) ([]*Customer, error) {
    rows, err := db.Query("SELECT name, email FROM customers")
    if err != nil {
        return nil, err
    }

    var customers []*Customer
    for _, row := range rows {
        customers = append(customers, &User {
            Name: row[0].(string)
            Email: row[1].(string)
        })
    }

    return customers, nil
}

This is fairly straight forward. Here's how a HTTP controller using this service could look like:

package controllers

import "http"
import "encoding/json"
import "github.com/me/myapp/models"

func GetCustomers(req *http.Request, resp http.ResponseWriter) {
    ...
    customers, err := models.GetCustomers(db)
    if err != nil {
        ...
    }
    resp.WriteHeader(200)
    if err := json.NewEncoder(resp).Encode(customers); err != nil {
        ...
    }
}

This is all basic stuff, but it actually contains a quirk which might trigger bugs in consumers of this API. When there are no customers in the database, the SQL query will not return any rows. Therefore the loop which appends to the customers slice will never process any item. The customers slice will be returned as nil.

When the JSON encoder sees a nil slice, it will write null to the response, instead of writing [] which should be the case when there are no results. This is bound to create problems for consumers of the API that expect an empty list when there are no items.

The solution is simple, either use a slice literal customers := []*Customer{} or use a call to make such as customers := make([]*Customer, 0) . Note that some Go linters will warn you against using an empty slice literal and suggest to use var customers []*Customer instead, which does not have the same semantics.

There are also other places where this can cause trouble. To the len function, an empty map and a nil map are the same. They have 0 elements. But to other functions, such as reflect.DeepEqual these maps are not the same. I think that given how len behaves, it could be expected that a function which checks if both maps are the same would say they are. But reflect.DeepEqual disagrees, probably a by-product of using reflection to compare two objects, which is not a very good idea, but the only option available for now in Go.

Go modules and Gitlab

While relying on Git repositories to download modules might seem like a good idea at first, as soon as a more complicated use case shows up, Go modules completely fall apart. My team has had a heap of issues getting Go modules to work with our private Gitlab instance. There are two major issues that we've had.

The first issue is that Gitlab permits users to have recursive groups of projects. For instance you can have a git repository located at gitlab.whatever.com/group/tools/tool-1 . This is not something that Go modules support out of the box. Go modules will try to download gitlab.whatever.com/group/tools.git because it assumes that the website uses a pattern similar to GitHub where there can only ever be 2 levels of nesting. We've had to use a replace inside of our go.mod file to point Go modules to the right place.

There is another way of solving this issue using an HTML <meta> tag which points to the right git repository, but this requires the Git plaform to support it. It doesn't feel like a good design decision to require Git platforms to add this special case for Go modules. Not only does it require upstream changes in the Git plaforms, but it also requires the deployed software to be upgraded to a recent version, which is not always a fast process in enterprise deployments.

The second issue is that, since our Gitlab instance is private and Go tries to download git repositories via https , we get 401 errors when we try to download Go modules without any authentication. Using our Gitlab password to authenticate is not a practical option, especially if CI/CD is involved. The solution we have found is to force git to use ssh when https requests are made with this piece of .gitconfig .

[url "git@gitlab.whatever.com:"]
	insteadOf = https://gitlab.whatever.com

This solution works well in practice, but it is not easy to fix it when you first encounter it. It also assumes that your SSH public key is registered in Gitlab and that your private key is not encrypted with a password. It might be possible to use an encrypted private key if you register your password in a keyring agent, like GNOME Keyring or KDE Wallet and that git integrates with it, but I haven't tried doing this so I can't say if it works.

Date formatting API

Go's date formatting format is quite surprising to say the least. Instead of using the commonly used strftime %Y-%m-%d format or the yyyy-mm-dd format, Go uses placeholder numbers and words which have a special meaning. If you want to format a date with the yyyy-mm-dd format in Go, you must use the "2006-01-02" format string. 2006 is a placeholder for the year, 01 is a placeholder for the month, and 02 is a placeholder for the day. The word Jan stands for date's month in the three letter abbreviation form Jan , Feb , Mar , etc.

I find this unnecessarily painful. It's hard to remember without looking at the documentation, it's extremely confusing, and it breaks for no good reason from the strftime standard format which has been in use for half a century.

I also find that the official documentation for the time package does a terrible job of explaining this. It barely mentions how this is supposed to work and you have to rely on third party sources to explain it in a concise and clear way.

Untyped constants

Take a look at this piece of code.

sess, err := mongo.Connect("mongodb://...")
if err != nil {
    return err
}

defer mongo.Disconnect(sess)

ctx, cancel := context.WithTimeout(context.Background(), 15)
defer cancel()

if err := sess.Ping(ctx, nil) {
    return err
}

This looks pretty innocuous. We connect to a MongoDB database, we defer the disconnection for when the function exits, then we create a context with a 15 second timeout and we run a ping command using this context to health check the database. This should work, right? Except it doesn't. It will return a context deadline exceeded error every time.

Why? Because the context we created does not have a 15 second timeout. It has a 15 nanosecond timeout. Might as well be no timeout at all, this is instant failure.

The context.WithTimeout function accepts and a context.Context and a time.Duration . time.Duration is a newtype defined as type Duration int64 . We were able to pass an int to this function because of Go's untyped constants. That is, a constant does not have a type before one is assigned to it. So the 15 is not an int literal or an int constant. When we pass it as a time.Duration is becomes typed as a time.Duration .

This all means that there is no type error or lint telling us that we didn't give this function a proper time.Duration . Normally you want to pass this function time.Second * x to timeout in x seconds. The multiplication with time.Second which is of type time.Duration could perform the type cast and make this usage type safe. But this is not the case at the moment, and an untyped constant is just as valid as a real time.Duration , which is what creates this footgun.

Conclusion

Go is a fun and very productive language. It aims for simplicity and it manages to deliver it on the most part. However simplicity should not be the priority over correctness. If you choose simplicity over correctness you end up cutting corners and delivering broken solutions.

I think the Go modules interaction with Gitlab is a good example of this. Instead of doing what every other language does, which is to have package registries, Go has decided to use a "simple" solution and just fetch things from git servers. Except that it breaks spectacularly when authentication to the git server is required. And it also breaks when the git server does not have the same naming/grouping conventions than GitHub. In the end you end up wasting a day looking at stackoverflow questions trying to fix the "simple" package system.

I've been following the proposals for Go 2 and I'm happy to see that the Go team is taking their time with it. They are gathering a lot of community feedback which is great. Users often have very interesting input to give. Go 2 is a great opportunity to fix some of the quirks mentionned in this post, or at the very least, empower the users to create their own set of data structures and types which address some of these quirks.

I know for sure that I'll be writing a bunch of utilities and data structures to make my programs safer and more ergonomic when Go 2 arrives.


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

算法技术手册

算法技术手册

George T. Heineman、Gary Pollice、Stanley Selkow / 杨晨、李明 / 机械工业出版社 / 2010-3 / 55.00元

《算法技术手册》内容简介:开发健壮的软件需要高效的算法,然后程序员们往往直至问题发生之时,才会去求助于算法。《算法技术手册》讲解了许多现有的算法,可用于解决各种问题。通过阅读它,可以使您学会如何选择和实现正确的算法,来达成自己的目标。另外,书中的数学深浅适中,足够使您可以了解并分析算法的性能。 较之理论而言,《算法技术手册》更专注于应用。《算法技术手册》提供了高效的代码解决方案,使用多种语言......一起来看看 《算法技术手册》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

SHA 加密
SHA 加密

SHA 加密工具

html转js在线工具
html转js在线工具

html转js在线工具