Rebel Go: разветвление кодировки / json

Что происходит, когда меня слишком долго оставляют одну

В моем представлении разговор проходил примерно так.

Фил: я бы хотел улучшить кодировку / json. Я хотел бы сохранить выделения при маршалинге, добавив новый интерфейс для настраиваемых маршалеров.

Весь мир. Учитывая, что это можно легко изучить с помощью сторонних пакетов, это похоже на спад. Оставляем открытым на неделю для окончательных комментариев.

Фил: Интересно, как будет выглядеть успешное исследование?

Мир: одним из последствий написания собственного пакета является то, что вы можете его использовать, и вам не потребуется вносить изменения в стандартную библиотеку.

Фил: Ты только что сказал мне самому пойти форком?

Мир: Кажется, я убил Боба.

Я действительно не уверен, что хочу использовать кодировку encoding / json. Я даже не уверен, как бы я мог форкнуть часть репо, или, по крайней мере, не таким образом, чтобы я мог легко объединить исправления из апстрима. И я был бы шокирован, если бы кто-нибудь им воспользовался. Скорее всего, это будет грустная и одинокая сирота. Зачем создавать что-то с такой несчастной судьбой?

Почему? Ну, в основном потому, что сегодня суббота, @lizrice уехала на конференцию, и мне больше нечем заняться.

Я не собираюсь начинать с вилки. Я просто создам ветку и покажу, какие изменения повлекут за собой изменения и какие будут улучшения. Как всегда, я начну с теста.

В Ravelin мы передаем много JSON, и этот JSON включает поля, которые имеют значение time.Time. Мы также приняли решение много лет назад использовать https://github.com/guregu/null для выражения нулевых значений. На данный момент ни одно из этих решений не выглядит лучшим. Если бы мы начали с нуля, я не уверен, что буду использовать JSON, или когда-либо использовать time.Time в качестве основного метода работы со временем, или использовать https://github.com/guregu/null, но на данный момент эти решения очень трудно отменить.

Итак, давайте создадим эталон, отражающий эти решения. Мы маршалируем структуру, в которой есть несколько из этих элементов. На самом деле в Ravelin наши структуры имеют гораздо больше полей, чем это, но очень многие из полей имеют значения null.* и time.Time.

func BenchmarkEncodeMarshaler(b *testing.B) {
	b.ReportAllocs()
	m := struct {
		A null.Int
		B time.Time
		C time.Time
		D null.String
	}{
		A: null.IntFrom(42),
		B: time.Now(),
		C: time.Now().Add(-time.Hour),
		D: null.StringFrom(`hello`),
	}
	b.RunParallel(func(pb *testing.PB) {
		enc := json.NewEncoder(ioutil.Discard)
		for pb.Next() {
			if err := enc.Encode(&m); err != nil {
				b.Fatal("Encode:", err)
			}
		}
	})
}

Если мы запустим этот тест с Go 1.13 и прогоним вывод через benchstat, мы получим следующее. Да, для маршалинга этой небольшой структуры в JSON требуется 13 распределений.

name               time/op
EncodeMarshaler-8  753ns ± 2%
name               alloc/op
EncodeMarshaler-8   496B ± 0%
name               allocs/op
EncodeMarshaler-8   13.0 ± 0%

В Go 1.14 уже запланированы некоторые улучшения, которые позволят сократить это количество до 5 (некоторые от меня! Сообщение, сообщение, вклад, вклад). Но с грубой версией предлагаемого изменения мы можем полностью избавиться от распределения. Вот сравнение того же теста в Go tip с моей веткой с изменениями.

name               old time/op    new time/op    delta
EncodeMarshaler-8     626ns ± 3%     315ns ± 1%   -49.65%  (p=0.000 n=8+7)
name               old alloc/op   new alloc/op   delta
EncodeMarshaler-8      128B ± 0%        0B       -100.00%  (p=0.000 n=8+8)
name               old allocs/op  new allocs/op  delta
EncodeMarshaler-8      5.00 ± 0%      0.00       -100.00%  (p=0.000 n=8+8)

