Repository: rs/zerolog
Pull request #352 (2021-08-31 04:25:29)

Fields helper accepts both map and slice
Useful for logging wrappers like [zerologr ](https://github.com/hn8/zerologr) to reuse the same code for Fields helper (which requires map). No extra allocation for variadic arguments or slice of key/value pairs. It avoids stack copy overhead from variadic arguments (as in Zap ...Fields)

rs: I'm not a big fan of the name of the method. Ideally it should be `Fields` but it is taken by the map form. I also wonder if a variadic argument wouldn't be more friendly for this method (not sure about the allocation difference).

What I have in mind (but convinced by any of them):

* `Fields(interface)` that would handle both map and array forms via type inference. I generally don't like type-less APIs, but here it is more a convenience method so perhaps it's ok
* `Fields(interface…)` same as above but either it's a single map arg or a variadic key, value… It might be very confusing.
* `FieldList([]interface)` isn't much more convincing than `KeyValues`

Ideas are welcome.
hn8: @rs Pushed the change to go with first option Fields(interface). It is less confusing than variadic version, also has less overhead. Tried to optimize logr Zap wrapper before, learned that passing arguments with variadic spread down to call stack does hurt its performance. Does it look good for you?
rs: Looks good. Could you please add some tests and an example?
hn8: @rs 👍 

Patch:
diff --git a/context.go b/context.go
index 7cdd8cc..f398e31 100644
--- a/context.go
+++ b/context.go
@@ -18,8 +18,10 @@ func (c Context) Logger() Logger {
 	return c.l
 }
 
-// Fields is a helper function to use a map to set fields using type assertion.
-func (c Context) Fields(fields map[string]interface{}) Context {
+// Fields is a helper function to use a map or slice to set fields using type assertion.
+// Only map[string]interface{} and []interface{} are accepted. []interface{} must
+// alternate string keys and arbitrary values, and extraneous ones are ignored.
+func (c Context) Fields(fields interface{}) Context {
 	c.l.context = appendFields(c.l.context, fields)
 	return c
 }
diff --git a/event.go b/event.go
index 5a6d2a3..6aaa618 100644
--- a/event.go
+++ b/event.go
@@ -148,8 +148,10 @@ func (e *Event) msg(msg string) {
 	}
 }
 
-// Fields is a helper function to use a map to set fields using type assertion.
-func (e *Event) Fields(fields map[string]interface{}) *Event {
+// Fields is a helper function to use a map or slice to set fields using type assertion.
+// Only map[string]interface{} and []interface{} are accepted. []interface{} must
+// alternate string keys and arbitrary values, and extraneous ones are ignored.
+func (e *Event) Fields(fields interface{}) *Event {
 	if e == nil {
 		return e
 	}
diff --git a/fields.go b/fields.go
index 907b3f0..c1eb5ce 100644
--- a/fields.go
+++ b/fields.go
@@ -12,15 +12,36 @@ func isNilValue(i interface{}) bool {
 	return (*[2]uintptr)(unsafe.Pointer(&i))[1] == 0
 }
 
-func appendFields(dst []byte, fields map[string]interface{}) []byte {
-	keys := make([]string, 0, len(fields))
-	for key := range fields {
-		keys = append(keys, key)
+func appendFields(dst []byte, fields interface{}) []byte {
+	switch fields := fields.(type) {
+	case []interface{}:
+		if n := len(fields); n&0x1 == 1 { // odd number
+			fields = fields[:n-1]
+		}
+		dst = appendFieldList(dst, fields)
+	case map[string]interface{}:
+		keys := make([]string, 0, len(fields))
+		for key := range fields {
+			keys = append(keys, key)
+		}
+		sort.Strings(keys)
+		kv := make([]interface{}, 2)
+		for _, key := range keys {
+			kv[0], kv[1] = key, fields[key]
+			dst = appendFieldList(dst, kv)
+		}
 	}
-	sort.Strings(keys)
-	for _, key := range keys {
-		dst = enc.AppendKey(dst, key)
-		val := fields[key]
+	return dst
+}
+
+func appendFieldList(dst []byte, kvList []interface{}) []byte {
+	for i, n := 0, len(kvList); i < n; i += 2 {
+		key, val := kvList[i], kvList[i+1]
+		if key, ok := key.(string); ok {
+			dst = enc.AppendKey(dst, key)
+		} else {
+			continue
+		}
 		if val, ok := val.(LogObjectMarshaler); ok {
 			e := newEvent(nil, 0)
 			e.buf = e.buf[:0]


Test patch:
diff --git a/benchmark_test.go b/benchmark_test.go
index 7fc93e2..9aa4100 100644
--- a/benchmark_test.go
+++ b/benchmark_test.go
@@ -148,16 +148,16 @@ func BenchmarkLogFieldType(b *testing.B) {
 		{"a", "a", 0},
 	}
 	objects := []obj{
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
 	}
 	errs := []error{errors.New("a"), errors.New("b"), errors.New("c"), errors.New("d"), errors.New("e")}
 	types := map[string]func(e *Event) *Event{
@@ -272,16 +272,16 @@ func BenchmarkContextFieldType(b *testing.B) {
 		{"a", "a", 0},
 	}
 	objects := []obj{
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
-		obj{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
+		{"a", "a", 0},
 	}
 	errs := []error{errors.New("a"), errors.New("b"), errors.New("c"), errors.New("d"), errors.New("e")}
 	types := map[string]func(c Context) Context{
diff --git a/binary_test.go b/binary_test.go
index 79cfa9c..b4882d7 100644
--- a/binary_test.go
+++ b/binary_test.go
@@ -364,6 +364,42 @@ func ExampleEvent_Durs() {
 	// Output: {"foo":"bar","durs":[10000,20000],"message":"hello world"}
 }
 
+func ExampleEvent_Fields_map() {
+	fields := map[string]interface{}{
+		"bar": "baz",
+		"n":   1,
+	}
+
+	dst := bytes.Buffer{}
+	log := New(&dst)
+
+	log.Log().
+		Str("foo", "bar").
+		Fields(fields).
+		Msg("hello world")
+
+	fmt.Println(decodeIfBinaryToString(dst.Bytes()))
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
+func ExampleEvent_Fields_slice() {
+	fields := []interface{}{
+		"bar", "baz",
+		"n", 1,
+	}
+
+	dst := bytes.Buffer{}
+	log := New(&dst)
+
+	log.Log().
+		Str("foo", "bar").
+		Fields(fields).
+		Msg("hello world")
+
+	fmt.Println(decodeIfBinaryToString(dst.Bytes()))
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
 func ExampleContext_Dict() {
 	dst := bytes.Buffer{}
 	log := New(&dst).With().
@@ -510,3 +546,39 @@ func ExampleContext_Durs() {
 	fmt.Println(decodeIfBinaryToString(dst.Bytes()))
 	// Output: {"foo":"bar","durs":[10000,20000],"message":"hello world"}
 }
+
+func ExampleContext_Fields_map() {
+	fields := map[string]interface{}{
+		"bar": "baz",
+		"n":   1,
+	}
+
+	dst := bytes.Buffer{}
+	log := New(&dst).With().
+		Str("foo", "bar").
+		Fields(fields).
+		Logger()
+
+	log.Log().Msg("hello world")
+
+	fmt.Println(decodeIfBinaryToString(dst.Bytes()))
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
+func ExampleContext_Fields_slice() {
+	fields := []interface{}{
+		"bar", "baz",
+		"n", 1,
+	}
+
+	dst := bytes.Buffer{}
+	log := New(&dst).With().
+		Str("foo", "bar").
+		Fields(fields).
+		Logger()
+
+	log.Log().Msg("hello world")
+
+	fmt.Println(decodeIfBinaryToString(dst.Bytes()))
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
diff --git a/log_example_test.go b/log_example_test.go
index 71fd217..a41fdbb 100644
--- a/log_example_test.go
+++ b/log_example_test.go
@@ -335,6 +335,38 @@ func ExampleEvent_Durs() {
 	// Output: {"foo":"bar","durs":[10000,20000],"message":"hello world"}
 }
 
+func ExampleEvent_Fields_map() {
+	fields := map[string]interface{}{
+		"bar": "baz",
+		"n":   1,
+	}
+
+	log := zerolog.New(os.Stdout)
+
+	log.Log().
+		Str("foo", "bar").
+		Fields(fields).
+		Msg("hello world")
+
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
+func ExampleEvent_Fields_slice() {
+	fields := []interface{}{
+		"bar", "baz",
+		"n", 1,
+	}
+
+	log := zerolog.New(os.Stdout)
+
+	log.Log().
+		Str("foo", "bar").
+		Fields(fields).
+		Msg("hello world")
+
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
 func ExampleContext_Dict() {
 	log := zerolog.New(os.Stdout).With().
 		Str("foo", "bar").
@@ -484,3 +516,35 @@ func ExampleContext_MacAddr() {
 
 	// Output: {"hostMAC":"00:14:22:01:23:45","message":"hello world"}
 }
+
+func ExampleContext_Fields_map() {
+	fields := map[string]interface{}{
+		"bar": "baz",
+		"n":   1,
+	}
+
+	log := zerolog.New(os.Stdout).With().
+		Str("foo", "bar").
+		Fields(fields).
+		Logger()
+
+	log.Log().Msg("hello world")
+
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
+
+func ExampleContext_Fields_slice() {
+	fields := []interface{}{
+		"bar", "baz",
+		"n", 1,
+	}
+
+	log := zerolog.New(os.Stdout).With().
+		Str("foo", "bar").
+		Fields(fields).
+		Logger()
+
+	log.Log().Msg("hello world")
+
+	// Output: {"foo":"bar","bar":"baz","n":1,"message":"hello world"}
+}
diff --git a/log_test.go b/log_test.go
index e22e926..8f49c8f 100644
--- a/log_test.go
+++ b/log_test.go
@@ -246,6 +246,66 @@ func TestFieldsMapNilPnt(t *testing.T) {
 	}
 }
 
+func TestFieldsSlice(t *testing.T) {
+	out := &bytes.Buffer{}
+	log := New(out)
+	log.Log().Fields([]interface{}{
+		"nil", nil,
+		"string", "foo",
+		"bytes", []byte("bar"),
+		"error", errors.New("some error"),
+		"bool", true,
+		"int", int(1),
+		"int8", int8(2),
+		"int16", int16(3),
+		"int32", int32(4),
+		"int64", int64(5),
+		"uint", uint(6),
+		"uint8", uint8(7),
+		"uint16", uint16(8),
+		"uint32", uint32(9),
+		"uint64", uint64(10),
+		"float32", float32(11),
+		"float64", float64(12),
+		"ipv6", net.IP{0x20, 0x01, 0x0d, 0xb8, 0x85, 0xa3, 0x00, 0x00, 0x00, 0x00, 0x8a, 0x2e, 0x03, 0x70, 0x73, 0x34},
+		"dur", 1 * time.Second,
+		"time", time.Time{},
+		"obj", obj{"a", "b", 1},
+	}).Msg("")
+	if got, want := decodeIfBinaryToString(out.Bytes()), `{"nil":null,"string":"foo","bytes":"bar","error":"some error","bool":true,"int":1,"int8":2,"int16":3,"int32":4,"int64":5,"uint":6,"uint8":7,"uint16":8,"uint32":9,"uint64":10,"float32":11,"float64":12,"ipv6":"2001:db8:85a3::8a2e:370:7334","dur":1000,"time":"0001-01-01T00:00:00Z","obj":{"Pub":"a","Tag":"b","priv":1}}`+"\n"; got != want {
+		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestFieldsSliceExtraneous(t *testing.T) {
+	out := &bytes.Buffer{}
+	log := New(out)
+	log.Log().Fields([]interface{}{
+		"string", "foo",
+		"error", errors.New("some error"),
+		32, "valueForNonStringKey",
+		"bool", true,
+		"int", int(1),
+		"keyWithoutValue",
+	}).Msg("")
+	if got, want := decodeIfBinaryToString(out.Bytes()), `{"string":"foo","error":"some error","bool":true,"int":1}`+"\n"; got != want {
+		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestFieldsNotMapSlice(t *testing.T) {
+	out := &bytes.Buffer{}
+	log := New(out)
+	log.Log().
+		Fields(obj{"a", "b", 1}).
+		Fields("string").
+		Fields(1).
+		Msg("")
+	if got, want := decodeIfBinaryToString(out.Bytes()), `{}`+"\n"; got != want {
+		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
 func TestFields(t *testing.T) {
 	out := &bytes.Buffer{}
 	log := New(out)
@@ -336,7 +396,7 @@ func TestFieldsArraySingleElement(t *testing.T) {
 		Floats32("float32", []float32{11}).
 		Floats64("float64", []float64{12}).
 		Durs("dur", []time.Duration{1 * time.Second}).
-		Times("time", []time.Time{time.Time{}}).
+		Times("time", []time.Time{{}}).
 		Msg("")
 	if got, want := decodeIfBinaryToString(out.Bytes()), `{"string":["foo"],"err":["some error"],"bool":[true],"int":[1],"int8":[2],"int16":[3],"int32":[4],"int64":[5],"uint":[6],"uint8":[7],"uint16":[8],"uint32":[9],"uint64":[10],"float32":[11],"float64":[12],"dur":[1000],"time":["0001-01-01T00:00:00Z"]}`+"\n"; got != want {
 		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)
@@ -363,7 +423,7 @@ func TestFieldsArrayMultipleElement(t *testing.T) {
 		Floats32("float32", []float32{11, 0}).
 		Floats64("float64", []float64{12, 0}).
 		Durs("dur", []time.Duration{1 * time.Second, 0}).
-		Times("time", []time.Time{time.Time{}, time.Time{}}).
+		Times("time", []time.Time{{}, {}}).
 		Msg("")
 	if got, want := decodeIfBinaryToString(out.Bytes()), `{"string":["foo","bar"],"err":["some error",null],"bool":[true,false],"int":[1,0],"int8":[2,0],"int16":[3,0],"int32":[4,0],"int64":[5,0],"uint":[6,0],"uint8":[7,0],"uint16":[8,0],"uint32":[9,0],"uint64":[10,0],"float32":[11,0],"float64":[12,0],"dur":[1000,0],"time":["0001-01-01T00:00:00Z","0001-01-01T00:00:00Z"]}`+"\n"; got != want {
 		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)