Được giới thiệu trong Go 1.27, package encoding/json/v2 là một bản sửa đổi lớn của package encoding/json ban đầu. Hướng dẫn này mô tả vì sao bạn có thể muốn chuyển từ package v1 sang v2, và cách thực hiện việc đó một cách an toàn.
Vì sao nên chuyển đổi?
Trước hết: bạn không bắt buộc phải chuyển! Package encoding/json sẽ không bao giờ biến mất. Nó nằm trong cam kết tương thích Go 1, nên các package dùng encoding/json sẽ tiếp tục hoạt động vô thời hạn.
Ngoài ra, encoding/json và encoding/json/v2 tương thích với nhau. Ví dụ, nếu một kiểu hiện thực hành vi marshal tuỳ biến bằng encoding/json/v2.MarshalerTo, thì những nơi gọi marshal kiểu này qua encoding/json.Marshal vẫn sẽ đi qua marshaler tuỳ biến đó. Tương tự, các struct tag json mới được giới thiệu trong Go 1.27 cùng với encoding/json/v2 cũng được encoding/json hỗ trợ.
Dù bạn không bắt buộc phải chuyển đổi, vẫn có vài lý do chính đáng để làm việc đó:
Thứ nhất, API mới dễ dùng hơn. Ví dụ, bạn có thể dễ dàng marshal vào một io.Writer bằng encoding/json/v2.MarshalWrite, thay vì phải cần đến một encoding/json.Encoder. encoding/json/v2.Marshalers cho phép ghi đè hành vi marshal của những kiểu cụ thể, ngay cả khi bạn không kiểm soát các kiểu đó. encoding/json/v2.MatchCaseInsenstiveNames cho phép kiểm soát việc phân biệt chữ hoa chữ thường khi khớp tên thành viên của JSON object với các field của struct Go.
Những cải tiến này rất hay, nhưng lý do tốt nhất để chuyển đổi là package v2 chọn các giá trị mặc định chặt chẽ hơn, tương thích tốt hơn với hệ sinh thái so với v1. Tài liệu encoding/json liệt kê đầy đủ mọi khác biệt, nhưng một số điểm nổi bật gồm:
- Ở v1, các byte UTF-8 không hợp lệ bên trong một chuỗi bị âm thầm thay bằng ký tự thay thế Unicode. Ngược lại, ở v2 sự hiện diện của UTF-8 không hợp lệ dẫn đến lỗi.
- Ở v1, một JSON object có tên trùng nhau được chấp nhận. Ngược lại, ở v2 một JSON object có tên trùng nhau dẫn đến lỗi.
- Ở v1, một slice Go hoặc map Go bằng nil được marshal thành JSON null. Ngược lại, v2 marshal slice Go hoặc map Go bằng nil lần lượt thành JSON array rỗng hoặc JSON object rỗng.
- Ở v1, lỗi không bao giờ được báo lúc chạy đối với các kiểu struct Go có dạng lỗi cấu trúc nào đó (ví dụ, một field tag sai định dạng). Ngược lại, v2 báo lỗi lúc chạy cho các kiểu Go không hợp lệ xét theo khía cạnh tuần tự hoá JSON.
Những thay đổi này được thiết kế để encoding/json/v2 tương thích tốt hơn với hệ sinh thái JSON rộng lớn, ít gây bất ngờ và ít dẫn đến lỗi hơn. Nhưng chúng không tương thích ngược; một số ứng dụng có thể phụ thuộc vào hành vi của v1. Vì vậy, việc chuyển sang v2 phải đi kèm kiểm thử cẩn thận để bảo đảm tính tương thích.
Thay đổi API so với thay đổi hành vi
Với các thao tác marshal và unmarshal đơn giản, API Go phần lớn tương thích ở mức ngôn ngữ, nên việc chuyển đổi API là chuyện nhỏ. Ví dụ, b, err := json.Marshal(v) sẽ vẫn biên dịch được chỉ bằng cách đổi import "encoding/json" thành import "encoding/json/v2".
Phần khó khăn khi chuyển từ v1 sang v2 không nằm ở khác biệt API, mà ở những thay đổi hành vi khi marshal và unmarshal. Hãy xét chương trình sau:
package main
import (
"encoding/json"
"fmt"
)
type Pet struct {
Name string
Nicknames []string
}
func main() {
pets := []Pet{
{Name: "Oliver", Nicknames: []string{"Ollie", "Olliepop"}},
{Name: "Remi"},
}
b, err := json.Marshal(pets)
if err != nil {
panic(err)
}
fmt.Println(string(b))
}
Khi chạy, chương trình in ra:
[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":null}]
Nếu ta chuyển chương trình này sang encoding/json/v2 một cách đơn giản bằng việc đổi import, chương trình vẫn biên dịch được. Khi chạy, nó in ra:
[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":[]}]
Hãy để ý rằng field “Nicknames” của Remi đã đổi từ null thành []. Nếu đây là một chương trình mới, việc dùng mảng rỗng nhiều khả năng là một cải tiến tốt, nhưng trong một ứng dụng đã tồn tại, những bên tiêu thụ kết quả này ở phía sau có thể đang phụ thuộc vào sự hiện diện của null, nên thay đổi này có thể làm hỏng chúng.
Options
Tất cả những chỗ hành vi v2 khác biệt so với v1 đều được bao phủ bởi Options, cho phép chỉ định hành vi v1 khi dùng API v2. Để dùng API v2 nhưng vẫn chỉ định toàn bộ hành vi v1, hãy dùng DefaultOptionsV1:
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
)
type Pet struct {
Name string
Nicknames []string
}
func main() {
pets := []Pet{
{Name: "Oliver", Nicknames: []string{"Ollie", "Olliepop"}},
{Name: "Remi"},
}
b, err := json.Marshal(pets, jsonv1.DefaultOptionsV1())
if err != nil {
panic(err)
}
fmt.Println(string(b))
}
Khi chạy, chương trình lại in ra null:
[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":null}]
Tài liệu DefaultOptionsV1 liệt kê đầy đủ tập option được dùng để tương thích với v1. Các option đặt sau ghi đè lên option đặt trước, nên bạn có thể dùng danh sách này để bật từng hành vi v2 một.
Chuyển đổi
Tuỳ theo loại ứng dụng và mức độ chấp nhận rủi ro, có vài cách khác nhau để tiếp cận việc chuyển sang v2:
- Chấp nhận rủi ro cao: chuyển một lần
- Chấp nhận rủi ro thấp hoặc đang gỡ rối: từng option một
- Ứng dụng máy chủ chạy production: jsonsplit
Chuyển một lần
Nếu ứng dụng đơn giản hoặc chấp nhận rủi ro cao (việc chuyển đổi gây ra vấn đề cũng không hậu quả gì mấy), thì có lẽ không cần một quy trình chuyển đổi cầu kỳ. Chỉ cần cập nhật các nơi gọi để dùng encoding/json/v2, bảo đảm các test đều qua, rồi commit vào.
Nếu bạn gặp vấn đề tương thích, nguồn gốc khác biệt có thể đã rõ ngay từ thay đổi hoặc từ thông báo lỗi, khi đó bạn có thể đặt option tương thích phù hợp. Nếu nguồn gốc vấn đề không rõ ràng, bạn có thể muốn dùng một trong các cách dưới đây để hỗ trợ gỡ rối.
Đây cũng có thể là cách nhanh chóng để tìm và sửa những vấn đề hiển nhiên (chẳng hạn những vấn đề do unit test phát hiện) trước khi chuyển sang cách tiếp cận tinh tế hơn nhằm truy ra phần còn lại.
Từng option một
Nếu ứng dụng phức tạp hoặc mức chấp nhận rủi ro thấp, bạn có thể cần một cách tiếp cận chậm rãi và cẩn thận hơn.
Như đã nói ở trên, gọi Marshal hoặc Unmarshal với DefaultOptionsV1 khiến lời gọi hành xử y hệt encoding/json. Bước đầu tiên, hãy chuyển mọi lời gọi sang encoding/json/v2 với DefaultOptionsV1. Đây là một thay đổi đơn giản và an toàn; thực tế đây chính xác là cách encoding/json hiện thực Marshal và Unmarshal!
Các option truyền thêm sẽ ghi đè lên option trước đó, nên bạn có thể tắt từng option tương thích v1. Ví dụ, json.Marshal(v, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false)) sẽ hành xử như v1 ngoại trừ việc các slice nil được định dạng thành mảng rỗng.
Điều này mở ra một lối đi để chuyển đổi chậm rãi thay vì thay đổi mọi hành vi cùng lúc. Bạn có thể bật từng option một để chắc chắn về điều gì đang thay đổi, hoặc gom nhóm các option tương tự nhau. Ngoài ra, khi gỡ rối, các option này cho bạn cách chia đôi để khoanh vùng đúng thay đổi hành vi gây hỏng, tương tự cơ chế phát hiện option mà ta sẽ thấy ở phần jsonsplit bên dưới.
jsonsplit
github.com/go-json-experiment/jsonsplit là một package bọc JSON, hỗ trợ việc chuyển đổi bằng cách báo cáo những khác biệt giữa v1 và v2 trong môi trường production.
jsonsplit cung cấp các hàm bọc Marshal và Unmarshal vốn mặc định hành xử giống encoding/json, nhưng có thể được cấu hình lúc chạy để dùng v1, v2, hoặc cả hai.
Khi được cấu hình dùng cả hai (CallBothButReturnV1), jsonsplit.Marshal sẽ marshal đầu vào hai lần: một lần với v1 và một lần với v2. Nó sẽ báo cáo mọi khác biệt, nhưng vẫn trả về giá trị của v1 cho nơi gọi. Nhờ vậy một dịch vụ production có thể báo cáo các khác biệt mà không thay đổi hành vi của mình. Với tuỳ chọn AutoDetectOptions, jsonsplit thậm chí sẽ tự động xác định option cụ thể nào gây ra khác biệt.
Lưu ý rằng tính năng này có cái giá của nó. Marshal bằng cả v1 lẫn v2 để phát hiện khác biệt sẽ làm chi phí marshal tăng gần gấp đôi, và AutoDetectOptions còn thực hiện thêm nhiều lần marshal nữa để thu hẹp các option liên quan. Để giảm bớt các chi phí này, jsonsplit cho phép chỉ kiểm tra một tập con ngẫu nhiên các lời gọi thông qua SetMarshalCallRatio.
Ở đây, chúng tôi đã áp dụng jsonsplit vào ví dụ ban đầu:
package main
import (
"fmt"
"github.com/go-json-experiment/jsonsplit"
)
func init() {
// Call both v1 and v2 so we can detect differences, but continue using
// v1 output.
jsonsplit.GlobalCodec.SetMarshalCallMode(jsonsplit.CallBothButReturnV1)
// Specify that when a difference is detected, to auto-detect which
// options are causing the difference.
jsonsplit.GlobalCodec.AutoDetectOptions = true
// Log every time we detect a difference between v1 and v2.
jsonsplit.GlobalCodec.ReportDifference = func(d jsonsplit.Difference) {
fmt.Printf("detected jsonv1-to-jsonv2 difference: %v\n", d)
}
}
type Pet struct {
Name string
Nicknames []string
}
func main() {
pets := []Pet{
{Name: "Oliver", Nicknames: []string{"Ollie", "Olliepop"}},
{Name: "Remi"},
}
b, err := jsonsplit.Marshal(pets)
if err != nil {
panic(err)
}
fmt.Println(string(b))
}
Khi chạy, chương trình báo cáo khác biệt và thậm chí chỉ ra rằng FormatNilSliceAsNull là nguyên nhân gây ra khác biệt đó:
detected jsonv1-to-jsonv2 difference: {"Caller":"main.main+5","Func":"Marshal","GoType":"[]main.Pet","JSONValueV1":[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":null}],"JSONValueV2":[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":[]}],"Options":["jsonv2.FormatNilSliceAsNull"]}
[{"Name":"Oliver","Nicknames":["Ollie","Olliepop"]},{"Name":"Remi","Nicknames":null}]
Chúng ta có thể chuyển đổi dịch vụ production một cách êm thấm bằng quy trình như sau:
-
Chuyển các nơi gọi sang
jsonsplit, đặtCallBothButReturnV1,AutoDetectOptions(không bắt buộc), và nối cách giám sát ưa thích của bạn vàoReportDifference(chẳng hạn ghi log hoặc xuất bản các chỉ số). -
Theo dõi môi trường production của bạn để phát hiện các khác biệt được báo cáo.
-
Xử lý các khác biệt.
Ở những chỗ jsonsplit báo cáo khác biệt, hãy điều chỉnh option hoặc kiểu dữ liệu để bảo đảm kết quả giống hệt nhau.
Ví dụ, trong ví dụ ở trên, hãy truyền option json.FormatNilSliceAsNull(true). Trong các trường hợp khác, v2 có thể báo một vấn đề dễ sửa. Ví dụ, việc gắn struct tag JSON “string” cho một kiểu không hợp lệ (chẳng hạn một struct) bị bỏ qua ở v1, nhưng lại báo lỗi ở v2. Mặc dù ReportErrorsWithLegacySemantics sẽ chặn lỗi đó, hợp lý hơn là bỏ hẳn tag “string”. Dù sao nó cũng chẳng làm gì cả.
Lưu ý rằng một khác biệt trong kết quả không nhất thiết có nghĩa là hành vi ở phía sau bị hỏng, mà chỉ là có nguy cơ hỏng. Chúng ta điều chỉnh option ngay lúc này để có thể hoàn tất đại đa số việc chuyển đổi mà không phải dừng lại đánh giá từng thay đổi kết quả tinh tế, nhưng sau khi đã chuyển sang v2, bạn nên quay lại các chỗ đó để xác định xem có thể chuyển sang hành vi mới hay không.
- Chuyển sang v2.
Khi môi trường production của bạn ngừng báo cáo các khác biệt mới, bạn có thể chuyển sang hành vi v2 bằng cách đặt OnlyCallV2 hoặc CallBothButReturnV2 để tiếp tục kiểm tra khác biệt.
- Dọn dẹp.
Khi việc chuyển sang v2 đã được triển khai an toàn, hãy dọn dẹp phần chuyển đổi bằng cách đổi từ jsonsplit sang chính encoding/json/v2. Ở bước này, bạn có thể đánh giá các trường hợp ở mục (3) vốn đang giữ lại một phần hành vi v1 để xác định xem có an toàn để chuyển chúng sang hành vi v2 hay không.
Xem tài liệu jsonsplit để biết thêm chi tiết về việc chuyển đổi bằng jsonsplit.