JavaRush /Курсы /Go SELF /json.Number и

json.Number и json.RawMessage — разбор JSON

Go SELF
46 уровень, 2 лекция
Открыта

1. Числа в JSON и json.Number

Если вы когда-нибудь думали: «Ну число и число, чего там сложного», то поздравляю — вы нормальный человек. Но JSON в этом месте слегка коварен. В JSON тип числа один: number, без разделения на int, int64, float64, «денежка с двумя знаками» и «ID задачи, который нельзя округлять, потому что это ID, а не температура в прогнозе погоды».

Особенно весело становится, когда мы декодируем JSON не в структуру, а в «динамическую» модель (any, map[string]any). По умолчанию encoding/json складывает числа в float64, и на маленьких значениях вы этого даже не заметите. А потом в вашем приложении появится ID побольше, и начнётся «магия округления», от которой хочется спрятаться в fmt.Println("я устал").

Посмотрим на это на простом примере: декодируем JSON в map[string]any и печатаем типы.

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var m map[string]any
	_ = json.Unmarshal([]byte(`{"id": 10, "ratio": 0.25}`), &m)

	fmt.Printf("id: %v (%T)\n", m["id"], m["id"])       // id: 10 (float64)
	fmt.Printf("ratio: %v (%T)\n", m["ratio"], m["ratio"]) // ratio: 0.25 (float64)
}

Обратите внимание: 10 внезапно стал float64. Это не баг, это дизайн: если вы просите «динамический» разбор, библиотека выбирает типы по умолчанию.

Почему это проблема именно для нас (и особенно для нашего учебного приложения-менеджера задач tasker)? Потому что ID, счётчики, номера версий, суммы в копейках — это обычно целые числа, где любое округление превращается в «не тот объект» или «не те деньги». А деньги, как известно, любят точность. И бухгалтерию.

Decoder.UseNumber(): включаем режим «не трогай мои числа»

Когда мы понимаем, что вход у нас «динамический», но числа хочется контролировать, у encoding/json есть специальный режим: Decoder.UseNumber(). Он говорит декодеру примерно следующее: «Если видишь число и ты не знаешь, во что его типизировать, не превращай его в float64. Сохрани его как json.Number».

Важно: UseNumber() имеет смысл именно тогда, когда вы декодируете в any, map[string]any, []any и подобные динамические контейнеры. Если вы декодируете в struct с полями int, int64, float64 — там уже есть строгая типизация, и библиотека и так постарается разобрать число в нужный тип.

Небольшой пример на io.Reader: это ближе к реальной жизни, потому что мы часто читаем JSON из файла, сети или stdin, а не из готового []byte.

package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

func main() {
	dec := json.NewDecoder(strings.NewReader(`{"id": 10}`))
	dec.UseNumber()

	var m map[string]any
	_ = dec.Decode(&m)

	fmt.Printf("id: %v (%T)\n", m["id"], m["id"]) // id: 10 (json.Number)
}

Теперь id — это json.Number. И это хорошо: он не «поплыл» в float64, а остался числом «как текст», которое мы дальше явно превратим в int64 или float64 и обработаем возможную ошибку.

Чтобы зафиксировать разницу, вот компактная табличка:

Как декодируем Что будет с JSON-числом 123
json.Unmarshal в map[string]any
float64(123)
Decoder + UseNumber() в map[string]any
json.Number("123")
json.Unmarshal в struct{ ID int64 } int64(123) (или ошибка, если нецелое/слишком большое)

json.Number: парсим осознанно и не верим данным на слово

Когда число приехало к нам как json.Number, оно ведёт себя как «число в текстовом виде» плюс набор методов для конвертации. Это очень по-go: хочешь тип — преобразуй явно, а если не получилось — получи ошибку и реши, что делать.

Давайте разберём базовый паттерн: достали значение из map, проверили тип, сконвертировали.

package main

import (
	"encoding/json"
	"fmt"
)

func readID(m map[string]any) (int64, error) {
	v, ok := m["id"]
	if !ok {
		return 0, fmt.Errorf("missing field id")
	}

	n, ok := v.(json.Number)
	if !ok {
		return 0, fmt.Errorf("field id is not a number")
	}

	id, err := n.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	return id, nil
}

