Tackling the Problem With the String Values and the Struct
Some of you may be familiar with the problem of binding string values, such as those from a POST request or URL query
parameter in the form of url.Values
and there are some (good) libraries that have been developed specifically for this
purpose, for example the quite popular go-playground/form
package.
My problem with such libraries is that they are usually based on reflection – which can be slow and is generally unpopular for good reasons – and that you need to have a little secret esoteric knowledge in your head to avoid mishandling niche cases or, if necessary, write your own handlers to convert data to the correct data type. It is also not convenient to validate the data, so you first convert the incoming data into a DTO and then check it using a separate validator.
Fortunately, a few years ago, the Go developers have heard our wishes and prayers and implemented generics, so we (or I) can rethink how this can be achieved in a type-safe manner for my existing and coming projects.
The basics
Apart from converting the values to the correct data type, this is more or less the only thing needed:
go code snippet start
type Binder[T any] interface {
BindValue(target *T, path []string, values []string) error
}
type BinderFunc[T any] func(target *T, path []string, values []string) error
func (f BinderFunc[T]) BindValue(target *T, path []string, values []string) error {
return f(target, path, values)
}
go code snippet end
If this seems familiar, there’s a good reason for that; it’s inspired by the net/http
handlers and is therefore
as simple as it can be powerful. Yes, you have to do a little groundwork here to become productive, as the values
don’t parse themselves, but it also offers the possibility to define your own, type safe rules. Let’s take, for example, a
binder that binds a string representation to a boolean:
go code snippet start
func NewBoolBinder[T ~bool]() BinderFunc[T] {
return func(target *T, _ []string, values []string) error {
v, err := strconv.ParseBool(values[0])
if err != nil {
return err
}
*target = T(v)
return nil
}
}
go code snippet end
In practice, this would be used as follows:
go code snippet start
binder := NewBoolBinder[bool]()
var b bool
if err := binder.BindValue(&b, nil, []string{"1"}); err != nil {
// do something
}
go code snippet end
Since this binder is stateless (as all binders should be, if possible), it can be initialized once and simply reused for further operations.
Increasing the complexity
There is also nothing to prevent you from calling other binders (or the same binder again) within a binder, which allows to prepare complex binders in a simple, transparent way. Let’s take the following struct as an example:
go code snippet start
type Node struct {
Value string
Next []*Node
}
// The corresponding values. If this is not clear, the keys
// will be split to act as path.
var values map[string][]string{
"value": []string{"foo"}
"next.0.value": []string{"bar"}
"next.0.next.0.value": []string{"baz"}
}
go code snippet end
Here we need two binders, one for string
and one for Node
. What is special here is that we have a slice, and
the slice contains pointer types. This has to be handled somehow, so two additional supporting binders are needed.
However, since everything is implemented with generics, this is not a problem, because these binders are reusable
for every data type.
go code snippet start
// nodeBinder uses itself, so we have to declare it first
var nodeBinder BinderFunc[Node]
// Simply applies the first value to the target
var stringBinder = BinderFunc[string](func(target *string, _ []string, values []string) error {
*target = values[0]
return nil
})
func init() {
nodeBinder = func(target *Node, path []string, values []string) error {
switch path[0] {
case "value":
return stringBinder.BindValue(&target.Value, path, values)
case "next":
nxt := NewSliceBinder(NewPtrBinder(nodeBinder))
return nxt.BindValue(&target.Next, path[1:], values)
}
return nil
}
}
func NewPtrBinder[T any](binder Binder[T]) BinderFunc[*T] {
return func(target **T, path []string, values []string) error {
return binder.BindValue(*target, path, values)
}
}
func NewSliceBinder[T any](binder Binder[T]) BinderFunc[[]T] {
return func(target *[]T, path []string, values []string) error {
// This is simplified and assumes the index idx exists.
// Remember: target is a pointer.
idx, err := strconv.Atoi(path[0])
return binder.BindValue(&(*target)[idx], path[1:], values)
}
}
go code snippet end
Validating incoming data
Simply binding the values is often not enough; they usually also need to be checked for their validity. There are several ways to do this, but the simplest and most flexible is to write a binder that validates the target:
go code snippet start
func WithValidation[T any](binder Binder[T], val func(*T) error) BinderFunc[T] {
return func(target *T, path []string, values []string) error {
err := binder.BindValue(target, path, values)
if err != nil {
return err
}
return val(target)
}
}
go code snippet end
Pros and Cons
I understand that it is easier and faster (to implement) to use reflection for this, especially when a project contains dozens or hundreds of DTOs. However, I want to know as much as possible about what is actually going on in my code base, and having control over how the data is bound or validated can be beneficial as the number of calls increases.
Custom solution | Reflection-based | |
---|---|---|
Ease of use | Once everything is set up, it’s relatively easy to define new handlers and map complex structures, but it does take it’s time (unless you use CG) | Very easy |
Speed | It depends largely on the implementation of the individual binders and the path split, but it can be very fast | Reflection is usually fast enough |
Transparency | Very expressive | Most cases will be obvious, others not so much |
Validation | Easy to achieve using multiple approaches | Requires custom checks or additional validation library |
Extensibility | As with http.Handler s, you can do some crazy stuff |
Depends on the library, most offer some level of extensibility |
I am currently working on an improved and more sophisticated version, which I am planning to move into a separate package, and I am planning to add some benchmark comparisons as well, which may be covered in a follow-up post.