Вот почему я считаю это хорошей идеей. Время работы сокращено вдвое, и все распределения ушли. Изменения полностью обратно совместимы и на данный момент заняли менее 3 часов (включая написание большей части сообщения в блоге).

Так какие же изменения? Что ж, начнем с добавления нового интерфейса маршалера. Идея заключается в том, что реализации MarshalAppendJSON не нужно выделять []byte, который она возвращает, который содержит маршалированный JSON. Он может построить свой ответ, добавив к фрагменту in, который ему передает кодирование / json. С текущей функцией MarshalJSON это неверно, поэтому почти каждый вызов метода MarshalJSON приводит к выделению памяти.

type MarshalAppender interface {
	MarshalAppendJSON(in []byte) ([]byte, error)
}

Когда у нас есть интерфейс, мы добавляем код, чтобы определить его присутствие для типа и назначить соответствующую функцию маршалинга. Нам нужно убедиться, что мы используем новый MarshalAppender интерфейс вместо Marshaler, когда он присутствует.

var (
	marshalerType       = reflect.TypeOf((*Marshaler)(nil)).Elem()
	marshalAppenderType = reflect.TypeOf((*MarshalAppender)(nil)).Elem()
	textMarshalerType   = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
)
// newTypeEncoder constructs an encoderFunc for a type.
// The returned encoder only checks CanAddr when allowAddr is true.
func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
	// If we have a non-pointer value whose type implements
	// Marshaler with a value receiver, then we're better off taking
	// the address of the value - otherwise we end up with an
	// allocation as we cast the value to an interface.
	if t.Kind() != reflect.Ptr && allowAddr && reflect.PtrTo(t).Implements(marshalAppenderType) {
		return newCondAddrEncoder(addrMarshalAppenderEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(marshalAppenderType) {
		return marshalAppenderEncoder
	}
	if t.Kind() != reflect.Ptr && allowAddr && reflect.PtrTo(t).Implements(marshalerType) {
		return newCondAddrEncoder(addrMarshalerEncoder, newTypeEncoder(t, false))
	}

Последнее изменение, заставляющее новый интерфейс работать, - это добавление кода для вызова нового метода. Это соответствует шаблону, установленному для существующего Marshaler, за исключением того, что мы предполагаем, что MarshalAppender реализации создают допустимый компактный JSON. Текущий Marshaler не предполагает этого, поэтому он вызывает функцию compact на выходе. compact проверяет и уплотняет JSON, возвращенный из Marshaler.

Я думаю, что лучший выбор - довериться разработчикам библиотек в создании правильных реализаций. Нам не нужно миллионы и миллиарды раз проверять вывод в работающих приложениях. Итак, моя реализация ниже просто копирует результат MarshalAppendJSON в буфер кодировщика.

func marshalAppenderEncoder(e *encodeState, v reflect.Value, opts encOpts) {
	if v.Kind() == reflect.Ptr && v.IsNil() {
		e.WriteString("null")
		return
	}
	m, ok := v.Interface().(MarshalAppender)
	if !ok {
		e.WriteString("null")
		return
	}
	b, err := m.MarshalAppendJSON(e.scratch[:0])
	if err != nil {
		e.error(&MarshalerError{v.Type(), err, "MarshalAppendJSON"})
		return
	}
	// We trust implementers of MarshalAppender to generate valid, maximally compact JSON
	e.Write(b)
}
func addrMarshalAppenderEncoder(e *encodeState, v reflect.Value, opts encOpts) {
	va := v.Addr()
	if va.IsNil() {
		e.WriteString("null")
		return
	}
	m := va.Interface().(MarshalAppender)
	b, err := m.MarshalAppendJSON(e.scratch[:0])
	if err != nil {
		e.error(&MarshalerError{v.Type(), err, "MarshalAppendJSON"})
		return
	}
	// We trust implementers of MarshalAppender to generate valid, maximally compact JSON
	e.Write(b)
}

Следующим шагом, чтобы сделать это полезным, является реализация некоторых настраиваемых маршалеров. Сначала сделаем time.Time. Для меня это очень важно - должно быть очень большое количество людей с time.Time в структурах, которые маршалируются в JSON. Функция time.AppendFormat упрощает реализацию этого.

// MarshalAppendJSON implements the json.Marshaler interface.
// The time is a quoted string in RFC 3339 format, with sub-second precision added if present.
func (t Time) MarshalAppendJSON(b []byte) ([]byte, error) {
	if y := t.Year(); y < 0 || y >= 10000 {
		// RFC 3339 is clear that years are 4 digits exactly.
		// See golang.org/issue/4556#c15 for more discussion.
		return nil, errors.New("Time.MarshalAppendJSON: year outside of range [0,9999]")
	}
	b = append(b, '"')
	b = t.AppendFormat(b, RFC3339Nano)
	b = append(b, '"')
	return b, nil
}

Наконец, для моего теста мне нужно создать MarshalAppendJSON методов для Int и String из нулевого пакета. Int просто из-за функции strconv.AppendInt.

func (i Int) MarshalAppendJSON(b []byte) ([]byte, error) {
	if !i.Valid {
		return nullLiteral, nil
	}
	return strconv.AppendInt(b, i.Int64, 10), nil
}

Версия String намного проблематичнее. Мне нужно JSON закодировать строковое значение. Но если я просто позвоню json.Marshal, чтобы сделать это, я не получу никаких улучшений в производительности. На самом деле мне нужно было бы добавить эффективный EncodeString метод в кодировку / json. Но и без этого, по крайней мере, новый интерфейс позволяет работать и улучшать производительность. Здесь я просто следил за определением строки JSON из json.org и сам внедрил кодировку. И это может быть даже частично верно (это определенно еще не совсем правильно)! И, по крайней мере, это показывает, что можно было бы легко реализовать эффективный метод EncodeString.

func (s String) MarshalAppendJSON(b []byte) ([]byte, error) {
	if !s.Valid {
		return append(b, nullLiteral...), nil
	}
	b = append(b, '"')
	for i, r := range s.String {
		switch r {
		case '\\', '"':
			b = append(b, '\\', byte(r))
		case '\n':
			b = append(b, '\\', 'n')
		case '\r':
			b = append(b, '\\', 'r')
		case '\t':
			b = append(b, '\\', 't')
		default:
			if r < 32 {
				b = append(b, '\\', 'u', '0', '0', hex[r>>4], hex[r&0xF])
			} else if r < utf8.RuneSelf {
				b = append(b, byte(r))
			} else {
				// append in its natural form
				b = append(b, s.String[i:i+utf8.RuneLen(r)]...)
			} 
		}
	}
	b = append(b, '"')
	return b, nil
}

Это было все, что было необходимо для удаления всех выделений из этого конкретного теста маршалинга JSON. Надеюсь, я объяснил, почему это полезное и позитивное изменение. Думаю, я также подчеркнул, почему это должно быть сделано в стандартной библиотеке. Это действительно полезно, только если разработчики библиотеки реализуют MarshalAppender. И я думаю, что они сделают это только в том случае, если MarshalAppender является частью библиотеки JSON де-факто. Конечно, я не могу представить, что пакет стандартной библиотеки time будет изменен для реализации стороннего метода кодирования JSON!

Дорогой читатель, вы зашли так далеко, но у меня есть еще одна задача. Я хочу знать, следует ли мне создать вилку encoding / json, чтобы добавить MarshalAppender и, возможно, внести другие изменения, которые не будут приемлемы для команды Go. Я действительно не хочу заниматься этим только для собственного развлечения. Я хочу делать это только в том случае, если люди действительно будут этим пользоваться. У меня нет системы голосования, но если этот пост станет «популярным» (а мои стандарты популярности смехотворно низки), я могу просто сделать это. Так что, если вы хотите, чтобы я сделал форк-пюре с кнопкой / ретвитнуть / аплодировать / проголосовать за все, что вы обнаружили в этом посте.