Entspannte Schnittstellen für Gopher

Schnittstellen leicht gemacht: RESTful APIs in Go

Ralf Wirdemann

©SuS Media  ©Renee French

Alle bauen APIs. Grob geschätzt bestehen 80 Prozent der heute entwickelten Anwendungen im Kern aus einer oder mehreren serverseitigen Komponenten, die Geschäftslogik kapseln und diese ihren Clients über ein RESTful API zur Verfügung stellen. Ist das REST-Paradigma einmal verstanden, dann sind REST-APIs klar und einfach zu benutzen.

Go ist eine einfache, statisch typisierte, kompilierte und performante Programmiersprache, die sich hervorragend für die Entwicklung von REST-APIs eignet. Eigenschaften wie leichte Erlernbarkeit, ein simples und leistungsfähiges Concurrency-Modell, sehr guter HTTP-, REST- und JSON-Support, Cross-Plattform-Fähigkeit sowie einfaches Deployment zeichnen Go aus. Dieser Beitrag führt in die wesentlichen Aspekte der REST-Entwicklung in Go anhand eines einfachen Beispiels ein. Der Artikel verzichtet dabei auf eine explizite Einführung der Programmiersprache Go. Stattdessen werden Go-spezifische Konzepte erklärt, wenn sie im Kontext des Beispiels zum Einsatz kommen.

REST am fachlichen Beispiel

Den fachlichen Hintergrund des Beispiel-API bildet ein einfaches Shopsystem. Das Domainmodell des Shops besteht aus den Entitäten Customer, Order und Product. Abbildung 1 zeigt deren Beziehungen, URIs sowie JSON-Repräsentationen.

Abb. 1: Shop als fachliches Beispiel

Eine Ressource im REST-Kontext ist eine Abstraktion, die ein „Ding“ referenziert. Dieses Ding kann z. B. ein einzelner Kunde, eine Liste aller Kunden oder auch eine Liste von Kunden mit dem Namen Meyer sein. Ressourcen werden über URIs identifiziert. So identifiziert der URI /customers die Liste aller Kunden und die Ressource /customer/1 den Kunden mit der ID 1. Neben einer Identität besitzen Ressourcen eine oder mehrere Repräsentationen. Im Beispiel werden Ressourcen in JSON repräsentiert und in diesem Format zwischen Client und Server übertragen.

Beziehungen zwischen Ressourcen werden über Links beschrieben. Für HTML-repräsentierte Ressourcen wird häufig das HTML-Element <link> genutzt, während in JSON-Repräsentationen verlinkte Ressourcen als Listen von IDs beschrieben werden.

Gopher ante portas: Go hält Einzug auf JAXenter!

Wenige (relativ) neue Programmiersprachen erfreuen sich derzeit einer so großen Belibtheit wie Go. Googles Alternative für „C“ ist immer häufiger Zentrum interessanter Anwendungen und mittlerweile im Mainstream angekommen. Grund genug, sich ein wenig eingehender mit der Sprache zu befassen. Folgende Artikel, Kolumnen und Infografiken sind Teil unserer aktuellen Go-Aktion auf JAXenter:

Happy Gophing!

Das Anfragen, Anlegen, Verändern und Löschen von Ressourcen erfolgt über die HTTP-Methoden GET, POST, PUT und DELETE, im REST-Kontext auch als HTTP-Verben bezeichnet. So liefert GET /customers/1/orders/9 die Bestellung mit der ID 9, während DELETE /customers/1/orders/9 diese Bestellung löscht.

Das Shopbeispiel ist relativ einfach gehalten und kann entgegen gängigen Trends durchaus als monolithisches API entwickelt werden. Aus didaktischen Gründen entscheiden wir uns dennoch für eine Microservices-Architektur, sodass wir den Artikelfokus leichter auf den Catalog-Teil des API legen können.

Abb. 2: Microservices-Architektur des Shopsystems

Abb. 2: Microservices-Architektur des Shopsystems

Hello net/http

Dreh- und Angelpunkt der REST-Programmierung in Go ist das Package net/http. Ein einfacher HTTP-Server ist schnell programmiert und folgt dem Schema aus Listing 1.

 
func main() {
  http.HandleFunc("/catalog/products", productsHandler)
  http.ListenAndServe(":8080", nil)
}

func productsHandler(w http.ResponseWriter, r *http.Request) {
  w.Write([]byte("Schuhe, Hose, Hemd"))
}

Jedes Go-Programm besitzt eine Funktion main, die beim Programmstart aufgerufen wird. Die Funktion main registriert die Funktion productsHandler als Handler für HTTP Requests auf den URI /catalog/products. Der HTTP-Server blockiert im Aufruf http.ListenAndServe und nimmt auf Port 8080 Requests entgegen. Eingehende Requests werden an die Funktion productsHandler delegiert, die ihre Antwort durch den Aufruf der Methode Write in die HTTP Response schreibt. Für jeden Request startet die Go Runtime eine Go-Routine, die den Request im Hintergrund auf einem eigenen Thread abwickelt.

Ressourcen, Domainobjekte und ihre JSON-Repräsentation

Ressourcen werden API-intern als Domainobjekte repräsentiert. Domainobjekte werden in Go als Structs modelliert. Ein Struct ist ein zusammengesetzter Typ, der aus einer Reihe von Attributen besteht. In ihrer einfachsten Form bestehen Produkte aus einer ID und einem Namen:

type Product struct {
  Id   int
  Name string
}

Die von dem URI /catalog/products/11 referenzierte Ressource wird als Domainobjekt vom Typ Product verwaltet. Für jeden Struct-Typ stellt der Go-Compiler ein Literal zur Erzeugung von Instanzen dieses Typs zur Verfügung. Ähnlich einem Konstruktoraufruf werden dem Typnamen die Initialisierungswerte der Attribute in geschweiften Klammern übergeben. Die Angabe der Attributnamen ist optional, sofern das Struct vollständig und die Attribute in der definierten Reihenfolge initialisiert werden:

p := catalog.Product{Id: 11, Name: "Schuhe"}

Um Ressourcen zwischen API und Client auszutauschen, müssen sie repräsentiert werden. Neben HTML und XML ist JSON eines der gängigsten Repräsentationsformate im Kontext von REST. Go enthält im Package encoding/json der Standardbibliothek bereits alle notwendigen Funktionen für das JSON Marshalling und Unmarshalling von Domainobjekten. Listing 2 erweitert die Funktion productsHandler so, dass die Produkte als serialisierter JSON-String an den Client geliefert werden. Die dabei verwendete Funktion json.Marshal entstammt dem importierten Package encoding/json, das diese Funktion exportiert. Die von einem Package exportierten Funktionen sind dadurch gekennzeichnet, dass sie großgeschrieben sind.

import "encoding/json"

func productsHandler(w http.ResponseWriter, r *http.Request) {
  p := catalog.Product{Id: 11, Name: "Schuhe"}
  products := []catalog.Product{p}
  json, _ := json.Marshal(products)
  w.Write(json)
}

Der Produktkatalog wird im Beispiel als Array mit einem einzelnen Produkt repräsentiert. Die Funktion json.Marshal liefert zwei Rückgabewerte und folgt der Go-Konvention, dass der letzte Rückgabewert vom Typ error ist. Das Beispiel vernachlässigt potenzielle Serialisierungsfehler, indem statt einer Fehlervariable ein Underscore (_) eingesetzt wird, als Zeichen für den Compiler, dass uns der Fehler nicht interessiert.

Repositories

Der hartcodierte Produktkatalog aus Listing 2 wird im nächsten Schritt durch eine Datenbank ersetzt. Die Handler-Funktion erhält über das Interface Repository Zugriff auf die Datenbank, das die eigentliche Implementierung verbirgt. Interfaces in Go sind Typen, die eine Reihe von Methoden deklarieren:

 
type Repository interface {
  AddProduct(p Product)
  AllProducts() []Product
}

Interfaces sind nicht instanziierbar. Sie dienen ausschließlich als Schnittstellenbeschreibung und können überall dort verwendet werden, wo sonst „normale“ Typen eingesetzt werden. Ein Typ implementiert ein Interface, wenn er alle Methoden des Interface implementiert. Eine explizite Kennzeichnung des das Interface implementierenden Typs (wie z. B. implements Repository in Java) ist nicht notwendig. Eine Methode ist eine auf einem Typ operierende Funktion, deren Signatur dadurch gekennzeichnet ist, dass dem Funktionsnamen der zugehörige Typ vorangestellt wird (Listing 3).

type DefaultRepository struct {
  products []Product
}

func (r *DefaultRepository) AddProduct(p Product) {
  r.products = append(r.products, p)
}

func (r *DefaultRepository) AllProducts() []Product {
  return r.products
}

Exkurs zu Pointer Receiver: DefaultRepository implementiert Repository für sogenannte Pointer Receiver, die durch einen dem Typ vorangestellten Stern gekennzeichnet sind. Ein Pointer ist ein Verweis auf eine Stelle im Speicher, an der ein Wert, z. B. eine Instanz von DefaultRepository, gespeichert ist. Ein Pointer Receiver ist ein Zeiger auf das an die Methode gebundene Objekt. Oder einfacher formuliert: Die auf einem Pointer Receiver aufgerufene Methode kann die Instanz verändern, auf der sie aufgerufen wird, da die Methode die Instanz „by reference“ und nicht „by value“ übergeben bekommt. Die Reference-Semantik ist für DefaultRepository zwingend erforderlich, da die Methode AddProduct das gebundene Objekt verändern muss.

Der Typ DefaultRepository erfüllt das Interface Repository, da er beide vom Interface deklarierten Methoden implementiert. Die Trennung von Interface und konkreten Typen ermöglicht das Schreiben von Funktionen, die als Parameter Interfaces akzeptieren, sich aber nicht weiter für deren konkrete Implementierung interessieren.

func foo(r catalog.Repository) 
  products := r.AllProducts()
  ... 
}

r := DefaultRepository{}
foo(&r)

mySql := MySqlRepository{}
foo(&mySql)

Im Beispiel aus Listing 4 erhält foo ein Repository, das als Interface übergeben wird. Der Funktion ist die konkrete Implementierung des Interface egal, sodass foo für alle Typen funktioniert, die Repository implementieren – im Beispiel DefaultRepository und MySqlRepository.

Wir nutzen dieses Konzept und bauen die Funktion productsHandler so um, dass sie auf catalog.Repository arbeitet. Hierbei gilt es zunächst, ein Problem zu umschiffen: Die Signatur des HTTP Handlers muss immer dem Write/Request-Muster entsprechen, das heißt das Repository kann nicht einfach als dritter Parameter dem Handler übergeben werden. Der Schlüssel zur Lösung liegt in der Verwendung einer Closure. Eine Closure ist ein ausführbarer Codeblock, z. B. eine Funktion, die Zugriff auf ihren Erstellungskontext hat und beim Aufruf darauf zugreifen kann:

func MakeProductsHandler(repository catalog.Repository) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    products := repository.AllProducts()
    ...
  }
}

Die Funktion MakeProductsHandler aus dem Beispiel oben erzeugt den ursprünglichen HTTP Handler als Closure, die statt des hartcodierten Product-Arrays das an die Make-Funktion übergebene Repository nutzt. Hieraus resultieren zwei Vorteile: Zum einen wird der HTTP Handler einfacher und muss sich nicht mehr um die Verwaltung der Produkte kümmern. Und zum anderen kennt der HTTP Handler die konkrete Implementierung des Repositorys nicht, sodass diese austauschbar wird, was die Testbarkeit des API verbessert (siehe dazu Abschnitt „Testing“).

Zum Abschluss wird die main-Funktion so umgebaut, dass zunächst ein DefaultRepository instanziiert und der HTTP Handler über die neue Funktion MakeProductsHandler als Request Handler registriert wird (Listing 5).

 
func main() {
  r := catalog.DefaultRepository{}
  http.HandleFunc("/catalog/products", handler.MakeProductsHandler(&r))
  http.ListenAndServe(":8080", nil)
}

Die Umstellung des API auf ein MySQL oder MongoDB Repository ist einfach und beschränkt sich auf den Austausch der ersten Zeile:

func main() {
  r := catalog.MySqlRepository{}
  ...
}

Die Sources des in diesem Artikel verwendeten Beispiels enthalten eine exemplarische Implementierung des MySqlRepositories.

Routing

Routing bezeichnet das Mapping von URIs auf Handler-Funktionen. Das Package net/http beinhaltet den Typ http.ServerMux, dessen Aufgabe das Matching von URI-Mustern eingehender HTTP Requests auf registrierte Handler ist. ServerMux implementiert das Interface http.Handler und wird als zweiter Parameter an die Serverstartfunktion http.ListenAndServe übergeben. In den bisherigen Beispielen wurde dieser Parameter auf nil gesetzt, sodass der DefaultServerMux der Standardbibliothek verwendet wurde (Listing 5).

Unit-Tests in Go folgen dem aus anderen Programmiersprachen bekannten Muster: Set-up, Aufruf der zu testenden Funktion, Überprüfung der Erwartungen.

Neben dem Standardmultiplexer kommt vermehrt die Multiplexerimplementierung des Gorilla-Projekts zum Einsatz, die insbesondere die Implementierung dynamischer Routen vereinfacht [2]. Dynamische URIs werden als dynamische Routen der Form /catalog/product/{id} an einem zuvor instanziierten Router registriert. Der Gorilla-Router implementiert das http.Handler-Interface und kann anstelle von http.ServerMux als zweiter Parameter an http.ListenAndServe übergeben werden. Das folgende Beispiel implementiert eine GET-Route für den Zugriff auf ein einzelnes Produkt. Die Route erwartet die Produkt-ID als dynamische URI-Komponente, gekennzeichnet durch die Verwendung geschweifter Klammern.

r := mux.NewRouter()
r.HandleFunc("/catalog/products/{id}", MakeGetProductHandler(&repository))
http.ListenAndServe(":8080", r)

HTTP Handler können über die Gorilla-Funktion mux.Vars auf variable Pfadkomponenten des URI zugreifen, z. B. wie in Listing 6 auf die ID des Product-URI.

func(w http.ResponseWriter, r *http.Request) {
  vars := mux.Vars(r)
    id, err := strconv.Atoi(vars["id"])
    if err != nil {
      w.WriteHeader(http.StatusBadRequest)
      return
    }
  if p, ok := repository.ProductById(id); ok {
    ...
  }    
}

Das Beispiel liest die in der Route enthaltenen Variablen in eine lokale Map vars und greift anschließend über den bei der URI-Registrierung verwendeten Key „id“ auf die angefragte Produkt-ID zu. Ähnlich einfach ist die Verwendung von Query-Parametern.

r := mux.NewRouter()
  r.HandleFunc("/catalog/products", MakeQueryProductsHandler(&repository)).Queries("category", "{category:[a-z]+}")

Der Aufruf registriert einen Handler für Suchanfragen nach Produktkategorie. Die Auswertung der Query-Parameter erfolgt im Handler über die im Request enthaltene URL-Methode Query, die eine Map mit den Query-Parametern des Requests liefert (Listing 7).

func(w http.ResponseWriter, r *http.Request) {
  query := r.URL.Query()
    if q, ok := query["category"]; ok {
      products := repository.FindProducts(q[0])
      ...
    }
}

Testing

Unit-Tests in Go folgen dem aus anderen Programmiersprachen bekannten Muster: Set-up, Aufruf der zu testenden Funktion, Überprüfung der Erwartungen. Ein einfaches Beispiel ist ein Test für das Hinzufügen von Produkten zum DefaultRepository (Listing 8).

func TestAddProduct(t *testing.T) {
  r := DefaultRepository{}

  p := Product{Name: "Schuhe"}
  r.AddProduct(p)

  if len(r.AllProducts()) != 1 {
    t.Fatalf("wanted %d, got %d", 1, len(r.AllProducts()))
  }
}

Der Test verwendet das Package testing der Go-Standardbibliothek. Zentraler Typ des Packages ist testing.T, der Methoden zum Management des Test-States und Loggings implementiert. Jede Testfunktion beginnt mit Test und erwartet einen Pointer auf testing.T als einzigen Parameter. Funktionen mit dieser Signatur werden vom Go-Kommandozeilentool „go“ erkannt und beim Aufruf von go test ausgeführt.

Spannender als das Testen von Repositories ist das Testen des HTTP-Servers. Das Package httptest beinhaltet den Typ ResponseRecorder, der die Überprüfung einer HTTP Response nach Ausführung des Requests ermöglicht.

func TestGetAllProducts(t *testing.T) {
  // Setup
    repositoryMock := catalog.DefaultRepository{}
    repositoryMock.AddProduct(catalog.Product{Id: 1, Name: "Schuhe"})
    router := NewRouter(&repositoryMock)

    // When: GET /catalog/products is called
    req, _ := http.NewRequest("GET", "/catalog/products", nil)
    rr := httptest.NewRecorder()
    router.ServeHTTP(rr, req)

    // Then: status is 200
    assert.Equal(t, http.StatusOK, rr.Code)

    // And: Body contains 1 product
    expected := 
      `[{"Id":1,"Name":"Schuhe","Description":"","Category":"","Price":0}]`
    assert.Equal(t, expected, rr.Body.String())
}

Der Test aus Listing 9 lagert die Instanziierung des Routers in die neue Funktion NewRouter aus, der ein Mock Repository übergeben bekommt und dies an die zu registrierenden HTTP Handler weiterreicht. Der Test demonstriert, wie einzelne Requests durch Aufruf der Router-Methode ServeHTTP ausgelöst werden. Der Methode ServeHTTP wird im ersten Parameter ein ResponseWriter und im zweiten Parameter der zuvor instanziierte Request übergeben. Der Schlüssel liegt im Verständnis des Interface http.ResponseWriter, das von jeder HTTP-Handler-Funktion als erster Parameter erwartet wird. Der Typ ResponseRecorder implementiert dieses Interface und kann anstelle der richtigen Response eingesetzt werden. Auf diese Weise kann der Request ganz normal ausgeführt werden und seine Antwort in die Response schreiben. Der Test liest anschließend die Response und gleicht sie mit den Erwartungen ab: Wird der korrekte Statuscode geliefert, und enthält der Response Body das erwartete Produkt im JSON-Format?

Der Test macht zudem die sinnvolle Verwendung von Interfaces deutlich. DefaultRepository ist ein In-Memory Repository, das das Interface Repository implementiert, die enthaltenen Produkte aber komplett von außen steuerbar lässt. Da die Funktion NewRouter das Interface anstatt einer konkreten Repository-Implementierung erwartet, kann der Test genau festlegen, welche Produkte die Requests liefern sollen. DefaultRepository wird zu einem Mock und der Test unabhängig von externen Systemen, in diesem Fall einer Datenbank.

Zufügen, aktualisieren und löschen

Neue Produkte werden dem Katalog per POST /catalog/products zugefügt. Die Daten des Produkts werden JSON-encodiert im Request Body übertragen. Im Erfolgsfall liefert der Server den Statuscode „201 CREATED“ sowie den URI des neu erzeugten Produkts im Location-Header der Response. Wir beginnen diesmal mit dem Test (Listing 10).

func TestAddProduct(t *testing.T) {
  json, _ := json.Marshal(catalog.Product{Name: "Jacke"})
  body := bytes.NewBuffer(json)
  req, _ := http.NewRequest("POST", "/catalog/products", body)

  rr := httptest.NewRecorder()
  router.ServeHTTP(rr, req)

  assert.Equal(t, http.StatusCreated, rr.Code)
  assert.Regexp(t, "/catalog/products/[0-9]+", rr.Header()["Location"][0])
  assert.True(t, repository.Contains("Jacke"))
}

Der Test funktioniert ähnlich dem GetAllProducts-Test. Der Request wird diesmal um einen Body erweitert, der das anzulegende Produkt JSON-serialisiert enthält. Im Assert-Teil des Tests ist außerdem zu sehen, wie der Response-Header auf die korrekte Location der neu erzeugten Ressource überprüft wird.

Die Implementierung des zugehörigen HTTP Handlers in Listing 11 birgt wenig Besonderheiten: Der Request Body wird gelesen, in ein Produktobjekt deserialisiert, das dem Repository zugefügt wird. Abschließend wird der URI des neuen Produkts in den Response-Header geschrieben. Der Code folgt dem in Go relativ häufig anzutreffenden „Return early“-Muster: Statt der Verwendung verschachtelter if/else-Kaskaden wird die Funktion im Fehlerfall möglichst früh verlassen.

func MakeAddProductHandler(repository catalog.Repository) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    var p catalog.Product
    if err = json.Unmarshal(body, &p); err != nil {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    id := repository.AddProduct(p)
    w.Header().Set("Location", fmt.Sprintf("%s/%d", r.URL.String(), id))
    w.WriteHeader(http.StatusCreated)
  }
}

Abschließend muss der neue POST Handler beim Router registriert werden. Da das Auflisten und Zufügen von Produkten denselben Basis-URL haben, werden die jeweiligen Handler durch den Aufruf von Methods() an die expliziten HTTP-Verben gebunden (Listing 12).

r := mux.NewRouter()
r.HandleFunc("/catalog/products",
  MakeProductsHandler(repository)).Methods("GET")
r.HandleFunc("/catalog/products",
  MakeAddProductHandler(repository)).Methods("POST")
r.HandleFunc("/catalog/products/{id}", 
  MakeUpdateProductHandler(repository)).Methods("PUT")
r.HandleFunc("/catalog/products/{id}", 
  MakeDeleteProductHandler(repository)).Methods("DELETE")

Die Implementierung von MakeUpdateProductHandler und MakeDeleteProductHandler findet sich in dem diesem Artikel zugehörigen Git Repository.

Authentifizierung als Middleware

Nachdem der Artikel zentrale Konzepte eines RESTful API wie Ressourcen, URIs, HTTP-Verben und Routing eingeführt hat, wird das erstellte API abschließend um eine Middleware zum Schutz vor unberechtigtem Zugriff erweitert. Der Begriff Middleware bezeichnet im Go-Kontext Code, der vor der eigentlichen Businesslogik ausgeführt wird. Dies kann z. B. Logging, die Filterung der Request-Parameter oder die Manipulation der Response vor der Auslieferung sein.

Wir verwenden eine Middleware zur Absicherung des API mithilfe eines JSON-Web-Tokens. JWTs sind signiert und können eine userdenierte Payload wie Benutzername oder Rollen enthalten. Zur Absicherung des API muss der im HTTP-Header enthaltene Token vor jedem Request validiert werden. Da diese Überprüfung ein zusätzlicher Aspekt ist, wird die Überprüfung nicht als Teil der Request-Bearbeitung implementiert, sondern in eine JWT-Middleware ausgelagert (Listing 13).

func jwtMiddleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    header := r.Header.Get("Authorization")
    jwt := strings.Split(header, " ")[1]
      if validate(jwt) {
        next.ServeHTTP(w, r)
      } else {
        w.WriteHeader(http.StatusUnauthorized)
      }
  })
}

Die JWT-Middleware wird eingebaut, indem die ursprünglichen MakeXXXHandler-Funktionen vor der Registrierung im Router mithilfe der Funktion jwtMiddleware gewrappt und das Ergebnis als HTTP Handler registriert wird.

r.HandleFunc("/catalog/products", jwtMiddleware(MakeProductsHandler(&repo)))

Token-Validerung und Request-Bearbeitung wissen nichts voneinander, sind lose gekoppelt und können unabhängig implementiert werden. Das Mid dle warekonzept ist außerdem offen und kann durch das Dazwischenschalten weiterer Middlewarefunktionen erweitert werden, ohne dass die existierenden Funktionen angepasst werden müssen.

Fazit

Go besticht durch Einfachheit. Sowohl Sprache als auch Tools sind einfach erlern- und benutzbar. Standards und Konventionen erzwingen gut lesbaren Code, was die Sprache zu einer produktiven Sprache für Teams macht.

Go ist auch eine Sprache für die Cloud. Eine im März 2017 veröffentlichte Umfrage hat ergeben, dass die Mehrheit der Entwickler Go für die Entwicklung von Web Services einsetzt. Die Standard-Library liefert bereits so gute HTTP-/JSON-Unterstützung, dass der Einsatz eines zusätzlichen Webframeworks entfallen kann. Die entwickelten Services sind statisch gelinkt, schnell und robust und lassen sich durch einfaches Kopieren auf die Zielmaschine deployen, ohne dass zuvor JVM, RubyGems, Java-JARs oder ähnliche Voraussetzungen installiert werden müssen.

Verwandte Themen:

Geschrieben von
Ralf Wirdemann
Ralf Wirdemann
Ralf Wirdemann ist Softwarecoach und Gründer der kommmitment GmbH und Co. KG. Guter Sourcecode ist ihm genauso wichtig wie gute User Stories, ein fürs Team passender Entwicklungsprozess oder eine funktionierende Deployment-Pipeline.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: