"Mein" Dienst ist ein Proxy zwischen bestimmten Modulen eines groĂen Projekts. Auf den ersten Blick können Sie es an einem Abend studieren und sich auf wichtigere Dinge konzentrieren. Aber als ich anfing zu arbeiten, wurde mir klar, dass ich mich geirrt hatte. Der Dienst wurde vor sechs Monaten in ein paar Wochen mit der Aufgabe geschrieben, MVP zu testen. WĂ€hrend dieser ganzen Zeit weigerte er sich zu arbeiten: Er verlor Ereignisse und Daten oder schrieb sie neu. Das Projekt wurde von Team zu Team geworfen, weil niemand es tun wollte, auch nicht seine Schöpfer. Jetzt wurde klar, warum sie einen separaten Programmierer dafĂŒr suchten.
"Mein" Service ist ein Beispiel fĂŒr schlechte Architektur und inhĂ€rent falsches Design. Wir alle verstehen, dass dies nicht getan werden sollte. Aber warum nicht, zu welchen Konsequenzen es fĂŒhrt und wie man versucht, alles zu reparieren, werde ich Ihnen sagen.
Wie schlecht Architektur im Weg steht
Typische Geschichte:
- MVP machen;
- Hypothesen darauf testen;
- , MVP;
- ...;
- PROFIT.
Dies ist jedoch nicht möglich (was wir alle verstehen).
Wenn Systeme in Eile gebaut werden, besteht die einzige Möglichkeit, immer wieder neue Versionen eines Produkts freizugeben, darin, das Personal aufzublĂ€hen. Anfangs zeigen die Entwickler eine ProduktivitĂ€t von nahezu 100%, aber wenn das anfĂ€nglich "rohe" Produkt mit Merkmalen und AbhĂ€ngigkeiten ĂŒberwachsen ist, dauert es immer lĂ€nger, es herauszufinden.
Mit jeder neuen Version sinkt die ProduktivitÀt der Entwickler. Niemand denkt an Code-Sauberkeit, Design und Architektur. Infolgedessen kann sich der Preis einer Codezeile um das 40-fache erhöhen.
Diese Prozesse sind in den Grafiken von Robert Martin deutlich zu sehen. Trotz der Tatsache, dass das Entwicklungspersonal von Version zu Version wĂ€chst, verlangsamt sich die Wachstumsrate des Produkts nur. Die Kosten steigen, der Umsatz sinkt, was bereits zu einem Personalabbau fĂŒhrt.
Herausforderung fĂŒr eine saubere Architektur
FĂŒr das Unternehmen spielt es keine Rolle, wie die Anwendung entworfen und geschrieben wird. FĂŒr Unternehmen ist es wichtig, dass sich das Produkt so verhĂ€lt, wie Benutzer es möchten, und dass es rentabel ist. Aber manchmal (nicht manchmal, aber oft) Ă€ndert das Unternehmen seine Lösungen und Anforderungen. Bei schlechter Struktur ist es schwierig, sich an neue Anforderungen anzupassen, Produkte zu Ă€ndern und neue Funktionen hinzuzufĂŒgen.
Ein gut gestaltetes System lĂ€sst sich leichter in das gewĂŒnschte Verhalten einpassen. Auch hier glaubt Robert Martin, dass Verhalten zweitrangig ist und immer korrigiert werden kann, wenn das System gut konzipiert ist.
Eine saubere Architektur fördert die Kommunikation zwischen den Ebenen im Projekt, wobei das Zentrum die GeschÀftslogik mit all ihren EntitÀten ist, die sich mit angewandten Problemen befassen.
- Alle Ă€uĂeren Schichten sind Adapter fĂŒr die Kommunikation mit der AuĂenwelt.
- Elemente der AuĂenwelt sollten den zentralen Teil des Projekts nicht durchdringen.
GeschĂ€ftslogik ist es egal, wer es ist: eine Desktop-Anwendung, ein Webserver oder ein Mikrocontroller. Es sollte nicht vom "Label" abhĂ€ngen. Sie muss bestimmte Aufgaben ausfĂŒhren. Alles andere sind Details, zum Beispiel Datenbanken oder Desktop.
Mit einer sauberen Architektur erhalten wir ein unabhĂ€ngiges System. Beispielsweise ist es unabhĂ€ngig von der Datenbank- oder Framework-Version. Wir können die Desktop-Anwendung fĂŒr die Anforderungen des Servers ersetzen, ohne die interne Komponente der GeschĂ€ftslogik zu Ă€ndern. DafĂŒr wird GeschĂ€ftslogik geschĂ€tzt.
Eine saubere Architektur reduziert die kognitive KomplexitÀt des Projekts, die Supportkosten und vereinfacht die Entwicklung und weitere Wartung von Programmierern.
Wie man "schlechte" Architektur identifiziert
Es gibt kein Konzept fĂŒr eine "schlechte" Architektur in der Programmierung. Es gibt Kriterien fĂŒr eine schlechte Architektur: Steifheit, Unbeweglichkeit, ZĂ€higkeit und ĂŒbermĂ€Ăige Wiederholbarkeit. Dies sind beispielsweise die Kriterien, anhand derer ich verstanden habe, dass die Architektur meines Microservices schlecht ist.
Starrheit . Es ist die UnfĂ€higkeit des Systems, auf selbst kleine Ănderungen zu reagieren. Wenn es schwierig wird, Teile des Projekts zu Ă€ndern, ohne das gesamte System zu beschĂ€digen, ist das System starr. Wenn beispielsweise eine Struktur in mehreren Ebenen des Projekts gleichzeitig verwendet wird, fĂŒhrt ihre kleine Ănderung zu Problemen im gesamten Projekt auf einmal.
Das Problem wird durch Konvertieren auf jeder Ebene behoben. Wenn jede Schicht nur ihre Objekte bearbeitet, die durch "Konvertieren" des externen Objekts erhalten wurden, werden die Schichten zu einer völlig unabhÀngigen
Unbeweglichkeit... Wenn das System mit schlechter Trennung (oder fehlendem) in wiederverwendbare Module gebaut wurde. Feste Systeme sind schwer zu ĂŒberarbeiten.
Wenn beispielsweise Informationen zu Datenbanken in den Bereich der GeschĂ€ftslogik gelangen, fĂŒhrt das Ersetzen der Datenbank durch eine andere zu einem Refactoring der gesamten GeschĂ€ftslogik.
ViskositĂ€t . Wenn die Aufteilung der Verantwortlichkeiten zwischen Paketen zu einer unnötigen Zentralisierung fĂŒhrt. Interessanterweise, was umgekehrt passiert, wenn ViskositĂ€t zur Dezentralisierung fĂŒhrt - alles ist in zu kleine Pakete unterteilt. In Go kann dies zu zirkulĂ€ren Importen fĂŒhren. Dies geschieht beispielsweise, wenn Adapterpakete zusĂ€tzliche Logik empfangen.
ĂbermĂ€Ăige Wiederholbarkeit... In Go ist der Ausdruck "Kleine Kopie ist besser als kleine AbhĂ€ngigkeit" beliebt. Dies fĂŒhrt jedoch nicht dazu, dass es weniger AbhĂ€ngigkeiten gibt - es werden nur mehr Kopien. Ich sehe oft Kopien von Code aus anderen Paketen in verschiedenen Go-Paketen.
Zum Beispiel schreibt Robert Martin in seinem Buch "Clean Architecture", dass Google frĂŒher alle möglichen Zeichenfolgen wiederverwenden und sie separaten Bibliotheken zuordnen musste. Dies fĂŒhrte dazu, dass das Ăndern von 2-3 Zeilen eines kleinen Dienstes alle anderen zugehörigen Dienste betraf. Das Unternehmen behebt immer noch Probleme mit diesem Ansatz.
Wunsch nach Refactor... Dies ist ein Bonuskriterium fĂŒr schlechte Architektur. Aber es gibt Nuancen. UnabhĂ€ngig davon, wie schlecht das Projekt von Ihnen geschrieben wurde oder nicht, sollten Sie es niemals von Grund auf neu schreiben. Dies fĂŒhrt nur zu zusĂ€tzlichen Problemen. FĂŒhren Sie iteratives Refactoring durch.
Wie man relativ richtig gestaltet
"Mein" Proxy-Service lebte sechs Monate und erfĂŒllte die ganze Zeit seine Aufgaben nicht. Wie hat er so lange gelebt?
Wenn ein Unternehmen ein Produkt testet und es Unwirksamkeit zeigt, wird es aufgegeben oder zerstört. Es ist in Ordnung. Wenn der MVP getestet wird und sich als effizient herausstellt, lebt er weiter. Aber normalerweise wird MVP nicht neu geschrieben und lebt "wie es ist" weiter, ĂŒberwachsen mit Code und FunktionalitĂ€t. Daher sind "Zombie-Produkte", die fĂŒr MVPs erstellt wurden, eine gĂ€ngige Praxis.
Als ich herausfand, dass mein Proxy-Service nicht funktionierte, beschloss das Team, ihn neu zu schreiben. Dieses GeschÀft wurde mir und einem Kollegen zugewiesen und zwei Wochen zugewiesen: Es gibt wenig GeschÀftslogik, der Service ist klein. Dies war ein weiterer Fehler.
Der Dienst wurde komplett neu geschrieben. Beim Schneiden, Umschreiben von Teilen des Codes und Hochladen in die Testumgebung stĂŒrzte ein Teil der Plattform ab. Es stellte sich heraus, dass der Service eine Menge undokumentierter GeschĂ€ftslogik hatte, von der niemand etwas wusste. Mein Kollege und ich sind gescheitert, aber dies ist ein Fehler in der Servicelogik.
Wir haben uns entschlossen, das Refactoring von der anderen Seite aus zu betrachten:
- Rollback auf die vorherige Version;
- Der Code wird nicht neu geschrieben.
- Wir teilen den Code in Teile - Pakete;
- Jedes Paket ist in eine separate Schnittstelle eingebunden.
Wir haben nicht verstanden, was der Service tat, weil niemand es verstand. Daher ist es die einzige Option, den Service in Teilen zu "sĂ€gen" und herauszufinden, wofĂŒr jedes Teil verantwortlich ist.
Danach war es möglich, jedes Paket einzeln umzugestalten. Wir könnten jeden Teil des Dienstes separat reparieren und / oder in anderen Teilen des Projekts implementieren. Gleichzeitig wird bis heute an dem Service gearbeitet.
Es stellte sich so heraus.
Wie wĂŒrden wir einen Ă€hnlichen Service schreiben, wenn wir ihn von Anfang an âgutâ gestalten wĂŒrden? Lassen Sie mich Ihnen das Beispiel eines kleinen Mikrodienstes zeigen, der einen Benutzer registriert und autorisiert.
Einleitend
Wir brauchen: den Kern des Systems, eine EntitĂ€t, die GeschĂ€ftslogik definiert und ausfĂŒhrt, indem sie externe Module manipuliert.
type Core struct {
userRepo UserRepo
sessionRepo SessionRepo
hashing Hasher
auth Auth
}
Als nĂ€chstes benötigen Sie zwei VertrĂ€ge, mit denen Sie die Repo-Schicht verwenden können. Der erste Vertrag bietet uns eine Schnittstelle. Mit seiner Hilfe werden wir mit der Datenbankebene kommunizieren, die Informationen ĂŒber Benutzer speichert.
// UserRepo interface for user data repository.
type UserRepo interface {
// CreateUser adds to the new user in repository.
// This method is also required to create a notifying hoard.
// Errors: ErrEmailExist, ErrUsernameExist, unknown.
CreateUser(context.Context, User, TaskNotification) (UserID, error)
// UpdatePassword changes password.
// Resets all codes to reset the password.
// Errors: unknown.
UpdatePassword(context.Context, UserID, []byte) error
// UserByID returning user info by id.
// Errors: ErrNotFound, unknown.
UserByID(context.Context, UserID) (*User, error)
// UserByEmail returning user info by email.
// Errors: ErrNotFound, unknown.
UserByEmail(context.Context, string) (*User, error)
// UserByUsername returning user info by id.
// Errors: ErrNotFound, unknown.
UserByUsername(context.Context, string) (*User, error)
}
Der zweite Vertrag "kommuniziert" mit der Ebene, in der Informationen zu Benutzersitzungen gespeichert sind.
// SessionRepo interface for session data repository.
type SessionRepo interface {
// SaveSession saves the new user Session in a database.
// Errors: unknown.
SaveSession(context.Context, UserID, TokenID, Origin) error
// Session returns user Session.
// Errors: ErrNotFound, unknown.
SessionByTokenID(context.Context, TokenID) (*Session, error)
// UserByAuthToken returning user info by authToken.
// Errors: ErrNotFound, unknown.
UserByTokenID(context.Context, TokenID) (*User, error)
// DeleteSession removes user Session.
// Errors: unknown.
DeleteSession(context.Context, TokenID) error
}
Jetzt benötigen Sie eine Schnittstelle zum Arbeiten, Hashing und Vergleichen von Passwörtern. Und auch die neueste Schnittstelle fĂŒr die Arbeit mit Autorisierungstoken, mit der sie generiert und auch identifiziert werden können.
// Hasher module responsible for working with passwords.
type Hasher interface {
// Password returns the hashed version of the password.
// Errors: unknown.
Password(password string) ([]byte, error)
// Compare compares two passwords for matches.
Compare(hashedPassword []byte, password []byte) error
}
// Auth module is responsible for working with authorization tokens.
type Auth interface {
// Token generates an authorization auth with a specified lifetime,
// and can also use the UserID if necessary.
// Errors: unknown.
Token(expired time.Duration) (AuthToken, TokenID, error)
// Parse and validates the auth and checks that it's expired.
// Errors: ErrInvalidToken, ErrExpiredToken, unknown.
Parse(token AuthToken) (TokenID, error)
}
Beginnen wir mit dem Schreiben der Logik. Die Hauptfrage ist, was wir von der GeschÀftslogik der Anwendung erwarten.
- Benutzer Registration.
- Mail und Spitznamen ĂŒberprĂŒfen.
- Genehmigung.
Schecks
Beginnen wir mit einfachen Methoden - dem ĂberprĂŒfen von E-Mails oder Spitznamen. Unser UserRepo hat keine Methoden zur ĂberprĂŒfung. Wir werden sie jedoch nicht hinzufĂŒgen. Wir können ĂŒberprĂŒfen, ob diese oder jene Daten belegt sind, indem wir den Benutzer nach diesen Daten fragen.
// VerificationEmail for implemented UserApp.
func (a *Application) VerificationEmail(ctx context.Context, email string) error {
_, err := a.userRepo.UserByEmail(ctx, email)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrEmailExist
default:
return err
}
}
// VerificationUsername for implemented UserApp.
func (a *Application) VerificationUsername(ctx context.Context, username string) error {
_, err := a.userRepo.UserByUsername(ctx, username)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrUsernameExist
default:
return err
}
}
Hier gibt es zwei Nuancen.
Warum
ErrNotFoundbesteht die PrĂŒfung einen Fehler ? Die Implementierung der GeschĂ€ftslogik sollte nicht von SQL oder einer anderen Datenbank abhĂ€ngen, daher sql.ErrNoRowssollte sie in den Fehler konvertiert werden, der fĂŒr unsere GeschĂ€ftslogik geeignet ist.
Wir erhöhen auch den Fehler der GeschÀftslogikschicht mit der API-Schicht, und der Fehlercode oder etwas anderes muss auf API-Ebene behoben werden. Die GeschÀftslogik sollte nicht vom Kommunikationsprotokoll mit dem Kunden abhÀngen und auf dieser Grundlage Entscheidungen treffen.
Registrierung und Autorisierung
// CreateUser for implemented UserApp.
func (a *Application) CreateUser(ctx context.Context, email, username, password string, origin Origin) (*User, AuthToken, error) {
passHash, err := a.password.Password(password)
if err != nil {
return nil, "", err
}
email = strings.ToLower(email)
newUser := User{
Email: email,
Name: username,
PassHash: passHash,
}
_, err = a.userRepo.CreateUser(ctx, newUser)
if err != nil {
return nil, "", err
}
return a.Login(ctx, email, password, origin)
}
// Login for implemented UserApp.
func (a *Application) Login(ctx context.Context, email, password string, origin Origin) (*User, AuthToken, error) {
email = strings.ToLower(email)
user, err := a.userRepo.UserByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if err := a.password.Compare(user.PassHash, []byte(password)); err != nil {
return nil, "", err
}
token, tokenID, err := a.auth.Token(TokenExpire)
if err != nil {
return nil, "", err
}
err = a.sessionRepo.SaveSession(ctx, user.ID, tokenID, origin)
if err != nil {
return nil, "", err
}
return user, token, nil
}
Es ist einfacher, zwingender Code, der leicht zu lesen und zu warten ist. Sie können diesen Code sofort beim Entwerfen schreiben. Es spielt keine Rolle, zu welcher Datenbank wir den Benutzer hinzufĂŒgen, welches Protokoll wir fĂŒr die Kommunikation mit Clients auswĂ€hlen oder wie Kennwörter gehasht werden. Die GeschĂ€ftslogik interessiert sich nicht fĂŒr alle diese Ebenen, sondern ist nur wichtig, um die Aufgaben ihres Anwendungsbereichs auszufĂŒhren.
Einfache Hashing-Schicht
Was bedeutet das? Alle externen Nicht-Layer sollten keine Entscheidungen ĂŒber Aufgaben treffen, die sich auf den Anwendungsbereich beziehen. Sie erfĂŒllen eine spezifische und einfache Aufgabe, die unsere GeschĂ€ftslogik erfordert. Nehmen wir zum Beispiel eine Ebene fĂŒr das Hashing von Passwörtern.
// Package hasher contains methods for hashing and comparing passwords.
package hasher
import (
"errors"
"github.com/zergslaw/boilerplate/internal/app"
"golang.org/x/crypto/bcrypt"
)
type (
// Hasher is an implements app.Hasher.
// Responsible for working passwords, hashing and compare.
Hasher struct {
cost int
}
)
// New creates and returns new app.Hasher.
func New(cost int) app.Hasher {
return &Hasher{cost: cost}
}
// Hashing need for implements app.Hasher.
func (h *Hasher) Password(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), h.cost)
}
// Compare need for implements app.Hasher.
func (h *Hasher) Compare(hashedPassword []byte, password []byte) error {
err := bcrypt.CompareHashAndPassword(hashedPassword, password)
switch {
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return app.ErrNotValidPassword
case err != nil:
return err
}
return nil
}
Dies ist eine einfache Ebene zum AusfĂŒhren von Kennwort-Hashing- und Vergleichsaufgaben. Das ist alles. Er ist dĂŒnn und einfach und weiĂ nichts anderes. Und das sollte es nicht.
Repo
Lassen Sie uns ĂŒber die Speicherinteraktionsschicht nachdenken.
Lassen Sie uns die Implementierung deklarieren und angeben, welche Schnittstellen implementiert werden sollen.
var _ app.SessionRepo = &Repo{}
var _ app.UserRepo = &Repo{}
// Repo is an implements app.UserRepo.
// Responsible for working with database.
type Repo struct {
db *sqlx.DB
}
// New creates and returns new app.UserRepo.
func New(repo *sqlx.DB) *Repo {
return &Repo{db: repo}
}
Es wird möglich sein, dem Leser des Codes zu vermitteln, welche VertrĂ€ge von der Schicht implementiert werden, und die fĂŒr unser Repo festgelegten Aufgaben zu berĂŒcksichtigen.
Kommen wir zur Implementierung. Um den Artikel nicht zu dehnen, werde ich nur einen Teil der Methoden angeben.
// CreateUser need for implements app.UserRepo.
func (repo *Repo) CreateUser(ctx context.Context, newUser app.User, task app.TaskNotification) (userID app.UserID, err error) {
const query = `INSERT INTO users (username, email, pass_hash) VALUES ($1, $2, $3) RETURNING id`
hash := pgtype.Bytea{
Bytes: newUser.PassHash,
Status: pgtype.Present,
}
err = repo.db.QueryRowxContext(ctx, query, newUser.Name, newUser.Email, hash).Scan(&userID)
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return userID, nil
}
// UserByUsername need for implements app.UserRepo.
func (repo *Repo) UserByUsername(ctx context.Context, username string) (user *app.User, err error) {
const query = `SELECT * FROM users WHERE username = $1`
u := &userDBFormat{}
err = repo.db.GetContext(ctx, u, query, username)
if err != nil {
return nil, err
}
return u.toAppFormat(), nil
}
Die Repo-Ebene verfĂŒgt ĂŒber einfache und grundlegende Methoden. Sie wissen nichts anderes als "Speichern, Senden, Aktualisieren, Löschen, Suchen". Die Aufgabe der Schicht besteht nur darin, ein bequemer Datenanbieter fĂŒr jede Datenbank zu sein, die unser Projekt benötigt.
API
Es gibt noch eine API-Ebene fĂŒr die Interaktion mit dem Client.
Es ist erforderlich, Daten vom Client an die GeschĂ€ftslogik zu ĂŒbertragen, die Ergebnisse zurĂŒckzugeben und alle HTTP-Anforderungen vollstĂ€ndig zu erfĂŒllen - Anwendungsfehler konvertieren.
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
origin := orifinFromReq(r)
res, err := api.app.CreateUser(
r.Context(),
params.Email,
params.Username,
params.Password,
request,
)
switch {
case errors.Is(err, app.ErrNotFound):
http.Error(w, app.ErrNotFound.Error(), http.StatusNotFound)
case errors.Is(err, app.ErrChtoto):
http.Error(w, app.ErrChtoto.Error(), http.StatusTeapot)
case err == nil:
json.NewEncoder(w).Encode(res)
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
Damit enden seine Aufgaben: Er brachte die Daten, bekam das Ergebnis und konvertierte es in ein fĂŒr HTTP geeignetes Format.
WofĂŒr wird saubere Architektur wirklich benötigt?
WofĂŒr ist das alles? Warum bestimmte Architekturlösungen implementieren? Nicht fĂŒr die "Sauberkeit" des Codes, sondern fĂŒr die Testbarkeit. Wir brauchen die FĂ€higkeit, unseren eigenen Code bequem, einfach und einfach zu testen.
Zum Beispiel Code wie das ist schlecht :
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rows, err := api.db.QueryContext(r.Context(), "sql query", params.Param)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var arrayRes []val
for rows.Next() {
value := val{}
err := rows.Scan(&value)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
arrayRes = append(arrayRes, value)
}
//
err = json.NewEncoder(w).Encode(arrayRes)
w.WriteHeader(http.StatusOK)
}
Hinweis: Ich habe vergessen darauf hinzuweisen, dass dieser Code schlecht ist. Dies kann irrefĂŒhrend sein, wenn Sie vor dem Update lesen. Das tut mir leid.
Die Möglichkeit, Code ohne gröĂere Probleme zu testen, ist der Hauptvorteil einer sauberen Architektur.
Wir können die gesamte GeschĂ€ftslogik testen, indem wir von der Datenbank, dem Server und dem Protokoll abstrahieren. Es ist nur wichtig, dass wir die angewandten Aufgaben unserer Anwendung ausfĂŒhren. Jetzt können wir nach bestimmten und einfachen Regeln unseren Code problemlos erweitern und Ă€ndern.
Jedes Produkt hat GeschĂ€ftslogik. Eine gute Architektur hilft beispielsweise dabei, GeschĂ€ftslogik in ein Paket zu packen, dessen Aufgabe es ist, mit externen Modulen zu arbeiten, um Anwendungsaufgaben auszufĂŒhren.
Aber saubere Architektur ist nicht immer gut. Manchmal kann es böse werden und unnötige KomplexitĂ€t bringen. Wenn Sie versuchen, sofort perfekt zu schreiben, verschwenden wir wertvolle Zeit und lassen das Projekt im Stich. Sie mĂŒssen nicht perfekt schreiben - schreiben Sie gut, basierend auf Ihren GeschĂ€ftszielen.
, Golang Live 2020 14 17 . â 14 , â , .