Skip to content

Commit

Permalink
Add Objects and ObjectValues field constructors (#1071)
Browse files Browse the repository at this point in the history
This pull request adds two new field constructors for slices, parameterized
over the types of the values in the slices:

```go
func Objects[T zapcore.ObjectMarshaler](key string, value []T) Field
```

This turns a slice of any Zap marshalable object into a Zap field.

```go
func ObjectValues[T any, P objectMarshalerPtr[T]](key string, values []T) Field
```

This is a variant of Objects for the case where `*T` implements
`zapcore.ObjectMarshaler` but we have a `[]T`.

Together, these two field constructors obviate the need for writing
`ArrayMarshaler` implementations for slices of objects:

```go
// Before //////////////

type userArray []*User

func (uu userArray) MarshalLogArray(enc zapcore.ArrayEncoder) error {
    for _, u := range uu {
        if err := enc.AppendObject(u); err != nil {
            return err
        }
    }
    return nil
}

var users []*User = ..
zap.Array("users", userArray(users))

// Now /////////////////

var users []*User = ..
zap.Objects("users", users)
```

### Backwards compatibility

Both new field constructors are hidden behind a build tag, but use of type
parameters requires us to bump the `go` directive in the `go.mod` file to 1.18.

Note that this *does not* break Zap on Go 1.17.
Per the [documentation for the `go` directive](https://go.dev/ref/mod#go-mod-file-go),

> If an older Go version builds one of the module’s packages and encounters a
> compile error, the error notes that the module was written for a newer Go
> version.

It only breaks our ability to run `go mod tidy` inside Zap from Go 1.17,
which is okay because with the `go` directive, we're stating that we're
developing Zap while on Go 1.18.

### Linting

staticcheck support for type parameters is expected in a few weeks.
Meanwhile, this disables staticcheck for the `make lint` target.
(I considered switching linting to Go 1.17 until then, but that isn't
enough because the formatting linter expects valid Go code--which type
parameters aren't in 1.17.)
  • Loading branch information
abhinav committed Mar 22, 2022
2 parents 34cb012 + 854d895 commit 15be647
Show file tree
Hide file tree
Showing 6 changed files with 376 additions and 4 deletions.
5 changes: 3 additions & 2 deletions Makefile
Expand Up @@ -26,8 +26,9 @@ lint: $(GOLINT) $(STATICCHECK)
@$(foreach dir,$(MODULE_DIRS),(cd $(dir) && go vet ./... 2>&1) &&) true | tee -a lint.log
@echo "Checking lint..."
@$(foreach dir,$(MODULE_DIRS),(cd $(dir) && $(GOLINT) ./... 2>&1) &&) true | tee -a lint.log
@echo "Checking staticcheck..."
@$(foreach dir,$(MODULE_DIRS),(cd $(dir) && $(STATICCHECK) ./... 2>&1) &&) true | tee -a lint.log
# @echo "Checking staticcheck..."
# @$(foreach dir,$(MODULE_DIRS),(cd $(dir) && $(STATICCHECK) ./... 2>&1) &&) true | tee -a lint.log
# TODO: Re-enable after https://github.com/dominikh/go-tools/issues/1166.
@echo "Checking for unresolved FIXMEs..."
@git grep -i fixme | grep -v -e Makefile | tee -a lint.log
@echo "Checking for license headers..."
Expand Down
124 changes: 124 additions & 0 deletions array_go118.go
@@ -0,0 +1,124 @@
// Copyright (c) 2022 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

//go:build go1.18
// +build go1.18

package zap

import "go.uber.org/zap/zapcore"

// Objects constructs a field with the given key, holding a list of the
// provided objects that can be marshaled by Zap.
//
// Note that these objects must implement zapcore.ObjectMarshaler directly.
// That is, if you're trying to marshal a []Request, the MarshalLogObject
// method must be declared on the Request type, not its pointer (*Request).
// If it's on the pointer, use ObjectValues.
//
// Given an object that implements MarshalLogObject on the value receiver, you
// can log a slice of those objects with Objects like so:
//
// type Author struct{ ... }
// func (a Author) MarshalLogObject(enc zapcore.ObjectEncoder) error
//
// var authors []Author = ...
// logger.Info("loading article", zap.Objects("authors", authors))
//
// Similarly, given a type that implements MarshalLogObject on its pointer
// receiver, you can log a slice of pointers to that object with Objects like
// so:
//
// type Request struct{ ... }
// func (r *Request) MarshalLogObject(enc zapcore.ObjectEncoder) error
//
// var requests []*Request = ...
// logger.Info("sending requests", zap.Objects("requests", requests))
//
// If instead, you have a slice of values of such an object, use the
// ObjectValues constructor.
//
// var requests []Request = ...
// logger.Info("sending requests", zap.ObjectValues("requests", requests))
func Objects[T zapcore.ObjectMarshaler](key string, values []T) Field {
return Array(key, objects[T](values))
}

type objects[T zapcore.ObjectMarshaler] []T

func (os objects[T]) MarshalLogArray(arr zapcore.ArrayEncoder) error {
for _, o := range os {
if err := arr.AppendObject(o); err != nil {
return err
}
}
return nil
}

// objectMarshalerPtr is a constraint that specifies that the given type
// implements zapcore.ObjectMarshaler on a pointer receiver.
type objectMarshalerPtr[T any] interface {
*T
zapcore.ObjectMarshaler
}

// ObjectValues constructs a field with the given key, holding a list of the
// provided objects, where pointers to these objects can be marshaled by Zap.
//
// Note that pointers to these objects must implement zapcore.ObjectMarshaler.
// That is, if you're trying to marshal a []Request, the MarshalLogObject
// method must be declared on the *Request type, not the value (Request).
// If it's on the value, use Objects.
//
// Given an object that implements MarshalLogObject on the pointer receiver,
// you can log a slice of those objects with ObjectValues like so:
//
// type Request struct{ ... }
// func (r *Request) MarshalLogObject(enc zapcore.ObjectEncoder) error
//
// var requests []Request = ...
// logger.Info("sending requests", zap.ObjectValues("requests", requests))
//
// If instead, you have a slice of pointers of such an object, use the Objects
// field constructor.
//
// var requests []*Request = ...
// logger.Info("sending requests", zap.Objects("requests", requests))
func ObjectValues[T any, P objectMarshalerPtr[T]](key string, values []T) Field {
return Array(key, objectValues[T, P](values))
}

type objectValues[T any, P objectMarshalerPtr[T]] []T

func (os objectValues[T, P]) MarshalLogArray(arr zapcore.ArrayEncoder) error {
for i := range os {
// It is necessary for us to explicitly reference the "P" type.
// We cannot simply pass "&os[i]" to AppendObject because its type
// is "*T", which the type system does not consider as
// implementing ObjectMarshaler.
// Only the type "P" satisfies ObjectMarshaler, which we have
// to convert "*T" to explicitly.
var p P = &os[i]
if err := arr.AppendObject(p); err != nil {
return err
}
}
return nil
}
181 changes: 181 additions & 0 deletions array_go118_test.go
@@ -0,0 +1,181 @@
// Copyright (c) 2022 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

//go:build go1.18
// +build go1.18

package zap

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
)

func TestObjectsAndObjectValues(t *testing.T) {
t.Parallel()

tests := []struct {
desc string
give Field
want []any
}{
{
desc: "Objects/nil slice",
give: Objects[*emptyObject]("", nil),
want: []any{},
},
{
desc: "ObjectValues/nil slice",
give: ObjectValues[emptyObject]("", nil),
want: []any{},
},
{
desc: "ObjectValues/empty slice",
give: ObjectValues("", []emptyObject{}),
want: []any{},
},
{
desc: "ObjectValues/single item",
give: ObjectValues("", []emptyObject{
{},
}),
want: []any{
map[string]any{},
},
},
{
desc: "Objects/multiple different objects",
give: Objects("", []*fakeObject{
{value: "foo"},
{value: "bar"},
{value: "baz"},
}),
want: []any{
map[string]any{"value": "foo"},
map[string]any{"value": "bar"},
map[string]any{"value": "baz"},
},
},
{
desc: "ObjectValues/multiple different objects",
give: ObjectValues("", []fakeObject{
{value: "foo"},
{value: "bar"},
{value: "baz"},
}),
want: []any{
map[string]any{"value": "foo"},
map[string]any{"value": "bar"},
map[string]any{"value": "baz"},
},
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.desc, func(t *testing.T) {
t.Parallel()

tt.give.Key = "k"

enc := zapcore.NewMapObjectEncoder()
tt.give.AddTo(enc)
assert.Equal(t, tt.want, enc.Fields["k"])
})
}
}

type emptyObject struct{}

func (*emptyObject) MarshalLogObject(zapcore.ObjectEncoder) error {
return nil
}

type fakeObject struct {
value string
err error // marshaling error, if any
}

func (o *fakeObject) MarshalLogObject(enc zapcore.ObjectEncoder) error {
enc.AddString("value", o.value)
return o.err
}

func TestObjectsAndObjectValues_marshalError(t *testing.T) {
t.Parallel()

tests := []struct {
desc string
give Field
want []any
wantErr string
}{
{
desc: "Objects",
give: Objects("", []*fakeObject{
{value: "foo"},
{value: "bar", err: errors.New("great sadness")},
{value: "baz"}, // does not get marshaled
}),
want: []any{
map[string]any{"value": "foo"},
map[string]any{"value": "bar"},
},
wantErr: "great sadness",
},
{
desc: "ObjectValues",
give: ObjectValues("", []fakeObject{
{value: "foo"},
{value: "bar", err: errors.New("stuff failed")},
{value: "baz"}, // does not get marshaled
}),
want: []any{
map[string]any{"value": "foo"},
map[string]any{"value": "bar"},
},
wantErr: "stuff failed",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.desc, func(t *testing.T) {
t.Parallel()

tt.give.Key = "k"

enc := zapcore.NewMapObjectEncoder()
tt.give.AddTo(enc)

require.Contains(t, enc.Fields, "k")
assert.Equal(t, tt.want, enc.Fields["k"])

// AddTo puts the error in a "%vError" field based on the name of the
// original field.
require.Contains(t, enc.Fields, "kError")
assert.Equal(t, tt.wantErr, enc.Fields["kError"])
})
}
}
66 changes: 66 additions & 0 deletions example_go118_test.go
@@ -0,0 +1,66 @@
// Copyright (c) 2022 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

//go:build go1.18
// +build go1.18

package zap_test

import "go.uber.org/zap"

func ExampleObjects() {
logger := zap.NewExample()
defer logger.Sync()

// Use the Objects field constructor when you have a list of objects,
// all of which implement zapcore.ObjectMarshaler.
logger.Debug("opening connections",
zap.Objects("addrs", []addr{
{IP: "123.45.67.89", Port: 4040},
{IP: "127.0.0.1", Port: 4041},
{IP: "192.168.0.1", Port: 4042},
}))
// Output:
// {"level":"debug","msg":"opening connections","addrs":[{"ip":"123.45.67.89","port":4040},{"ip":"127.0.0.1","port":4041},{"ip":"192.168.0.1","port":4042}]}
}

func ExampleObjectValues() {
logger := zap.NewExample()
defer logger.Sync()

// Use the ObjectValues field constructor when you have a list of
// objects that do not implement zapcore.ObjectMarshaler directly,
// but on their pointer receivers.
logger.Debug("starting tunnels",
zap.ObjectValues("addrs", []request{
{
URL: "/foo",
Listen: addr{"127.0.0.1", 8080},
Remote: addr{"123.45.67.89", 4040},
},
{
URL: "/bar",
Listen: addr{"127.0.0.1", 8080},
Remote: addr{"127.0.0.1", 31200},
},
}))
// Output:
// {"level":"debug","msg":"starting tunnels","addrs":[{"url":"/foo","ip":"127.0.0.1","port":8080,"remote":{"ip":"123.45.67.89","port":4040}},{"url":"/bar","ip":"127.0.0.1","port":8080,"remote":{"ip":"127.0.0.1","port":31200}}]}
}
2 changes: 1 addition & 1 deletion example_test.go
Expand Up @@ -182,7 +182,7 @@ func (a addr) MarshalLogObject(enc zapcore.ObjectEncoder) error {
return nil
}

func (r request) MarshalLogObject(enc zapcore.ObjectEncoder) error {
func (r *request) MarshalLogObject(enc zapcore.ObjectEncoder) error {
enc.AddString("url", r.URL)
zap.Inline(r.Listen).AddTo(enc)
return enc.AddObject("remote", r.Remote)
Expand Down

0 comments on commit 15be647

Please sign in to comment.