Здесь важно два момента.

Первый момент: json.Number.Int64() может вернуть ошибку. Например, если пришло {"id": 1.5} или {"id": "abc"} (да, «число строкой» тоже бывает, особенно когда данные писались «на коленке»). И ошибка — это нормально: вы просто узнали, что вход не соответствует контракту.

Второй момент: это именно момент проверки контракта. То есть место, где мы превращаем «какой-то JSON» в «данные нашей программы». Чем раньше вы это сделаете (на границе), тем меньше странностей утечёт дальше.

Чуть более практичный хелпер для нашего tasker: пусть он читает ID как int (чтобы удобно работать в коде), но хранит и возвращает ошибку, если число не помещается или не целое.

package main

import (
	"encoding/json"
	"fmt"
)

func parseIntID(v any) (int, error) {
	n, ok := v.(json.Number)
	if !ok {
		return 0, fmt.Errorf("id must be number")
	}

	id64, err := n.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	if id64 <= 0 {
		return 0, fmt.Errorf("id must be positive")
	}
	return int(id64), nil
}

Да, тут есть ещё один момент: преобразование int64int потенциально опасно на 32-битных системах, но в рамках обучения мы это держим в голове как «в будущем будем аккуратнее», а пока считаем, что int у нас достаточно большой. Главное — вы поняли идею: json.Number вынуждает вас сделать конверсию явно, и этим спасает от «тихого округления».

Мини-фича для tasker: динамическая команда и безопасные числа

Сейчас мы соберём маленький кусочек функциональности, который выглядит очень жизненно. Представим, что наш tasker умеет принимать команды в JSON со stdin (или из файла): например, «пометить задачу выполненной». Мы хотим поддержать формат, где команда может расширяться, а мы пока читаем только нужные поля.

Идея такая: читаем в map[string]any, включаем UseNumber, а затем вручную вытаскиваем нужные поля и валидируем.

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

func readCommand() (map[string]any, error) {
	dec := json.NewDecoder(os.Stdin)
	dec.UseNumber()

	var m map[string]any
	if err := dec.Decode(&m); err != nil {
		return nil, fmt.Errorf("decode command: %w", err)
	}
	return m, nil
}

Теперь представим команду:

{"op":"done","id":10}

Разбор:

package main

import "fmt"

func handleDone(cmd map[string]any) error {
	op, _ := cmd["op"].(string)
	if op != "done" {
		return fmt.Errorf("unexpected op %q", op)
	}

	id, err := parseIntID(cmd["id"])
	if err != nil {
		return fmt.Errorf("parse id: %w", err)
	}

	fmt.Printf("Mark task %d as done\n", id) // Mark task 10 as done
	return nil
}

Да, это «ручная работа». Зато она очень честная: вход может быть каким угодно, но до бизнес-логики дойдёт только то, что прошло проверку. И самое важное — мы не потеряли точность чисел, потому что не превращали их в float64 без спроса.

2. json.RawMessage и двухшаговый разбор

json.RawMessage: читаем конверт и откладываем разбор содержимого

Иногда проблема не в том, что JSON динамический «в целом», а в том, что он состоит из двух слоёв. Снаружи у него есть понятный «конверт» (например, поле op или type), а внутри — data, структура которого зависит от значения этого поля. И вот тут декодирование в map[string]any начинает превращаться в жизнь без типизации, а жизнь без типизации — это как чай без сахара: можно, но зачем страдать?

json.RawMessage решает это красиво: он хранит кусок JSON как []byte, не разбирая его сразу. То есть мы сначала парсим «шапку» (конверт), а потом, зная op, парсим data в нужную структуру.

Опишем конверт для команд tasker:

package main

import "encoding/json"

type CommandEnvelope struct {
	Op   string          `json:"op"`
	Data json.RawMessage `json:"data"`
}

Теперь пример входа:

{"op":"done","data":{"id":10}}

И разбор в два шага:

package main

import (
	"encoding/json"
	"fmt"
)

