Maison  >  Article  >  développement back-end  >  JSON vs FlatBuffers vs tampons de protocole

JSON vs FlatBuffers vs tampons de protocole

WBOY
WBOYoriginal
2024-08-08 01:31:54426parcourir

Quand on pense communication entre services/microservices, la première option qui nous vient à l'esprit est le bon vieux JSON. Et ce n'est pas sans raison, car le format présente des avantages, tels que :

  • est facilement lisible par les ordinateurs et les humains ;
  • tous les langages de programmation modernes peuvent lire et générer du JSON ;
  • il est beaucoup moins verbeux que l'alternative précédente, Jurassic XML.

Utiliser JSON est la recommandation pour la grande majorité des API développées dans le quotidien des entreprises. Mais dans certains cas, où les performances sont essentielles, nous devrons peut-être envisager d’autres alternatives. Cet article vise à montrer deux alternatives à JSON en matière de communication entre applications.

Mais quel est le problème avec JSON ? L'un de ses avantages est qu'il est « facilement lisible par les humains », mais cela peut constituer un point faible en termes de performances. Le fait est que nous devons convertir le contenu JSON en une structure connue du langage de programmation que nous utilisons. Une exception à cette règle est si nous utilisons JavaScript, car JSON y est natif. Mais si vous utilisez un autre langage, Go, par exemple, nous devons analyser les données, comme nous pouvons le voir dans l'exemple de code (incomplet) ci-dessous :

type event struct {
    ID      uuid.UUID
    Type    string `json:"type"`
    Source  string `json:"source"`
    Subject string `json:"subject"`
    Time    string `json:"time"`
    Data    string `json:"data"`
}

var e event
err := json.NewDecoder(data).Decode(&e)
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
}

Pour résoudre ce problème, nous pouvons tester deux alternatives, Protocol Buffers et Flatbuffers.

Tampons de protocole

Protobuf (Protocol Buffers), créé par Google, est, selon le site officiel :

Les tampons de protocole sont le mécanisme extensible de Google, neutre en termes de langage et de plate-forme, pour sérialiser des données structurées. Pensez XML, mais plus petit, plus rapide et plus simple. Vous définissez une fois la manière dont vous souhaitez que vos données soient structurées. Ensuite, vous pouvez utiliser un code source spécialement généré pour écrire et lire rapidement vos données structurées vers et depuis divers flux de données en utilisant une variété de langages.

Généralement utilisé en conjonction avec gRPC (mais pas nécessairement), Protobuf est un protocole binaire qui augmente considérablement les performances par rapport au format texte de JSON. Mais il "souffre" du même problème que JSON : nous devons l'analyser selon une structure de données de notre langage. Par exemple, dans Go :

//generated code
type Event struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Type    string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
    Subject string `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"`
    Source  string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
    Time    string `protobuf:"bytes,4,opt,name=time,proto3" json:"time,omitempty"`
    Data    string `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"`
}

e := Event{}
err := proto.Unmarshal(data, &e)
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
}

L'adoption d'un protocole binaire nous apporte un gain de performances, mais nous devons encore résoudre le problème de l'analyse des données. Notre troisième concurrent se concentre sur la résolution de ce problème.

Tampons plats

Selon le site officiel :

FlatBuffers est une bibliothèque de sérialisation multiplateforme efficace pour C++, C#, C, Go, Java, Kotlin, JavaScript, Lobster, Lua, TypeScript, PHP, Python, Rust et Swift. Il a été initialement créé chez Google pour le développement de jeux et d'autres applications critiques en termes de performances.

Bien qu'initialement créé pour le développement de jeux, il s'intègre parfaitement dans l'environnement que nous étudions dans cet article. Son avantage est que nous n'avons pas besoin d'analyser les données en plus d'être un protocole binaire. Par exemple, dans Go :

//generated code
e := events.GetRootAsEvent(data, 0)

//we can use the data directly
saveEvent(string(e.Type()), string(e.Source()), string(e.Subject()), string(e.Time()), string(e.Data()))

Mais à quel point les deux alternatives à JSON sont-elles plus performantes ? Enquêtons...

Application

La première question qui m'est venue à l'esprit était : "comment puis-je appliquer cela dans un scénario réel ?". J'ai imaginé le scénario suivant :

Une entreprise dotée d'une application mobile, consultée quotidiennement par des millions de clients, dotée d'une architecture interne de microservices et qui a besoin de sauvegarder les événements générés par les utilisateurs et les systèmes à des fins d'audit.

C’est un véritable scénario. Tellement réel que je vis avec ça tous les jours dans l'entreprise où je travaille :)

JSON vs FlatBuffers vs Protocol Buffers

Remarque : le scénario ci-dessus est une simplification et ne représente pas la complexité réelle de la candidature de l'équipe. Il sert à des fins éducatives.

La première étape consiste à définir un événement dans Protocol Buffers et Flatbuffers. Les deux ont leur propre langage pour définir des schémas, que nous pouvons ensuite utiliser pour générer du code dans les langages que nous utiliserons. Je n'entrerai pas dans les détails de chaque schéma car cela se trouve facilement dans la documentation.

Le fichier event.proto a la définition du Protocol Buffer :

syntax = "proto3";
package events;

option go_package = "./events_pb";

message Event {
    string type = 1;
    string subject = 2;
    string source = 3;
    string time = 4;
    string data = 5;
}

Et le fichier event.fbs a l'équivalent en Flatbuffers :

namespace events;

table Event {
    type: string;
    subject:string;
    source:string;
    time:string;
    data:string;
}

root_type Event;

L'étape suivante consiste à utiliser ces définitions pour générer le code nécessaire. Les commandes suivantes installent les dépendances sur macOS :

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
brew install protobuf
protoc -I=. --go_out=./ event.proto
brew install flatbuffers
flatc --go event.fbs

Le résultat est la création de packages Go pour manipuler les données dans chaque format.

Une fois les exigences remplies, l'étape suivante consistait à implémenter l'API d'événement. Le main.go ressemblait à ceci :

package main

import (
    "fmt"
    "net/http"
    "os"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/google/uuid"
)

func main() {
    r := handlers()
    http.ListenAndServe(":3000", r)
}

func handlers() *chi.Mux {
    r := chi.NewRouter()
    if os.Getenv("DEBUG") != "false" {
        r.Use(middleware.Logger)
    }
    r.Post("/json", processJSON())
    r.Post("/fb", processFB())
    r.Post("/pb", processPB())
    return r
}

func saveEvent(evType, source, subject, time, data string) {
    if os.Getenv("DEBUG") != "false" {
        id := uuid.New()
        q := fmt.Sprintf("insert into event values('%s', '%s', '%s', '%s', '%s', '%s')", id, evType, source, subject, time, data)
        fmt.Println(q)
    }
    // save event to database
}

Pour une meilleure organisation, j'ai créé des fichiers pour séparer chaque fonction, qui ressemblaient à ceci :

package main

import (
    "encoding/json"
    "net/http"

    "github.com/google/uuid"
)

type event struct {
    ID      uuid.UUID
    Type    string `json:"type"`
    Source  string `json:"source"`
    Subject string `json:"subject"`
    Time    string `json:"time"`
    Data    string `json:"data"`
}

func processJSON() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var e event
        err := json.NewDecoder(r.Body).Decode(&e)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
        saveEvent(e.Type, e.Source, e.Subject, e.Time, e.Data)
        w.WriteHeader(http.StatusCreated)
        w.Write([]byte("json received"))
    }
}

package main

import (
    "io"
    "net/http"

    "github.com/eminetto/post-flatbuffers/events_pb"
    "google.golang.org/protobuf/proto"
)

func processPB() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body := r.Body
        data, _ := io.ReadAll(body)

        e := events_pb.Event{}
        err := proto.Unmarshal(data, &e)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
        saveEvent(e.GetType(), e.GetSource(), e.GetSubject(), e.GetTime(), e.GetData())
        w.WriteHeader(http.StatusCreated)
        w.Write([]byte("protobuf received"))
    }
}
package main

import (
    "io"
    "net/http"

    "github.com/eminetto/post-flatbuffers/events"
)

func processFB() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body := r.Body
        data, _ := io.ReadAll(body)
        e := events.GetRootAsEvent(data, 0)
        saveEvent(string(e.Type()), string(e.Source()), string(e.Subject()), string(e.Time()), string(e.Data()))
        w.WriteHeader(http.StatusCreated)
        w.Write([]byte("flatbuffer received"))
    }
}

In the functions processPB() and processFB(), we can see how the generated packages are used to manipulate the data.

Benchmark

The last step of our proof of concept is generating the benchmark to compare the formats. I used the Go stdlib benchmark package for this.

The file main_test.go has tests for each format:

package main

import (
    "bytes"
    "fmt"
    "net/http"
    "net/http/httptest"
    "os"
    "strings"
    "testing"

    "github.com/eminetto/post-flatbuffers/events"
    "github.com/eminetto/post-flatbuffers/events_pb"
    flatbuffers "github.com/google/flatbuffers/go"
    "google.golang.org/protobuf/proto"
)

func benchSetup() {
    os.Setenv("DEBUG", "false")
}

