Golang webservice 程式開發完整案例

目錄架構

main.go
handlers
------ handlers.go
------ home.go
------ healthz.go
------ readyz.go
------ handlers_test.go  // 測試程式
------ home_test.go
version
------version.go

main.go

package main

import (
    "fmt"
    "net/http"
    "log"
)

func main() {
    log.Print("Starting the service...")

    http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) {
        fmt.Fprint(w, "Hello! Your request was processed.")
    },
    )

    log.Print("The service is ready to listen and serve.")
    log.Fatal(http.ListenAndServe(":8000", nil))
}

若是http服務建議使用gloglogrus當作log服務。

增加roter 服務

  • 使用與net/http相容性較高的 gorilla/mux
  • handlers/handlers.go
package handlers

import (
    "github.com/gorilla/mux"
)

// Router register necessary routes and returns an instance of a router.
func Router() *mux.Router {
    r := mux.NewRouter()
    r.HandleFunc("/home", home).Methods("GET")
    return r
}
  • handlers/home.go
package handlers

import (
    "fmt"
    "net/http"
)

// home is a simple HTTP handler function which writes a response.
func home(w http.ResponseWriter, _ *http.Request) {
    fmt.Fprint(w, "Hello! Your request was processed.")
}
  • 有了router之後,就可以簡化main.go的內容
package main

import (
    "log"
    "net/http"

    "github.com/justjii/handlers"
)

// How to try it: go run main.go
func main() {
    log.Print("Starting the service...")
    router := handlers.Router()
    log.Print("The service is ready to listen and serve.")
    log.Fatal(http.ListenAndServe(":8000", router))
}

撰寫測試程式

  • handlers/handlers_test.go
package handlers

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestRouter(t *testing.T) {
    r := Router()
    ts := httptest.NewServer(r)
    defer ts.Close()

    res, err := http.Get(ts.URL + "/home")
    if err != nil {
        t.Fatal(err)
    }
    if res.StatusCode != http.StatusOK {
        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusOK)
    }

    res, err = http.Post(ts.URL+"/home", "text/plain", nil)
    if err != nil {
        t.Fatal(err)
    }
    if res.StatusCode != http.StatusMethodNotAllowed {
        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusMethodNotAllowed)
    }

    res, err = http.Get(ts.URL + "/not-exists")
    if err != nil {
        t.Fatal(err)
    }
    if res.StatusCode != http.StatusNotFound {
        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusNotFound)
    }
}
  • handlers/home_test.go
package handlers

import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHome(t *testing.T) {
    w := httptest.NewRecorder()
    home(w, nil)

    resp := w.Result()
    if have, want := resp.StatusCode, http.StatusOK; have != want {
        t.Errorf("Status code is wrong. Have: %d, want: %d.", have, want)
    }

    greeting, err := ioutil.ReadAll(resp.Body)
    resp.Body.Close()
    if err != nil {
        t.Fatal(err)
    }
    if have, want := string(greeting), "Hello! Your request was processed."; have != want {
        t.Errorf("The greeting is wrong. Have: %s, want: %s.", have, want)
    }
}
  • 執行測試程式
$ go test -v ./...
?       github.com/justjii      [no test files]
=== RUN   TestRouter
--- PASS: TestRouter (0.00s)
=== RUN   TestHome
--- PASS: TestHome (0.00s)
PASS
ok      github.com/justjii/handlers     0.018s

環境變數設定

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/justjii/handlers"
)

// How to try it: PORT=8000 go run main.go
func main() {
    log.Print("Starting the service...")

    port := os.Getenv("PORT")  // port 改由環境變數設定
    if port == "" {
        log.Fatal("Port is not set.")
    }

    r := handlers.Router()
    log.Print("The service is ready to listen and serve.")
    log.Fatal(http.ListenAndServe(":"+port, r))
}

透過makefile簡化相關步驟

  • Makefile
APP?=advent
PORT?=8000

clean:
    rm -f ${APP}

build: clean
    go build -o ${APP}

run: build
    PORT=${PORT} ./${APP}

test:
    go test -v -race ./...

版本

為了了解目前正在運行的程式版本,最好能夠在程式中設定相關功能,讓使用者能隨時知道目前使用程式版本。

  • version/version.go
package version

var (
    // BuildTime is a time label of the moment when the binary was built
    BuildTime = "unset"
    // Commit is a last commit hash at the moment when the binary was built
    Commit = "unset"
    // Release is a semantic version of current build
    Release = "unset"
)
  • handlers/home.go
package handlers

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

    "github.com/rumyantseva/advent-2017/version"
)

// home is a simple HTTP handler function which writes a response.
func home(w http.ResponseWriter, _ *http.Request) {
    info := struct {
        BuildTime string `json:"buildTime"`
        Commit    string `json:"commit"`
        Release   string `json:"release"`
    }{
        version.BuildTime, version.Commit, version.Release,
    }

    body, err := json.Marshal(info)
    if err != nil {
        log.Printf("Could not encode info data: %v", err)
        http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(body)
}
  • main.go
...
func main() {
    log.Printf(
        "Starting the service...\ncommit: %s, build time: %s, release: %s",
        version.Commit, version.BuildTime, version.Release,
    )
...
}
  • makefile 異動
PROJECT?=github.com/rumyantseva/advent-2017
RELEASE?=0.0.1
COMMIT?=$(shell git rev-parse --short HEAD)
BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S')

.....

build: clean
    go build \
        -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \
        -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \
        -o ${APP}

Health checks

  • handlers/healthz.go
// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {
    w.WriteHeader(http.StatusOK)
}
  • handlers/readyz.go
// readyz is a readiness probe.
func readyz(isReady *atomic.Value) http.HandlerFunc {
    return func(w http.ResponseWriter, _ *http.Request) {
        if isReady == nil || !isReady.Load().(bool) {
            http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    }
}
  • handlers.go
func Router(buildTime, commit, release string) *mux.Router {
    isReady := &atomic.Value{}
    isReady.Store(false)
    go func() {
        log.Printf("Readyz probe is negative by default...")
        time.Sleep(10 * time.Second)
        isReady.Store(true)
        log.Printf("Readyz probe is positive.")
    }()

    r := mux.NewRouter()
    r.HandleFunc("/home", home(buildTime, commit, release)).Methods("GET")
    r.HandleFunc("/healthz", healthz)
    r.HandleFunc("/readyz", readyz(isReady))
    return r
}

Graceful shutdown

  • main.go
func main() {
    ...
    r := handlers.Router(version.BuildTime, version.Commit, version.Release)

    interrupt := make(chan os.Signal, 1)
    signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)

    srv := &http.Server{
        Addr:    ":" + port,
        Handler: r,
    }
    go func() {
        log.Fatal(srv.ListenAndServe())
    }()
    log.Print("The service is ready to listen and serve.")

    killSignal := <-interrupt
    switch killSignal {
    case os.Interrupt:
        log.Print("Got SIGINT...")
    case syscall.SIGTERM:
        log.Print("Got SIGTERM...")
    }

    log.Print("The service is shutting down...")
    srv.Shutdown(context.Background())
    log.Print("Done")
}

Docker 化程式

  • Dockerfile
FROM scratch

ENV PORT 8000
EXPOSE $PORT

COPY advent /
CMD ["/advent"]
  • makefile
...

GOOS?=linux
GOARCH?=amd64

...

build: clean
    CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build \
        -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \
        -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \
        -o ${APP}

container: build
    docker build -t $(APP):$(RELEASE) .

run: container
    docker stop $(APP):$(RELEASE) || true && docker rm $(APP):$(RELEASE) || true
    docker run --name ${APP} -p ${PORT}:${PORT} --rm \
        -e "PORT=${PORT}" \
        $(APP):$(RELEASE)

...

函數庫版本控制 dep

$ dep init
  Using ^1.6.0 as constraint for direct dep github.com/gorilla/mux
  Locking in v1.6.0 (7f08801) for direct dep github.com/gorilla/mux
  Locking in v1.1 (1ea2538) for transitive dep github.com/gorilla/context

dep init 會建立vendor目錄,同時在該目錄下建立:Gopkg.toml 、Gopkg.lock 兩個檔案。

Kubernetes

  • makefile
CONTAINER_IMAGE?=docker.io/webdeva/${APP}

...

container: build
    docker build -t $(CONTAINER_IMAGE):$(RELEASE) .

...

push: container
    docker push $(CONTAINER_IMAGE):$(RELEASE)

記得先使用docker login登入系統後,才能使用make push命令

  • deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ .ServiceName }}
  labels:
    app: {{ .ServiceName }}
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 50%
      maxSurge: 1
  template:
    metadata:
      labels:
        app: {{ .ServiceName }}
    spec:
      containers:
      - name: {{ .ServiceName }}
        image: docker.io/webdeva/{{ .ServiceName }}:{{ .Release }}
        imagePullPolicy: Always
        ports:
        - containerPort: 8000
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8000
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8000
        resources:
          limits:
            cpu: 10m
            memory: 30Mi
          requests:
            cpu: 10m
            memory: 30Mi
      terminationGracePeriodSeconds: 30
  • service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .ServiceName }}
  labels:
    app: {{ .ServiceName }}
spec:
  ports:
  - port: 80
    targetPort: 8000
    protocol: TCP
    name: http
  selector:
    app: {{ .ServiceName }}

  • ingress.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
    ingress.kubernetes.io/rewrite-target: /
  labels:
    app: {{ .ServiceName }}
  name: {{ .ServiceName }}
spec:
  backend:
    serviceName: {{ .ServiceName }}
    servicePort: 80
  rules:
  - host: advent.test
    http:
      paths:
      - path: /
        backend:
          serviceName: {{ .ServiceName }}
          servicePort: 80
  • 啟動 kubenetes
minikube start
minikube addons enable ingress
kubectl config use-context minikube
  • Makefile
minikube: push
    for t in $(shell find ./kubernetes/advent -type f -name "*.yaml"); do \
        cat $$t | \
            gsed -E "s/\{\{(\s*)\.Release(\s*)\}\}/$(RELEASE)/g" | \
            gsed -E "s/\{\{(\s*)\.ServiceName(\s*)\}\}/$(APP)/g"; \
        echo ---; \
    done > tmp.yaml
    kubectl apply -f tmp.yaml
  • 測試部屬狀況
kubectl get deployment
kubectl get service
kubectl get ingress
  • 設定網域:advent.test 到 /etc/host
echo "$(minikube ip) advent.test" | sudo tee -a /etc/hosts

參考資料