type DonePayload struct {
	ID int `json:"id"`
}

func decodeDone(env CommandEnvelope) (DonePayload, error) {
	var p DonePayload
	if err := json.Unmarshal(env.Data, &p); err != nil {
		return DonePayload{}, fmt.Errorf("decode done payload: %w", err)
	}
	return p, nil
}

Здесь ключевая мысль: мы снова вернулись к типизированному миру (struct), а значит получили нормальные ошибки при несовпадении типов, и нам не нужно вручную проверять any и делать type assertions на каждом шаге.

Чтобы визуально закрепить, вот схема потока данных:

flowchart TD
    A[JSON вход] --> B[Decode конверта в CommandEnvelope]
    B --> C{env.Op}
    C -->|done| D[Unmarshal env.Data в DonePayload]
    C -->|create| E[Unmarshal env.Data в CreatePayload]
    D --> F[Бизнес-логика tasker]
    E --> F

Паттерн «диспетчер команд»: RawMessage + switch

Когда команд становится больше одной, хочется не размазывать if по всему коду, а сделать одно место, которое «маршрутизирует» обработку. Это особенно приятно в CLI/серверном коде: один вход, один диспетчер, чёткие ошибки.

Сделаем две команды: create и done. У create будет поле title, у done — поле id.

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

type CreatePayload struct {
	Title string `json:"title"`
}

func handleCommand(r io.Reader) error {
	dec := json.NewDecoder(r)

	var env CommandEnvelope
	if err := dec.Decode(&env); err != nil {
		return fmt.Errorf("decode envelope: %w", err)
	}

	switch env.Op {
	case "create":
		var p CreatePayload
		if err := json.Unmarshal(env.Data, &p); err != nil {
			return fmt.Errorf("decode create: %w", err)
		}
		fmt.Printf("Create task: %s\n", p.Title) // Create task: Buy milk
		return nil

	case "done":
		var p DonePayload
		if err := json.Unmarshal(env.Data, &p); err != nil {
			return fmt.Errorf("decode done: %w", err)
		}
		fmt.Printf("Done task: %d\n", p.ID) // Done task: 10
		return nil

	default:
		return fmt.Errorf("unknown op %q", env.Op)
	}
}

Обратите внимание на очень важный эффект: DonePayload.ID — это int, а значит если придёт {"id": 1.5}, то json.Unmarshal вернёт ошибку. То есть строгость здесь получается «почти бесплатно», потому что мы используем типизацию.

Где тут место json.Number, если мы уже используем структуры

После предыдущего раздела легко подумать: «Так, RawMessage — удобно, а json.Number тогда вообще зачем?» И это хороший вопрос: он означает, что вы не просто копируете код, а реально понимаете, что происходит.

json.Number нужен в двух типичных ситуациях.

Первая ситуация — когда payload внутри data вы всё-таки декодируете «динамически» (например, map[string]any), потому что часть полей может быть произвольной. Тогда вы можете включить UseNumber() на декодере, декодировать data отдельно и парсить числа вручную.

Вторая ситуация — когда вы хотите принять число «в общем виде», а преобразование решить позже. Например, вы пишете импортёр, который принимает id как число, но допускает, что оно может быть слишком большим для int, и вы хотите сохранить его как строку и потом обработать. В таком случае json.Number как тип поля в структуре может быть полезен именно как «отложенное решение».

Например, сделаем payload, где id — именно json.Number:

package main

import (
	"encoding/json"
	"fmt"
)

type DonePayloadFlex struct {
	ID json.Number `json:"id"`
}

func (p DonePayloadFlex) ParseID() (int64, error) {
	id, err := p.ID.Int64()
	if err != nil {
		return 0, fmt.Errorf("id must be integer: %w", err)
	}
	return id, nil
}

Это выглядит чуть более «продвинуто», но идея та же: число не превращается в float64, а остаётся числом, которое вы потом конвертируете с проверкой.

Ошибки: добавляем контекст и не теряем первопричину

Когда вы начинаете делать двухшаговый разбор (envelopepayload), ошибки становятся более информативными… если вы им помогаете. То есть если вы возвращаете «голый err», то где-то наверху вы увидите что-то вроде «unexpected end of JSON input» и будете гадать: это конверт? это data? это вообще из какой команды?

