Merge branch 'main' into feat/custom-json-marshaling

This commit is contained in:
yusing 2025-04-17 06:10:37 +08:00
commit 7730585bc2
5 changed files with 688 additions and 126 deletions

View file

@ -1,7 +1,6 @@
package json
import (
"reflect"
"sync"
"github.com/bytedance/sonic"
@ -21,9 +20,7 @@ var (
//
// It's like json.Marshal, but with some differences:
//
// - It's ~4-5x faster in most cases.
//
// - It also supports custom Marshaler interface (MarshalJSONTo(buf []byte) []byte)
// - It supports custom Marshaler interface (MarshalJSONTo(buf []byte) []byte)
// to allow further optimizations.
//
// - It leverages the strutils library.
@ -36,20 +33,20 @@ var (
//
// `use_marshaler` to force using the custom marshaler for primitive types declaration (e.g. `type Status int`).
//
// - It correct the behavior of *url.URL and time.Duration.
// - It corrects the behavior of *url.URL and time.Duration.
//
// - It does not support maps other than string-keyed maps.
func Marshal(v any) ([]byte, error) {
buf := newBytes()
defer putBytes(buf)
return cloneBytes(appendMarshal(reflect.ValueOf(v), buf)), nil
return cloneBytes(appendMarshalAny(v, buf)), nil
}
func MarshalTo(v any, buf []byte) []byte {
return appendMarshal(reflect.ValueOf(v), buf)
return appendMarshalAny(v, buf)
}
const bufSize = 1024
const bufSize = 8192
var bytesPool = sync.Pool{
New: func() any {

View file

@ -20,7 +20,6 @@ var (
marshalFuncByKind map[reflect.Kind]marshalFunc
marshalFuncsByType = newCacheMap[reflect.Type, marshalFunc]()
flattenFieldsCache = newCacheMap[reflect.Type, []*field]()
nilValue = reflect.ValueOf(nil)
)
@ -44,6 +43,7 @@ func init() {
reflect.Map: appendMap,
reflect.Slice: appendArray,
reflect.Array: appendArray,
reflect.Struct: appendStruct,
reflect.Pointer: appendPtrInterface,
reflect.Interface: appendPtrInterface,
}
@ -69,17 +69,15 @@ func must(buf []byte, err error) []byte {
return buf
}
func appendMarshalAny(v any, buf []byte) []byte {
return appendMarshal(reflect.ValueOf(v), buf)
}
func appendMarshal(v reflect.Value, buf []byte) []byte {
if v == nilValue {
return append(buf, "null"...)
}
kind := v.Kind()
if kind == reflect.Struct {
if res, ok := appendWithCachedFunc(v, buf); ok {
return res
}
return appendStruct(v, buf)
}
marshalFunc, ok := marshalFuncByKind[kind]
if !ok {
panic(fmt.Errorf("unsupported type: %s", v.Type()))
@ -87,6 +85,10 @@ func appendMarshal(v reflect.Value, buf []byte) []byte {
return marshalFunc(v, buf)
}
func cacheMarshalFunc(t reflect.Type, marshalFunc marshalFunc) {
marshalFuncsByType.Store(t, marshalFunc)
}
func appendWithCachedFunc(v reflect.Value, buf []byte) (res []byte, ok bool) {
marshalFunc, ok := marshalFuncsByType.Load(v.Type())
if ok {
@ -108,7 +110,7 @@ func appendUint(v reflect.Value, buf []byte) []byte {
}
func appendFloat(v reflect.Value, buf []byte) []byte {
return strconv.AppendFloat(buf, v.Float(), 'f', 2, 64)
return strconv.AppendFloat(buf, v.Float(), 'f', -1, 64)
}
func appendWithCustomMarshaler(v reflect.Value, buf []byte) (res []byte, ok bool) {
@ -154,40 +156,6 @@ func appendKV(k reflect.Value, v reflect.Value, buf []byte) []byte {
return appendMarshal(v, buf)
}
func appendStruct(v reflect.Value, buf []byte) []byte {
if res, ok := appendWithCustomMarshaler(v, buf); ok {
return res
}
buf = append(buf, '{')
oldN := len(buf)
fields := flattenFields(v.Type())
for _, f := range fields {
cur := v.Field(f.index)
if f.omitEmpty && f.checkEmpty(cur) {
continue
}
if !f.hasInner {
buf = f.appendKV(cur, buf)
buf = append(buf, ',')
} else {
if f.isPtr {
cur = cur.Elem()
}
for _, inner := range f.inner {
buf = inner.appendKV(cur.Field(inner.index), buf)
buf = append(buf, ',')
}
}
}
n := len(buf)
if oldN != n {
buf = buf[:n-1]
}
return append(buf, '}')
}
func appendMap(v reflect.Value, buf []byte) []byte {
if v.Type().Key().Kind() != reflect.String {
panic(fmt.Errorf("map key must be string: %s", v.Type()))
@ -230,10 +198,6 @@ func appendArray(v reflect.Value, buf []byte) []byte {
return append(buf, ']')
}
func cacheMarshalFunc(t reflect.Type, marshalFunc marshalFunc) {
marshalFuncsByType.Store(t, marshalFunc)
}
func appendPtrInterface(v reflect.Value, buf []byte) []byte {
return appendMarshal(v.Elem(), buf)
}

View file

@ -3,23 +3,17 @@ package json_test
import (
stdJSON "encoding/json"
"fmt"
"maps"
"reflect"
"runtime/debug"
"strconv"
"testing"
"github.com/bytedance/sonic"
"github.com/stretchr/testify/require"
"github.com/yusing/go-proxy/internal/utils/strutils"
. "github.com/yusing/go-proxy/internal/utils/testing"
. "github.com/yusing/go-proxy/pkg/json"
)
func init() {
debug.SetMemoryLimit(1024 * 1024)
debug.SetMaxStack(1024 * 1024)
}
type testStruct struct {
Name string `json:"name"`
Age int `json:"age"`
@ -104,19 +98,6 @@ type selfReferencing struct {
Self *selfReferencing `json:"self"`
}
var testData = map[string]any{
"string": "test string",
"number": 42,
"float": 3.14159,
"bool": true,
"null_value": nil,
"array": []any{1, "2", 3.3, true, false, nil},
"object": map[string]any{
"nested": "value",
"count": 10,
},
}
func TestMarshal(t *testing.T) {
tests := []struct {
name string
@ -386,8 +367,19 @@ func TestMarshal(t *testing.T) {
input: map[string]testStruct{"one": {Name: "John", Age: 30, Score: 8.5}, "two": {Name: "Jane", Age: 25, Score: 9.5}},
},
{
name: "complex_map",
input: testData,
name: "complex_map",
input: map[string]any{
"string": "test string",
"number": 42,
"float": 3.14159,
"bool": true,
"null_value": nil,
"array": []any{1, "2", 3.3, true, false, nil},
"object": map[string]any{
"nested": "value",
"count": 10,
},
},
},
}
@ -411,34 +403,6 @@ func TestMarshal(t *testing.T) {
}
}
func TestMapAndMapSlice(t *testing.T) {
tests := []struct {
name string
input any
expected string
}{
{
name: "Map",
input: Map[string]{"key1": "value1", "key2": "value2"},
expected: `{"key1":"value1","key2":"value2"}`,
},
{
name: "MapSlice",
input: MapSlice[string]{{"key1": "value1"}, {"key2": "value2"}},
expected: `[{"key1":"value1"},{"key2":"value2"}]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := Marshal(tt.input)
if string(result) != tt.expected {
t.Errorf("Marshal(%v) = %s, want %s", tt.input, string(result), tt.expected)
}
})
}
}
func TestMarshalSyntacticEquivalence(t *testing.T) {
testData := []any{
"test\r\nstring",
@ -482,7 +446,46 @@ func TestMarshalSyntacticEquivalence(t *testing.T) {
}
}
func BenchmarkMarshalNoStructStdLib(b *testing.B) {
func TestWithTestStruct(t *testing.T) {
var custom, stdlib []byte
var err error
custom, err = Marshal(TwitterObject)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
stdlib, err = stdJSON.Marshal(TwitterObject)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
var unmarshalCustom, unmarshalStdlib any
if err := Unmarshal(custom, &unmarshalCustom); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if err := sonic.Unmarshal(stdlib, &unmarshalStdlib); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
ExpectEqual(t, unmarshalCustom, unmarshalStdlib)
}
func BenchmarkMarshalSimpleStdLib(b *testing.B) {
testData := map[string]any{
"string": "test string",
"number": 42,
"float": 3.14159,
"bool": true,
"null_value": nil,
"bytes": []byte("test"),
"array": []any{1, "2", 3.3, true, false, nil},
"object": map[string]any{
"nested": "value",
"count": 10,
},
}
b.Run("StdLib", func(b *testing.B) {
for b.Loop() {
_, _ = stdJSON.Marshal(testData)
@ -502,28 +505,22 @@ func BenchmarkMarshalNoStructStdLib(b *testing.B) {
})
}
func BenchmarkMarshalStruct(b *testing.B) {
withStruct := maps.Clone(testData)
withStruct["struct1"] = withAnonymous{Anonymous: Anonymous{Value: "one", Value2: 1}}
withStruct["struct2"] = &withPointerAnonymous{Anonymous: &Anonymous{Value: "two", Value2: 2}}
withStruct["struct3"] = &testStruct{Name: "three", Age: 30, Score: 9.8}
b.ResetTimer()
func BenchmarkMarshalTestStruct(b *testing.B) {
b.Run("StdLib", func(b *testing.B) {
for b.Loop() {
_, _ = stdJSON.Marshal(withStruct)
_, _ = stdJSON.Marshal(TwitterObject)
}
})
b.Run("Sonic", func(b *testing.B) {
for b.Loop() {
_, _ = sonic.Marshal(withStruct)
_, _ = sonic.Marshal(TwitterObject)
}
})
b.Run("Custom", func(b *testing.B) {
for b.Loop() {
_, _ = Marshal(withStruct)
_, _ = Marshal(TwitterObject)
}
})
}

View file

@ -20,6 +20,10 @@ type field struct {
marshal marshalFunc
}
func (f *field) appendKV(v reflect.Value, buf []byte) []byte {
return f.marshal(v, append(buf, f.quotedNameWithCol...))
}
const (
tagOmitEmpty = "omitempty"
tagString = "string" // https://pkg.go.dev/github.com/yusing/go-proxy/pkg/json#Marshal
@ -28,13 +32,56 @@ const (
tagUseMarshaler = "use_marshaler"
)
func flattenFields(t reflect.Type) []*field {
fields, ok := flattenFieldsCache.Load(t)
if ok {
return fields
func appendStruct(v reflect.Value, buf []byte) []byte {
if res, ok := appendWithCachedFunc(v, buf); ok {
return res
}
fields = make([]*field, 0, t.NumField())
if res, ok := appendWithCustomMarshaler(v, buf); ok {
return res
}
t := v.Type()
fields := flattenFields(t)
marshalFn := func(v reflect.Value, buf []byte) []byte {
return appendFields(v, fields, buf)
}
cacheMarshalFunc(t, marshalFn)
return marshalFn(v, buf)
}
func appendFields(v reflect.Value, fields []*field, buf []byte) []byte {
buf = append(buf, '{')
oldN := len(buf)
for _, f := range fields {
cur := v.Field(f.index)
if f.omitEmpty && f.checkEmpty(cur) {
continue
}
if !f.hasInner {
buf = f.appendKV(cur, buf)
buf = append(buf, ',')
} else {
if f.isPtr {
cur = cur.Elem()
}
for _, inner := range f.inner {
buf = inner.appendKV(cur.Field(inner.index), buf)
buf = append(buf, ',')
}
}
}
n := len(buf)
if oldN != n {
buf = buf[:n-1]
}
return append(buf, '}')
}
func flattenFields(t reflect.Type) []*field {
fields := make([]*field, 0, t.NumField())
for i := range t.NumField() {
structField := t.Field(i)
if !structField.IsExported() {
@ -95,11 +142,5 @@ func flattenFields(t reflect.Type) []*field {
}
f.quotedNameWithCol = strconv.Quote(f.quotedNameWithCol) + ":"
}
flattenFieldsCache.Store(t, fields)
return fields
}
func (f *field) appendKV(v reflect.Value, buf []byte) []byte {
return f.marshal(v, append(buf, f.quotedNameWithCol...))
}

563
pkg/json/testdata_test.go Normal file
View file

@ -0,0 +1,563 @@
/*
* Copyright 2021 ByteDance Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// from https://github.com/bytedance/sonic/blob/main/encoder/testdata_test.go
package json_test
import "github.com/bytedance/sonic"
var TwitterObject = func() *TwitterStruct {
var t TwitterStruct
err := sonic.Unmarshal([]byte(TwitterJson), &t)
if err != nil {
panic(err)
}
return &t
}()
const TwitterJson = `{
"statuses": [
{
"coordinates": null,
"favorited": false,
"truncated": false,
"created_at": "Mon Sep 24 03:35:21 +0000 2012",
"id_str": "250075927172759552",
"entities": {
"urls": [
],
"hashtags": [
{
"text": "freebandnames",
"indices": [
20,
34
]
}
],
"user_mentions": [
]
},
"in_reply_to_user_id_str": null,
"contributors": null,
"text": "Aggressive Ponytail #freebandnames",
"metadata": {
"iso_language_code": "en",
"result_type": "recent"
},
"retweet_count": 0,
"in_reply_to_status_id_str": null,
"id": 250075927172759552,
"geo": null,
"retweeted": false,
"in_reply_to_user_id": null,
"place": null,
"user": {
"profile_sidebar_fill_color": "DDEEF6",
"profile_sidebar_border_color": "C0DEED",
"profile_background_tile": false,
"name": "Sean Cummings",
"profile_image_url": "https://a0.twimg.com/profile_images/2359746665/1v6zfgqo8g0d3mk7ii5s_normal.jpeg",
"created_at": "Mon Apr 26 06:01:55 +0000 2010",
"location": "LA, CA",
"follow_request_sent": null,
"profile_link_color": "0084B4",
"is_translator": false,
"id_str": "137238150",
"entities": {
"url": {
"urls": [
{
"expanded_url": null,
"url": "",
"indices": [
0,
0
]
}
]
},
"description": {
"urls": [
]
}
},
"default_profile": true,
"contributors_enabled": false,
"favourites_count": 0,
"url": null,
"profile_image_url_https": "https://si0.twimg.com/profile_images/2359746665/1v6zfgqo8g0d3mk7ii5s_normal.jpeg",
"utc_offset": -28800,
"id": 137238150,
"profile_use_background_image": true,
"listed_count": 2,
"profile_text_color": "333333",
"lang": "en",
"followers_count": 70,
"protected": false,
"notifications": null,
"profile_background_image_url_https": "https://si0.twimg.com/images/themes/theme1/bg.png",
"profile_background_color": "C0DEED",
"verified": false,
"geo_enabled": true,
"time_zone": "Pacific Time (US & Canada)",
"description": "Born 330 Live 310",
"default_profile_image": false,
"profile_background_image_url": "https://a0.twimg.com/images/themes/theme1/bg.png",
"statuses_count": 579,
"friends_count": 110,
"following": null,
"show_all_inline_media": false,
"screen_name": "sean_cummings"
},
"in_reply_to_screen_name": null,
"source": "<a href=\"//itunes.apple.com/us/app/twitter/id409789998?mt=12%5C%22\" rel=\"\\\"nofollow\\\"\">Twitter for Mac</a>",
"in_reply_to_status_id": null
},
{
"coordinates": null,
"favorited": false,
"truncated": false,
"created_at": "Fri Sep 21 23:40:54 +0000 2012",
"id_str": "249292149810667520",
"entities": {
"urls": [
],
"hashtags": [
{
"text": "FreeBandNames",
"indices": [
20,
34
]
}
],
"user_mentions": [
]
},
"in_reply_to_user_id_str": null,
"contributors": null,
"text": "Thee Namaste Nerdz. #FreeBandNames",
"metadata": {
"iso_language_code": "pl",
"result_type": "recent"
},
"retweet_count": 0,
"in_reply_to_status_id_str": null,
"id": 249292149810667520,
"geo": null,
"retweeted": false,
"in_reply_to_user_id": null,
"place": null,
"user": {
"profile_sidebar_fill_color": "DDFFCC",
"profile_sidebar_border_color": "BDDCAD",
"profile_background_tile": true,
"name": "Chaz Martenstein",
"profile_image_url": "https://a0.twimg.com/profile_images/447958234/Lichtenstein_normal.jpg",
"created_at": "Tue Apr 07 19:05:07 +0000 2009",
"location": "Durham, NC",
"follow_request_sent": null,
"profile_link_color": "0084B4",
"is_translator": false,
"id_str": "29516238",
"entities": {
"url": {
"urls": [
{
"expanded_url": null,
"url": "https://bullcityrecords.com/wnng/",
"indices": [
0,
32
]
}
]
},
"description": {
"urls": [
]
}
},
"default_profile": false,
"contributors_enabled": false,
"favourites_count": 8,
"url": "https://bullcityrecords.com/wnng/",
"profile_image_url_https": "https://si0.twimg.com/profile_images/447958234/Lichtenstein_normal.jpg",
"utc_offset": -18000,
"id": 29516238,
"profile_use_background_image": true,
"listed_count": 118,
"profile_text_color": "333333",
"lang": "en",
"followers_count": 2052,
"protected": false,
"notifications": null,
"profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/9423277/background_tile.bmp",
"profile_background_color": "9AE4E8",
"verified": false,
"geo_enabled": false,
"time_zone": "Eastern Time (US & Canada)",
"description": "You will come to Durham, North Carolina. I will sell you some records then, here in Durham, North Carolina. Fun will happen.",
"default_profile_image": false,
"profile_background_image_url": "https://a0.twimg.com/profile_background_images/9423277/background_tile.bmp",
"statuses_count": 7579,
"friends_count": 348,
"following": null,
"show_all_inline_media": true,
"screen_name": "bullcityrecords"
},
"in_reply_to_screen_name": null,
"source": "web",
"in_reply_to_status_id": null
},
{
"coordinates": null,
"favorited": false,
"truncated": false,
"created_at": "Fri Sep 21 23:30:20 +0000 2012",
"id_str": "249289491129438208",
"entities": {
"urls": [
],
"hashtags": [
{
"text": "freebandnames",
"indices": [
29,
43
]
}
],
"user_mentions": [
]
},
"in_reply_to_user_id_str": null,
"contributors": null,
"text": "Mexican Heaven, Mexican Hell #freebandnames",
"metadata": {
"iso_language_code": "en",
"result_type": "recent"
},
"retweet_count": 0,
"in_reply_to_status_id_str": null,
"id": 249289491129438208,
"geo": null,
"retweeted": false,
"in_reply_to_user_id": null,
"place": null,
"user": {
"profile_sidebar_fill_color": "99CC33",
"profile_sidebar_border_color": "829D5E",
"profile_background_tile": false,
"name": "Thomas John Wakeman",
"profile_image_url": "https://a0.twimg.com/profile_images/2219333930/Froggystyle_normal.png",
"created_at": "Tue Sep 01 21:21:35 +0000 2009",
"location": "Kingston New York",
"follow_request_sent": null,
"profile_link_color": "D02B55",
"is_translator": false,
"id_str": "70789458",
"entities": {
"url": {
"urls": [
{
"expanded_url": null,
"url": "",
"indices": [
0,
0
]
}
]
},
"description": {
"urls": [
]
}
},
"default_profile": false,
"contributors_enabled": false,
"favourites_count": 19,
"url": null,
"profile_image_url_https": "https://si0.twimg.com/profile_images/2219333930/Froggystyle_normal.png",
"utc_offset": -18000,
"id": 70789458,
"profile_use_background_image": true,
"listed_count": 1,
"profile_text_color": "3E4415",
"lang": "en",
"followers_count": 63,
"protected": false,
"notifications": null,
"profile_background_image_url_https": "https://si0.twimg.com/images/themes/theme5/bg.gif",
"profile_background_color": "352726",
"verified": false,
"geo_enabled": false,
"time_zone": "Eastern Time (US & Canada)",
"description": "Science Fiction Writer, sort of. Likes Superheroes, Mole People, Alt. Timelines.",
"default_profile_image": false,
"profile_background_image_url": "https://a0.twimg.com/images/themes/theme5/bg.gif",
"statuses_count": 1048,
"friends_count": 63,
"following": null,
"show_all_inline_media": false,
"screen_name": "MonkiesFist"
},
"in_reply_to_screen_name": null,
"source": "web",
"in_reply_to_status_id": null
},
{
"coordinates": null,
"favorited": false,
"truncated": false,
"created_at": "Fri Sep 21 22:51:18 +0000 2012",
"id_str": "249279667666817024",
"entities": {
"urls": [
],
"hashtags": [
{
"text": "freebandnames",
"indices": [
20,
34
]
}
],
"user_mentions": [
]
},
"in_reply_to_user_id_str": null,
"contributors": null,
"text": "The Foolish Mortals #freebandnames",
"metadata": {
"iso_language_code": "en",
"result_type": "recent"
},
"retweet_count": 0,
"in_reply_to_status_id_str": null,
"id": 249279667666817024,
"geo": null,
"retweeted": false,
"in_reply_to_user_id": null,
"place": null,
"user": {
"profile_sidebar_fill_color": "BFAC83",
"profile_sidebar_border_color": "615A44",
"profile_background_tile": true,
"name": "Marty Elmer",
"profile_image_url": "https://a0.twimg.com/profile_images/1629790393/shrinker_2000_trans_normal.png",
"created_at": "Mon May 04 00:05:00 +0000 2009",
"location": "Wisconsin, USA",
"follow_request_sent": null,
"profile_link_color": "3B2A26",
"is_translator": false,
"id_str": "37539828",
"entities": {
"url": {
"urls": [
{
"expanded_url": null,
"url": "https://www.omnitarian.me",
"indices": [
0,
24
]
}
]
},
"description": {
"urls": [
]
}
},
"default_profile": false,
"contributors_enabled": false,
"favourites_count": 647,
"url": "https://www.omnitarian.me",
"profile_image_url_https": "https://si0.twimg.com/profile_images/1629790393/shrinker_2000_trans_normal.png",
"utc_offset": -21600,
"id": 37539828,
"profile_use_background_image": true,
"listed_count": 52,
"profile_text_color": "000000",
"lang": "en",
"followers_count": 608,
"protected": false,
"notifications": null,
"profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/106455659/rect6056-9.png",
"profile_background_color": "EEE3C4",
"verified": false,
"geo_enabled": false,
"time_zone": "Central Time (US & Canada)",
"description": "Cartoonist, Illustrator, and T-Shirt connoisseur",
"default_profile_image": false,
"profile_background_image_url": "https://a0.twimg.com/profile_background_images/106455659/rect6056-9.png",
"statuses_count": 3575,
"friends_count": 249,
"following": null,
"show_all_inline_media": true,
"screen_name": "Omnitarian"
},
"in_reply_to_screen_name": null,
"source": "<a href=\"//twitter.com/download/iphone%5C%22\" rel=\"\\\"nofollow\\\"\">Twitter for iPhone</a>",
"in_reply_to_status_id": null
}
],
"search_metadata": {
"max_id": 250126199840518145,
"since_id": 24012619984051000,
"refresh_url": "?since_id=250126199840518145&q=%23freebandnames&result_type=mixed&include_entities=1",
"next_results": "?max_id=249279667666817023&q=%23freebandnames&count=4&include_entities=1&result_type=mixed",
"count": 4,
"completed_in": 0.035,
"since_id_str": "24012619984051000",
"query": "%23freebandnames",
"max_id_str": "250126199840518145"
}
}`
type TwitterStruct struct {
Statuses []Statuses `json:"statuses"`
SearchMetadata SearchMetadata `json:"search_metadata"`
}
type Hashtags struct {
Text string `json:"text"`
Indices []int `json:"indices"`
}
type Entities struct {
Urls []interface{} `json:"urls"`
Hashtags []Hashtags `json:"hashtags"`
UserMentions []interface{} `json:"user_mentions"`
}
type Metadata struct {
IsoLanguageCode string `json:"iso_language_code"`
ResultType string `json:"result_type"`
}
type Urls struct {
ExpandedURL interface{} `json:"expanded_url"`
URL string `json:"url"`
Indices []int `json:"indices"`
}
type URL struct {
Urls []Urls `json:"urls"`
}
type Description struct {
Urls []interface{} `json:"urls"`
}
type UserEntities struct {
URL URL `json:"url"`
Description Description `json:"description"`
}
type User struct {
ProfileSidebarFillColor string `json:"profile_sidebar_fill_color"`
ProfileSidebarBorderColor string `json:"profile_sidebar_border_color"`
ProfileBackgroundTile bool `json:"profile_background_tile"`
Name string `json:"name"`
ProfileImageURL string `json:"profile_image_url"`
CreatedAt string `json:"created_at"`
Location string `json:"location"`
FollowRequestSent interface{} `json:"follow_request_sent"`
ProfileLinkColor string `json:"profile_link_color"`
IsTranslator bool `json:"is_translator"`
IDStr string `json:"id_str"`
Entities UserEntities `json:"entities"`
DefaultProfile bool `json:"default_profile"`
ContributorsEnabled bool `json:"contributors_enabled"`
FavouritesCount int `json:"favourites_count"`
URL interface{} `json:"url"`
ProfileImageURLHTTPS string `json:"profile_image_url_https"`
UtcOffset int `json:"utc_offset"`
ID int `json:"id"`
ProfileUseBackgroundImage bool `json:"profile_use_background_image"`
ListedCount int `json:"listed_count"`
ProfileTextColor string `json:"profile_text_color"`
Lang string `json:"lang"`
FollowersCount int `json:"followers_count"`
Protected bool `json:"protected"`
Notifications interface{} `json:"notifications"`
ProfileBackgroundImageURLHTTPS string `json:"profile_background_image_url_https"`
ProfileBackgroundColor string `json:"profile_background_color"`
Verified bool `json:"verified"`
GeoEnabled bool `json:"geo_enabled"`
TimeZone string `json:"time_zone"`
Description string `json:"description"`
DefaultProfileImage bool `json:"default_profile_image"`
ProfileBackgroundImageURL string `json:"profile_background_image_url"`
StatusesCount int `json:"statuses_count"`
FriendsCount int `json:"friends_count"`
Following interface{} `json:"following"`
ShowAllInlineMedia bool `json:"show_all_inline_media"`
ScreenName string `json:"screen_name"`
}
type Statuses struct {
Coordinates interface{} `json:"coordinates"`
Favorited bool `json:"favorited"`
Truncated bool `json:"truncated"`
CreatedAt string `json:"created_at"`
IDStr string `json:"id_str"`
Entities Entities `json:"entities"`
InReplyToUserIDStr interface{} `json:"in_reply_to_user_id_str"`
Contributors interface{} `json:"contributors"`
Text string `json:"text"`
Metadata Metadata `json:"metadata"`
RetweetCount int `json:"retweet_count"`
InReplyToStatusIDStr interface{} `json:"in_reply_to_status_id_str"`
ID int64 `json:"id"`
Geo interface{} `json:"geo"`
Retweeted bool `json:"retweeted"`
InReplyToUserID interface{} `json:"in_reply_to_user_id"`
Place interface{} `json:"place"`
User User `json:"user"`
InReplyToScreenName interface{} `json:"in_reply_to_screen_name"`
Source string `json:"source"`
InReplyToStatusID interface{} `json:"in_reply_to_status_id"`
}
type SearchMetadata struct {
MaxID int64 `json:"max_id"`
SinceID int64 `json:"since_id"`
RefreshURL string `json:"refresh_url"`
NextResults string `json:"next_results"`
Count int `json:"count"`
CompletedIn float64 `json:"completed_in"`
SinceIDStr string `json:"since_id_str"`
Query string `json:"query"`
MaxIDStr string `json:"max_id_str"`
}