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 :
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.
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.
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...
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 :)
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.
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)
Nanoseconds per operation (lower is better)
Number of bytes allocated per operation (lower is better)
Number of allocations per operation (lower is better)
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!