Go-стиль — добавлять контекст и сохранять первопричину через wrapping (%w). Это стандартная практика работы с ошибками: мы поднимаем ошибку вверх по стеку, добавляя «что делали», но сохраняя исходную причину, чтобы её можно было распознать.

Отдельно полезно помнить, что некоторые ошибки стандартной библиотеки имеют конкретные типы и дополнительные поля. Например, encoding/json возвращает SyntaxError с позицией (Offset), и такие детали можно извлекать и превращать в дружелюбную диагностику. Мы сейчас не будем углубляться в классификацию ошибок JSON, но сам принцип «тип ошибки важнее строки ошибки» стоит запомнить.

Мини-пример «хорошего wrapping» в нашем стиле:

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

func decodeEnvelope(r io.Reader) (CommandEnvelope, error) {
	dec := json.NewDecoder(r)

	var env CommandEnvelope
	if err := dec.Decode(&env); err != nil {
		return CommandEnvelope{}, fmt.Errorf("decode command envelope: %w", err)
	}
	return env, nil
}

Такой текст ошибки потом будет выглядеть как цепочка: «decode command envelope: …», и вам сразу понятно, на каком шаге вы упали. Это не роскошь — это способ не превратить отладку в археологические раскопки.

3. Типичные ошибки при работе с json.Number и json.RawMessage

Ошибка №1: ожидать, что UseNumber() “починит” декодирование в struct.
UseNumber() влияет в первую очередь на декодирование в any/map[string]any. Если вы декодируете в структуру с полями int/int64/float64, библиотека уже знает целевой тип и будет парсить число прямо в него. Поэтому UseNumber() полезен там, где тип заранее не известен и вы вынуждены использовать any.

Ошибка №2: доставать json.Number, но не проверять ошибку у Int64()/Float64().
Очень хочется написать id, _ := n.Int64() и «пойти дальше». На практике это означает: “если пришло 1.5, мы тихо превратим это в ноль и продолжим жить”. Такой баг потом выглядит как «почему у нас задача с id=0» и «почему это иногда происходит». Ошибку нужно проверять всегда: это и есть граница валидации данных.

Ошибка №3: использовать map[string]any там, где отлично подходит RawMessage + структуры.
Если у вас есть поле-переключатель (op, kind, type) и оно определяет структуру вложенного блока data, то json.RawMessage обычно проще и безопаснее, чем огромный набор type assertions по any. Вы сначала читаете маленький конверт, потом декодируете payload в правильный struct — и получаете нормальные ошибки типов.

Ошибка №4: считать json.RawMessage «просто байтами» и забывать, что это должен быть валидный JSON-фрагмент.
RawMessage хранит кусок JSON, который потом будет декодироваться json.Unmarshal. Если вы где-то руками модифицировали эти байты, склеили строкой, добавили лишнюю запятую или обрезали кусок — следующий шаг развалится синтаксической ошибкой. RawMessage хорошо работает, когда вы храните его как «как пришло — так и передали дальше на Unmarshal».

Ошибка №5: смешивать ответственность “парсим” и “исполняем”.
Часто новички начинают внутри switch по env.Op не только декодировать payload, но и сразу менять состояние приложения, печатать результат, ходить в файловую систему, и в итоге логика разбора, валидации и выполнения команды переплетается. Намного проще (и тестируемее) держать идею: сначала decode + validate, а только потом — действие. Это снижает количество мест, где «плохие данные» могут проскочить в бизнес-логику.

1
Задача
Go SELF, 46 уровень, 2 лекция
Недоступна
Датчик точности
Датчик точности
1
Задача
Go SELF, 46 уровень, 2 лекция
Недоступна
Пропавшая единица
Пропавшая единица
1
Задача
Go SELF, 46 уровень, 2 лекция
Недоступна
Конверт команды
Конверт команды
1
Задача
Go SELF, 46 уровень, 2 лекция
Недоступна
Сумматор без округлений
Сумматор без округлений
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