Repository: uber-go/zap (Go)

Issue:
Taking advantage of Time.AppendFormat
Hi,

I'm opening this issue to discuss a potential API change to the [buffer type](https://github.com/uber-go/zap/blob/master/buffer/buffer.go).

I have a custom encoder where times are encoded as strings formatted with the `Format` method from Go. Currently I have to do this:

```
enc.buf.AppendString(value.UTC().Format(time.RFC3339))
```

but it would be nice if we could just use the [AppendFormat](https://golang.org/pkg/time/#Time.AppendFormat) method to avoid an allocation.

I'm thinking of adding a method to the buffer type with this signature: `AppendTime(t time.Time, layout string)`.

Another solution would be to allow users to set the byte slice used by the buffer type so that I could write this:

```
buf := enc.buf.Bytes()
buf = value.AppendFormat(buf, time.RFC3339)
enc.buf.SetBytes(buf)
```

less elegant than having `AppendTime` but maybe a good solution if you don't want to special case times in the buffer type.


Discussion:
I'm not opposed to adding the `AppendFormat` function to the buffer type. It currently only deals with types that are part of the language, but I don't think supporting common standard library types is a bad idea.

That said, if we do support `AppendFormat` in the buffer type, it should be used within zap itself by the default encoder. This may be tricky since the [`TimeEncoder`](https://godoc.org/go.uber.org/zap/zapcore#TimeEncoder) type receives a [`PrimitiveArrayEncoder`](https://godoc.org/go.uber.org/zap/zapcore#PrimitiveArrayEncoder) which doesn't (intentionally) support append time.

Since we can't extend the interface in 1.0, the included time encoders in zap could try and cast the passed in encoder to see if it supports an `AppendFormat` function, and if so, use it. I'm not sure what the performance impact of the cast would be, so we should measure that.
Just want to say thanks for working on this, much appreciated.



Patch:
diff --git a/buffer/buffer.go b/buffer/buffer.go
index 7592e8c63..3f4b86e08 100644
--- a/buffer/buffer.go
+++ b/buffer/buffer.go
@@ -23,7 +23,10 @@
 // package's zero-allocation formatters.
 package buffer // import "go.uber.org/zap/buffer"
 
-import "strconv"
+import (
+	"strconv"
+	"time"
+)
 
 const _size = 1024 // by default, create 1 KiB buffers
 
@@ -49,6 +52,11 @@ func (b *Buffer) AppendInt(i int64) {
 	b.bs = strconv.AppendInt(b.bs, i, 10)
 }
 
+// AppendTime appends the time formatted using the specified layout.
+func (b *Buffer) AppendTime(t time.Time, layout string) {
+	b.bs = t.AppendFormat(b.bs, layout)
+}
+
 // AppendUint appends an unsigned integer to the underlying buffer (assuming
 // base 10).
 func (b *Buffer) AppendUint(i uint64) {
diff --git a/zapcore/encoder.go b/zapcore/encoder.go
index becc0ff88..6c78f7e49 100644
--- a/zapcore/encoder.go
+++ b/zapcore/encoder.go
@@ -112,21 +112,43 @@ func EpochNanosTimeEncoder(t time.Time, enc PrimitiveArrayEncoder) {
 	enc.AppendInt64(t.UnixNano())
 }
 
+func encodeTimeLayout(t time.Time, layout string, enc PrimitiveArrayEncoder) {
+	type appendTimeEncoder interface {
+		AppendTimeLayout(time.Time, string)
+	}
+
+	if enc, ok := enc.(appendTimeEncoder); ok {
+		enc.AppendTimeLayout(t, layout)
+		return
+	}
+
+	enc.AppendString(t.Format(layout))
+}
+
 // ISO8601TimeEncoder serializes a time.Time to an ISO8601-formatted string
 // with millisecond precision.
+//
+// If enc supports AppendTimeLayout(t time.Time,layout string), it's used
+// instead of appending a pre-formatted string value.
 func ISO8601TimeEncoder(t time.Time, enc PrimitiveArrayEncoder) {
-	enc.AppendString(t.Format("2006-01-02T15:04:05.000Z0700"))
+	encodeTimeLayout(t, "2006-01-02T15:04:05.000Z0700", enc)
 }
 
 // RFC3339TimeEncoder serializes a time.Time to an RFC3339-formatted string.
+//
+// If enc supports AppendTimeLayout(t time.Time,layout string), it's used
+// instead of appending a pre-formatted string value.
 func RFC3339TimeEncoder(t time.Time, enc PrimitiveArrayEncoder) {
-	enc.AppendString(t.Format(time.RFC3339))
+	encodeTimeLayout(t, time.RFC3339, enc)
 }
 
 // RFC3339NanoTimeEncoder serializes a time.Time to an RFC3339-formatted string
 // with nanosecond precision.
+//
+// If enc supports AppendTimeLayout(t time.Time,layout string), it's used
+// instead of appending a pre-formatted string value.
 func RFC3339NanoTimeEncoder(t time.Time, enc PrimitiveArrayEncoder) {
-	enc.AppendString(t.Format(time.RFC3339Nano))
+	encodeTimeLayout(t, time.RFC3339Nano, enc)
 }
 
 // UnmarshalText unmarshals text to a TimeEncoder.
diff --git a/zapcore/json_encoder.go b/zapcore/json_encoder.go
index 56256be8d..07f1897ed 100644
--- a/zapcore/json_encoder.go
+++ b/zapcore/json_encoder.go
@@ -266,6 +266,12 @@ func (enc *jsonEncoder) AppendString(val string) {
 	enc.buf.AppendByte('"')
 }
 
+func (enc *jsonEncoder) AppendTimeLayout(time time.Time, layout string) {
+	enc.buf.AppendByte('"')
+	enc.buf.AppendTime(time, layout)
+	enc.buf.AppendByte('"')
+}
+
 func (enc *jsonEncoder) AppendTime(val time.Time) {
 	cur := enc.buf.Len()
 	enc.EncodeTime(val, enc)


Test patch:
diff --git a/buffer/buffer_test.go b/buffer/buffer_test.go
index 59bc08a6a..96b81c294 100644
--- a/buffer/buffer_test.go
+++ b/buffer/buffer_test.go
@@ -24,6 +24,7 @@ import (
 	"bytes"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -46,6 +47,7 @@ func TestBufferWrites(t *testing.T) {
 		// Intenationally introduce some floating-point error.
 		{"AppendFloat32", func() { buf.AppendFloat(float64(float32(3.14)), 32) }, "3.14"},
 		{"AppendWrite", func() { buf.Write([]byte("foo")) }, "foo"},
+		{"AppendTime", func() { buf.AppendTime(time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC), time.RFC3339) }, "2000-01-02T03:04:05Z"},
 	}
 
 	for _, tt := range tests {
diff --git a/zapcore/json_encoder_impl_test.go b/zapcore/json_encoder_impl_test.go
index 183e2b8c1..81e3ed384 100644
--- a/zapcore/json_encoder_impl_test.go
+++ b/zapcore/json_encoder_impl_test.go
@@ -36,6 +36,11 @@ import (
 	"go.uber.org/multierr"
 )
 
+var _defaultEncoderConfig = EncoderConfig{
+	EncodeTime:     EpochTimeEncoder,
+	EncodeDuration: SecondsDurationEncoder,
+}
+
 func TestJSONClone(t *testing.T) {
 	// The parent encoder is created with plenty of excess capacity.
 	parent := &jsonEncoder{buf: bufferpool.Get()}
@@ -224,7 +229,55 @@ func TestJSONEncoderObjectFields(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.desc, func(t *testing.T) {
-			assertOutput(t, tt.expected, tt.f)
+			assertOutput(t, _defaultEncoderConfig, tt.expected, tt.f)
+		})
+	}
+}
+
+func TestJSONEncoderTimeFormats(t *testing.T) {
+	date := time.Date(2000, time.January, 2, 3, 4, 5, 6, time.UTC)
+
+	f := func(e Encoder) {
+		e.AddTime("k", date)
+		e.AddArray("a", ArrayMarshalerFunc(func(enc ArrayEncoder) error {
+			enc.AppendTime(date)
+			return nil
+		}))
+	}
+	tests := []struct {
+		desc     string
+		cfg      EncoderConfig
+		expected string
+	}{
+		{
+			desc: "time.Time ISO8601",
+			cfg: EncoderConfig{
+				EncodeDuration: NanosDurationEncoder,
+				EncodeTime:     ISO8601TimeEncoder,
+			},
+			expected: `"k":"2000-01-02T03:04:05.000Z","a":["2000-01-02T03:04:05.000Z"]`,
+		},
+		{
+			desc: "time.Time RFC3339",
+			cfg: EncoderConfig{
+				EncodeDuration: NanosDurationEncoder,
+				EncodeTime:     RFC3339TimeEncoder,
+			},
+			expected: `"k":"2000-01-02T03:04:05Z","a":["2000-01-02T03:04:05Z"]`,
+		},
+		{
+			desc: "time.Time RFC3339Nano",
+			cfg: EncoderConfig{
+				EncodeDuration: NanosDurationEncoder,
+				EncodeTime:     RFC3339NanoTimeEncoder,
+			},
+			expected: `"k":"2000-01-02T03:04:05.000000006Z","a":["2000-01-02T03:04:05.000000006Z"]`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			assertOutput(t, tt.cfg, tt.expected, f)
 		})
 	}
 }
@@ -324,7 +377,7 @@ func TestJSONEncoderArrays(t *testing.T) {
 					return nil
 				}))
 			}
-			assertOutput(t, `"array":`+tt.expected, func(enc Encoder) {
+			assertOutput(t, _defaultEncoderConfig, `"array":`+tt.expected, func(enc Encoder) {
 				err := f(enc)
 				assert.NoError(t, err, "Unexpected error adding array to JSON encoder.")
 			})
@@ -336,11 +389,8 @@ func assertJSON(t *testing.T, expected string, enc *jsonEncoder) {
 	assert.Equal(t, expected, enc.buf.String(), "Encoded JSON didn't match expectations.")
 }
 
-func assertOutput(t testing.TB, expected string, f func(Encoder)) {
-	enc := &jsonEncoder{buf: bufferpool.Get(), EncoderConfig: &EncoderConfig{
-		EncodeTime:     EpochTimeEncoder,
-		EncodeDuration: SecondsDurationEncoder,
-	}}
+func assertOutput(t testing.TB, cfg EncoderConfig, expected string, f func(Encoder)) {
+	enc := &jsonEncoder{buf: bufferpool.Get(), EncoderConfig: &cfg}
 	f(enc)
 	assert.Equal(t, expected, enc.buf.String(), "Unexpected encoder output after adding.")