Skip to main content

Sudarshan's Blog

Why types are elegant

I’ve been working with Go(for work) and Rust(for fun) lately. I got pulled into the two languages for different reasons (easy concurrency and memory safety respectively) and ended up staying for a totally different reason altogether. Their type systems.

Composition instead of inheritance feels elegant, right and very readable. I’m going to talk about something that happened today when I was very thankful for composition and typing itself.

A colleague and I ran into a problem that quickly devolved into an argument and as it is with professional software engineers, wanton namecalling. For work, I’d written a common request making library. It wasn’t anything special. It would branch off of a http.Client and make requests and responses and parse them to specific interfaced outputs that we used for our internal client sdks.

This library took a logger interface. One that could potentially log debug messages. To keep it simple, we initially implemented it as with a single Printf method like so.

    type Logger interface {
    	Printf(format string, args ...interface{})
    }

The logger was hardly used. The errors were all propagated back from the library and didn’t need to be logged at all. The logger was primary used to run httputil.DumpRequest in a debug like functionality.

If you read the source code for DumpRequest, you’ll notice that it is an allocation heavy operation that consumes the request body and reassigns it. All in all, it isn’t something you want to be logging in production level request making code.

Our solution was simple. We’d make passing the logger interface optional. If the logger interface wasn’t nil, we’d call Print.


func (cl *CustomClient) Send(){
    ...
    httpReq, err := http.NewRequest(http.MethodGet, "https://something.com", populateBody())
    ...
    
    if cl.Logger != nil{
        reqDump, err := httputil.DumpRequest(r.req, r.body)
        ...
        cl.Logger.Printf("%s", string(reqDump))
    }

}

This solved our problem. We could use the expensive logging in a debug setting but essentially disable it in production by simply passing the logger as nil.

However…

Assume we wanted to, in the future, add a token bucket based request rate limiter. This rate limiter would come with the added feature of logging a warning every time it began throttling requests.

The interface above could change to something that looked like this.

type LevelLogger interface {
	Info(args ...interface{})
	Warn(args ...interface{})
	Debug(args ...interface{})
	Infof(format string, args ...interface{})
	Warnf(format string, args ...interface{})
	Debugf(format string, args ...interface{})
}

We could have the client implement multiple levels and even set the loglevel. (this example logger follows github.com/sirupsen/logrus ’s intuitive structure for illustration purposes)

This however puts us in a bit of a bother because our earlier implementation doesn’t account for this. True, the client could stop calling the debug log itself by a simple implementation that could look like this.

type structuredLogger struct{
    level int
}

func (s *structuredLogger) Debug(args ...interface{}){
    if s.level > 1 {
        return
    }
}

The printf code earlier I showed you would now look like this.


func (cl *CustomClient) Send(){
    ...
    httpReq, err := http.NewRequest(http.MethodGet, "https://something.com", populateBody())
    ...

    if cl.Logger != nil{
        reqDump, err := httputil.DumpRequest(r.req, r.body)
        ...

        // The structuredLogger implementation could stop calling this
        cl.Logger.Debugf("%s", string(reqDump))
    }

}

The obvious issue we ran into here was while the debug log wouldn’t get printed, the DumpRequest function would still be called. We debated a bit and screamed at each other angrily in German and Tamil.

And finally it dawned to me, as all things do, when I was in the bathroom. I washed my hands(I promise) and got typing. I’d been working a lot with Rust traits recently and while this was definitely a Go thing as well, the incessant typing of systems practise definitely helped.

The solution was still to use the logger, but make the dumped Request a type that would compute only if called inside log.Debugf taking advantage of Go’s fmt.Stringer.

type requestDump struct {
    req  *http.Request
    body bool
}

func (r *requestDump) String() string {
    if reqDump, err := httputil.DumpRequest(r.req, r.body); err == nil {
        return string(reqDump)
    }
    return ""
}


...

func (r *Request) Send() error{
        if r.Logger != nil {
            r.Logger.Debugf("%s \n", &requestDump{req: r.HTTPRequest, body: true})
        }
}

This works perfectly for our usecase. Not only does the allocation heavy dumpRequest only take place during debug loglevel, it’s isolated to this specific call of debug and only computes if it definitely is going get printed by the client’s implementation fo the Debugf itself.

This is probably a simple thing about Go that most of you already do but made me feel very happy about type systems coming out.

Let me know what you guys think in the comments.

comments powered by Disqus