func BenchmarkJSON(b *testing.B) {
    benchSetup()
    r := handlers()
    payload := fmt.Sprintf(`{
        "type": "button.clicked",
        "source": "Login",
        "subject": "user1000",
        "time": "2018-04-05T17:31:00Z",
        "data": "User clicked because X"}`)
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        req, _ := http.NewRequest("POST", "/json", strings.NewReader(payload))
        r.ServeHTTP(w, req)
        if w.Code != http.StatusCreated {
            b.Errorf("expected status 201, got %d", w.Code)
        }
    }
}

func BenchmarkFlatBuffers(b *testing.B) {
    benchSetup()
    r := handlers()
    builder := flatbuffers.NewBuilder(1024)
    evtType := builder.CreateString("button.clicked")
    evtSource := builder.CreateString("service-b")
    evtSubject := builder.CreateString("user1000")
    evtTime := builder.CreateString("2018-04-05T17:31:00Z")
    evtData := builder.CreateString("User clicked because X")

    events.EventStart(builder)
    events.EventAddType(builder, evtType)
    events.EventAddSource(builder, evtSource)
    events.EventAddSubject(builder, evtSubject)
    events.EventAddTime(builder, evtTime)
    events.EventAddData(builder, evtData)
    evt := events.EventEnd(builder)
    builder.Finish(evt)

    buff := builder.FinishedBytes()
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        req, _ := http.NewRequest("POST", "/fb", bytes.NewReader(buff))
        r.ServeHTTP(w, req)
        if w.Code != http.StatusCreated {
            b.Errorf("expected status 201, got %d", w.Code)
        }
    }
}

func BenchmarkProtobuffer(b *testing.B) {
    benchSetup()
    r := handlers()
    evt := events_pb.Event{
        Type:    "button.clicked",
        Subject: "user1000",
        Source:  "service-b",
        Time:    "2018-04-05T17:31:00Z",
        Data:    "User clicked because X",
    }
    payload, err := proto.Marshal(&evt)
    if err != nil {
        panic(err)
    }
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        req, _ := http.NewRequest("POST", "/pb", bytes.NewReader(payload))
        r.ServeHTTP(w, req)
        if w.Code != http.StatusCreated {
            b.Errorf("expected status 201, got %d", w.Code)
        }
    }
}

It generates an event in each format and sends it to the API.

When we run the benchmark, we have the following result:

Running tool: /opt/homebrew/bin/go test -benchmem -run=^$ -coverprofile=/var/folders/vn/gff4w90d37xbfc_2tn3616h40000gn/T/vscode-gojAS4GO/go-code-cover -bench . github.com/eminetto/post-flatbuffers/cmd/api -failfast -v

goos: darwin
goarch: arm64
pkg: github.com/eminetto/post-flatbuffers/cmd/api
BenchmarkJSON
BenchmarkJSON-8               658386          1732 ns/op        2288 B/op         26 allocs/op
BenchmarkFlatBuffers
BenchmarkFlatBuffers-8       1749194           640.5 ns/op      1856 B/op         21 allocs/op
BenchmarkProtobuffer
BenchmarkProtobuffer-8       1497356           696.9 ns/op      1952 B/op         21 allocs/op
PASS
coverage: 77.5% of statements
ok      github.com/eminetto/post-flatbuffers/cmd/api    5.042s

If this is the first time you have analyzed the results of a Go benchmark, I recommend reading this post, where the author describes the details of each column and its meaning.

To make it easier to visualize, I created graphs for the most critical information generated by the benchmark:

‌Number of iterations (higher is better)

JSON vs FlatBuffers vs Protocol Buffers

Nanoseconds per operation (lower is better)

JSON vs FlatBuffers vs Protocol Buffers

Number of bytes allocated per operation (lower is better)

JSON vs FlatBuffers vs Protocol Buffers

Number of allocations per operation (lower is better)

JSON vs FlatBuffers vs Protocol Buffers

Conclusion

The numbers show a great advantage of binary protocols over JSON, especially Flatbuffers. This advantage is that we do not need to parse the data into structures of the language we are using.

Should you refactor your applications to replace JSON with Flatbuffers? Not necessarily. Performance is just one factor that teams must consider when selecting a communication protocol between their services and applications. But if your application receives billions of requests per day, performance improvements like those presented in this post can make a big difference in terms of costs and user experience.

The codes presented here can be found in this repository. I made the examples using the Go language, but both Protocol Buffers and Flatbuffers support different programming languages, so I would love to see other versions of these comparisons. Additionally, other benchmarks can be used, such as network consumption, CPU, etc. (since we only compare memory here).

I hope this post serves as an introduction to these formats and an incentive for new tests and experiments.

Originally published at https://eltonminetto.dev on August 05, 2024

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn