• yundream
  • 2019-05-22 15:49:43
  • 2018-09-11 03:18:38
  • 77033

Contents

MSA환경에서의 Go 웹 애플리케이션 테스트 경험

소개

  • 이름 : 윤상배
  • 닉네임 : yundream
  • 사이트 : www.joinc.co.kr
  • NaverLabs
  • 클라우드(주로 AWS) 환경에서 서비스 백앤드를 개발.

개발 환경

MSA

cut -f 3 -d, list.txt | awk '{print $1}' | myscript.sh | sort | uniq | gnuplot
  • 작은 시스템 큰 구현 -> 유연함 -> 어떤 데이터가 들어올지 예측 할 수 없음
  • 필요한 일만 하는 작은 프로세스의 연결
  • 표준입력 & 표준출력 -> REST & JSON
  • 파이프 -> HTTP over TCP/IP
  • 단일 운영체제 -> 분산운영체제

왜 ?

  • 인터넷 비지니스의 확장
  • 규모의 성장, 복잡도의 증가, 다양한 고객이 섞임 -> 예측 할 수 없음.
  • 예측 불가능한 시장에서의 생존 모델 ? 변화에 적응
  • 작은 시스템 큰 구현 -> 린,애자일,클라우드 -> DevOps -> ServerLess
  • 클라우드 -> 단일 운영체제로 보임 -> 유닉스의 파이프 모델을 적용해보자. -> 괜찮을 것 같네

좋은 MSA 모델을 만들어보자.

  • 파이프 모델의 성공원인을 살펴보자.
  • 단순함
  • Stateless
  • 표준 인터페이스 : 파이프, 표준입력과 출력, 파일
  • man page
  • 테스트

API로 커뮤니케이션 하자

  • 코드는 조직의 모습을 따른다.
  • MSA는 느슨하게 연결된 분산된 조직을 구성한다.
  • 분산되면 커뮤니케이션 비용이 늘어난다.
  • API 문서로만 커뮤니케이션 한다. -> 파이프 모델에서 복잡도를 man page로 줄이는 것 처럼.

swagger

RESTful Web Service를 위한 디자인, 빌드, 문서화를 도와주는 툴
swagger: '2.0'
info:
  version: 0.1.0
  title: Simple To Do List API
securityDefinitions:
  key:
    type: apiKey
    in: header
    name: x-todolist-token
security:
  - key: []
consumes:
  - application/io.swagger.examples.todo-list.v1+json
produces:
  - application/io.swagger.examples.todo-list.v1+json
schemes:
  - http
  - https
x-schemes:
  - unix
paths:
  /:
    get:
      tags: ["todos"]
      operationId: find
      parameters:
        - name: limit
          in: formData
          type: integer
          format: int32
          required: true
          allowEmptyValue: true
        - name: "X-Rate-Limit"
          in: header
          type: integer
          format: int32
          required: true
        - name: tags
          in: formData
          type: array
          collectionFormat: multi
          allowEmptyValue: true
          items:
            type: integer
            format: int32
          required: true
      responses:
        '200':
          description: OK
          schema:
            type: array
            items:
              $ref: "#/definitions/item"
        default:
          description: error
          schema:
            $ref: "#/definitions/error"
    post:
      tags: ["todos"]
      operationId: addOne
      parameters:
        - name: body
          in: body
          schema:
            $ref: "#/definitions/item"
      responses:
        '201':
          description: Created
          schema:
            $ref: "#/definitions/item"
        default:
          description: error
          schema:
            $ref: "#/definitions/error"
  /{id}:
    parameters:
      - type: string
        name: id
        in: path
        required: true
    put:
      tags: ["todos"]
      operationId: updateOne
      parameters:
        - name: body
          in: body
          schema:
            $ref: "#/definitions/item"
      responses:
        '200':
          description: OK
          schema:
            $ref: "#/definitions/item"
        default:
          description: error
          schema:
            $ref: "#/definitions/error"
    delete:
      tags: ["todos"]
      operationId: destroyOne
      responses:
        '204':
          description: Deleted
        default:
          description: error
          schema:
            $ref: "#/definitions/error"
definitions:
  item:
    type: object
    required:
      - description
    properties:
      id:
        type: integer
        format: int64
        readOnly: true
      description:
        type: string
        minLength: 1
      completed:
        type: boolean
  error:
    type: object
    required:
      - message
    properties:
      code:
        type: integer
        format: int64
      message:
        type: string

Swagger 문서 관리

  • git에 함께 배포
  • makefile로 관리
  • 개발자는 로컬에서 바로 확인

문서로 부터 코드 생성

  • 문서만 만들면 코드가 만들어지네 ?
  • API 문서와 코드가 항상 일치하겠군 ?
  • 장점 : API와 같이 배포하면 끝. API와 문서가 일치한다.
  • 문제점 : 코드 & 프레임워크 선택의 유연성이 떨어진다. 익숙해질 수 있지만 싫어하는 개발자는 싫어한다.

그냥 문서를 분리

  • 장점 : 코드의 유연성 확보
  • 문제점 : API 문서대로 작동하지 않을 확률이 있음.
  • 이 방식을 선택했음
  • API 문서로 커뮤니케이션 하면서, 점진적으로 문제점이 개선 될 것으로 기대(핸들러 단순)

REST API 테스트

  • MSA에서 각 자원은 REST 형태로 접근하는 경우가 많다.
  • 자원이 분산되고, 자원의 버전이 따로 관리 되면서 복잡도를 낮추기 위한 방법이 필요.
  • 테스트 & 테스트 & 테스트

테스트를 하는 이유

  • 점진적으로 품질 개선
  • 변화에 적응
  • TDD ?

첫번째 안 : http client

  • curl 을 포함한 스크립트
  • 서버를 실행 한후 go http client 패키지로 테스트
  • 커버리지가 나오지 않음
  • 툴들과 커플링 됨
  • 연동 테스트 이슈

두번째 안 : net/http/httptest

  • 테스트 웹 서버를 실행
  • 커버리지
  • go 테스트 프레임워크로 통합
 --/---- main.go
     |
     +-- handler --+--- handler_middleware.go
                   |
                   +--- handler_todo.go
                   |
                   +--- handler_user.go
                   |
                   +--- handler_test.go

// handler 초기화
type Handler struct {
   DB      *sql.DB
   Redis   *redis.Client
   Logger  *logging.Logger
   router  *mux.Router     // gorilla.mux
}

func (h *handler) Init() error {
    // DB, Redis, Logger 설정..
}

package handler
import (
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

var (
    testServer string
)

func Test_Init(t *testing.T) {
    /*
    handler := Handler{
      Redis:  redisCli,
      DB:     mysqlCli, 
    }
    */
    handler.Init() 
    server := httptest.NewServer(handlers.CombinedLoggingHandler(logfile, http.DefaultServeMux))
    testServer = server.URL  // ex. http://localhost:8193
}

func Test_Todo(t *testing.T) {
   r, b, err := DoGet(testServer+"/todo/ping", Header, Body)
   assert.Nil(t, err)
   assert.Equal(t, http.StatusOK, res.StatusCode)
   json.Unmarshal(b, &response)
   assert.Equal(t, "pong", response.Text)
}

백엔드 연동 테스트

  • 백앤드 서버들을 실제로 실행해서 연동한다. MySQL, REDIS, MongoDB, DynamoDB, S3
  • 테스트 코드는 백엔드를 초기화(테스트 데이터 추가, 삭제, 업데이트 등)하는 코드들도 함께 포함한다.
func Test_Init(t *testing.T) {
    db, err := sql.Open("mysql", "root:gkwlak@tcp(semina:3306)/semina")
    if err != nil {
        panic(err)
    }

    _, err = db.Exec("DELETE FROM todo")
    if err != nil {
        panic(err)
    }
    _, err = db.Exec("DELETE FROM user")
    if err != nil {
        panic(err)
    }
    _, err = db.Exec("DELETE FROM job")
    if err != nil {
        panic(err)
    }

    _, err = db.Exec("DELETE FROM schedule")
    if err != nil {
        panic(err)
    }

    testHeader = []map[string]string{
        {
            "X-SERVICE-Name":     "testService-01",
            "X-Service-Base-Version": "1.0",
            "X-Service-Version":      "1.2",
            "X-TOKEN":            "validtoken",
            "Content-Type":       "text/plain",
        },
        {
            "X-SERVICE-Name":     "testService-01",
            "X-Service-Base-Version": "1.0",
            "X-Service-Version":      "1.2",
            "X-TOKEN":            "validtoken",
            "Content-Type":       "text/plain",
        },
        {
            "X-SERVICE-Name":     "testService-01",
            "X-Service-Base-Version": "1.1",
            "X-Service-Version":      "1.2",
            "X-TOKEN":            "invalidtoken",
            "Content-Type":       "application/json",
        },
        {
            "X-SERVICE-Name":     "testService-02",
            "X-Service-Base-Version": "1.1",
            "X-Service-Version":      "1.3",
            "Content-Type":       "application/json
        },
        {
            "X-Service-Base-Version": "1.1",
            "X-Service-Version":      "1.3",
            "Content-Type":       "application/json
        },

외부 API 서버

  • 내부 인증 서버와 연동을 해야 했다.
  • API 스펙을 구현한 서버를 만들었다.
  • 개발망에 연결해서 테스트해도 되잖아요 ? : 쉽지 않음
    • 로컬 개발, 연동망, 스테이징이 훌륭하게 통합된 회사라면 가능 할지도.
    • 다른 개발 백엔드에 영향을 주면 안됨. 도커 띄워야 하나 ? 가능하겠지만 복잡
    • 그냥 쉽게 가자
  • 예제
 외부 API 서버 연동

  • 프로젝트 디렉토리에 외부연동 API 규격을 구현한 패키지와 코드를 만들고
  • httptest로 통합
package myauth
type Handler struct {
}

func (h *Handler) Init() *mux.Router {
    h.router = mux.NewRouter()
    h.router.HandleFunc("/oauth2.0/token", h.TokenValidation).Methods("GET")
    h.router.HandleFunc("/user/agreement", h.TermsGet).Methods("GET")
    h.router.HandleFunc("/user/agreement", h.TermsUpdate).Methods("PUT")
    h.router.HandleFunc("/user/agreement", h.TermsDelete).Methods("DELETE")
    h.router.HandleFunc("/ping", h.Ping).Methods("GET")
    return h.router
}


func (h *Handler) TokenValidation(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("Authorization")
    if len(token) < 10 {
        SendMessage(w, http.StatusNonAuthoritativeInfo, "FAIL")
        return
    }
    switch token {
    case "Bearer validToken-user01":
        profile := Profile{
            ResponseEmail:         "yundream@gmail.com",
            ResponseNickname:      "yundream",
            ResponseName:          "sangbae.yun",
            ResponseGender:        "M",
            ResponseAge:           "40",
            ResponseProfile_image: "http://www.example.com/test.png",
            ResponseId:            "903890",
            ResponseBirthday:      "19740208",
        }
        response := struct {
            ResultCode string  `json:"resultcode"`
            Message    string  `json:"message"`
            Response   Profile `json:"response"`
        }{"00", "success", profile}
        SendMessage(w, http.StatusOK, response)
    case "Bearer validToken-user02":
        profile := Profile{
            ResponseEmail:         "red3018@gmail.com",
            ResponseNickname:      "red3018",
            ResponseName:          "BoBs",
            ResponseGender:        "M",
            ResponseAge:           "42",
            ResponseProfile_image: "http://www.example.com/test.png",
            ResponseId:            "18501287",
            ResponseBirthday:      "19800512",
        }
        response := struct {
            ResultCode string  `json:"resultcode"`
            Message    string  `json:"message"`
            Response   Profile `json:"response"`
        }{"00", "success", profile}
        SendMessage(w, http.StatusOK, response)
    case "Bearer invalidToken":
        }
        response := struct {
            ResultCode string  `json:"resultcode"`
            Message    string  `json:"message"`
            Response   Profile `json:"response"`
        }{"80", "fail", profile}
        SendMessage(w, http.StatusOK, response)
    default:
        SendMessage(w, http.StatusNonAuthoritativeInfo, "FAIL")
    }
}

 --/---- main.go
     |
     +-- handler --+--- handler_middleware.go
     |             |
     |             +--- handler_todo.go
     |             |
     |             +--- handler_user.go
     |             |
     |             +--- handler_test.go
     |
     +-- myauth --+--- myauth.go

packate handler
import "myauth"

authHandler := myauth.Handler{}  
r := authHandler.Init()
authServer := httptest.NewServer(handlers.CombinedLoggingHandler(logfile, r))

마무리

  • 애자일, DevOps, MSA, Serverless는 죽지 않는다.
  • API 문서관리
  • 테스트 & 테스트 & 테스트
  • Go는 이런 흐름에서 나타난 언어