mock server 實踐

孫高飛發表於2020-12-02

mock server的一般玩法

  • mock server的目的是一定程度上隔離待測系統,將依賴的其他服務mock掉後達到只測試待測系統的目的。
  • mock server攔在待測程式和依賴服務中間, 接收來自待測程式法來的請求。 使用者會在mock server中編寫規則匹配模式, 當發來的請求匹配到規則後(比如header中帶有特殊的欄位)會返回事前mock好的response。 如果沒有命中規則,則會把請求轉發給真實的服務進行處理。 這樣做的目的有二: 1. 系統過於複雜, 要完全mock所有介面成本過高,所以這種模式可以只mock核心的,耗時長的介面。 2. 普通使用者訪問系統不受影響, 因為普通使用者的header裡沒帶那些特殊欄位, 所以正常使用。 而需要mock server的特定程式在測試請求中加了特殊欄位所以可以利用mock server的能力,這樣使得一個環境可以對雙方開放, 互不影響。
  • 測試程式可以實現一套case,在連線mock server的時候可以測試,而當把mock server 下掉後依然可以執行的目的。 當然這需要對case和mock都有相應的設計。 這樣的目的實現該模組自己開發的時候先用mock server進行測試,但是後面整合時完成相應的整合測試。 當然也可以直接維護兩套case, 一套對mock server的一套對真實環境的。 看每個人的選擇

mock server的開源工具

介紹一個我喜歡用的開源mock server: https://www.mock-server.com/mock_server/running_mock_server.html
mock server的規則可支援java, json,js 3種格式。比如java風格的:

public class MockServer {
public static void main(String[] args) {

ClientAndServer server = new ClientAndServer("localhost", 8080, 1080);

server.when(
request()
.withMethod("GET")
.withPath("/hello/say")
).respond(
response()
.withBody("mock successfully")
);
server.when(
request()
.withMethod("GET")
.withPath("/test")
.withQueryStringParameters(
param("p", "1")
)
).respond(
response()
.withCookie(new Cookie("cKey", "cValue"))
.withBody("test1")
);

server.when(
request()
.withMethod("GET")
.withPath("/test")
.withQueryStringParameters(
param("p", "2")
)
).respond(
response()
.withBody("test2")
);
}

}
  • 上面new一個ClientAndServer("localhost", 8080, 1080) 就是在啟動一個mock server。 前兩個引數是真實服務的ip地址和埠號, mock server一旦發現有請求沒有命中事前定義的規則, 那麼就會轉發給真實的服務處理。 而第三個引數就是mock server自己的埠號了。
  • 之後的程式都是規則匹配了, 可以編寫規則攔截特定的cookie,header,path,引數。 命中規則後就可以返回特定的response, 並且可以設定延遲時間, 也就是可以設定延遲個幾秒再返回請求, 這個對於需要測試網路延遲場景的case比較有用, 比如我們在混沌工程中注入特定介面的延遲故障,就是這麼來的。

也可以是json格式的,比如啟動mock server的時候直接指定讀取哪個json檔案中的規則。 例如:

java -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO

[
{
"httpRequest": {
"path": "/simpleFirst"
},
"httpResponse": {
"body": "some first response"
}
},
{
"httpRequest": {
"path": "/simpleSecond"
},
"httpResponse": {
"body": "some second response"
}
}
]
  • 上面程式碼第一行是啟動命令, 在官網上下載mock server的netty包後, 可以指定規則檔案的路徑
  • 上面程式碼中的json就是規則檔案。

具體的語法和規則請大家移步官網

圖形介面

mock server本身提供了一個圖形介面來實時檢視mock server接受到請求和規則命中情況。 如下:

動態加入規則

當mock server啟動後,依然是可以通過rest API 動態往裡面加入規則的。 比如:

curl -v -X PUT "http://172.27.128.8:31234/mockserver/expectation" -d '{
"httpRequest" : {
"method" : "GET",
"path" : "/view/cart",
"queryStringParameters" : {
"cartId" : [ "055CA455-1DF7-45BB-8535-4F83E7266092" ]
},
"cookies" : {
"session" : "4930456C-C718-476F-971F-CB8E047AB349"
}
},
"httpResponse" : {
"body" : "some_response_body"
}
}'

這就會給mock server中加入了一個新的規則, 可以在mock server的UI上看到

動態加入規則的目的是在一些比較複雜的情況下, 比如mock server跟部署工具或者環境繫結在一起, 手動起停比較困難的情況下,動態加入有利於作為workround和調式mock 規則時使用。 當然mock server同樣提供一個好用的功能, 它會把你動態加入的這些規則儲存到一個檔案中。 只需要你啟動的時候加入兩個引數就可以。 比如:

java -Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json  -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO

上面的命令比之前多了兩個引數分別是-Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json 。 這兩個引數保證了當你在mocker server上動態加入規則後, 這個規則能儲存在這個檔案裡。 比如:

這種模式比較方便你除錯mock 規則。

抓取response

有些時候研發的介面文件規範很差, 甚至研發自己都不知道要mock的response長什麼樣子。 所以我們需要能抓取到實際返回的介面請求。 那麼mock server也提供了這樣一個功能。 我們可以動態的呼叫一個api。 如下:

curl -v -X PUT "http://172.27.128.8:31234/mockserver/retrieve?type=REQUEST_RESPONSES" -d '{
"path": "/grid/console",
"method": "GET"
}'

上面的程式碼是在從mock server中所有method為GET 路徑為/grid/console的請求和相應詳情。 效果如下:

在k8s中的玩法

在微服務架構中, 一次測試中可能會需要很多個mock server, 以為一個mock server只能mock一個真實的服務。 那麼如何部署這些mock server就是我這幾天在研究的。 我們的產品是部署在k8s中的, 借鑑我們在混沌工程中進行故障注入的實踐方式,這一次我同樣選擇使用side car模式向服務所在POD中注入mock server 容器。 如下:

  • 第一步先注入一個init container, init container就是pod的初始化容器, 我們注入這個初始化容器是為了使用iptables 來修改網路規則, 把原本應該傳送給真實服務的請求轉發給mock server
  • 第二部注入mock server 容器, 這個容器啟動時接管了所有的流量, 命中規則返回mock response, 沒有命中規則的話轉發給真實服務。

由於在k8s pod中的所有容器都是共享網路名稱空間的, 所以這些容器都是天然的網路互通的(用localhost就可以訪問了). 通過編寫這樣的工具,就可以做到對產品的部署方式上進行無入侵的解決方案。

實現方式

  • 語言:golang
  • 包: k8s開源的client-go
package main

import (
"encoding/json"
"flag"
"fmt"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"io/ioutil"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
yamlUtil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"os"
"strings"
"time"
)

var (
isRecover bool
kubeConfigPath string
configPath string
)

const (
secretName = "mock-server-secret"
secretFilePath = "pull-secret.yaml"
timeout = 10
initContainerName = "mock-init"
mockServerContainerName = "mock-server"
mockServerContainerImage = "reg.4paradigm.com/qa/mock-server"
)

func init() {
log.SetOutput(os.Stdout)
log.SetLevel(log.DebugLevel)
}

func main() {
flag.BoolVar(&isRecover, "r", false, "是否將環境中pod的mock server移除")
flag.StringVar(&kubeConfigPath, "-kubeconfig", "kubeconfig", "k8s叢集的kubeconfig檔案, 工具需要此檔案連線k8s叢集")
flag.StringVar(&configPath, "-config", "config.json", "工具的配置檔案的路徑,工具會根據此配置檔案中的內容向業務pod中注入mock server")
flag.Parse()

log.Info("init the kubeconfig")
kubeConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath)
if err != nil {
log.Error("cannot init the kubeconfig")
panic(err.Error())
}
log.Info("init the k8sclient")
k8s, err := kubernetes.NewForConfig(kubeConfig)
if err != nil {
log.Error("cannot init the k8s client")
panic(err.Error())
}

configs, err := readConfig(configPath)
if err != nil {
handleErr(err, "Failed to load config %s ", "config.json")
}

deploys := make(map[string]string)
for _, c := range configs {
if isRecover {
log.Debugf("start to reset mock server ns[%s] deployment[%s]", c.Namespace, c.DeploymentName)
err = reset(k8s, c.Namespace, c.DeploymentName)
if err != nil {
handleErr(err, "Failed to reset the mock server. deployment=%s namespace=%s", c.Namespace, c.DeploymentName)
}
deploys[c.DeploymentName] = c.Namespace
} else {
err = addSecrets(k8s,c.Namespace)
if err != nil {
handleErr(err, "Failed to add Secrets to the namespace %s", c.Namespace)
}
log.Debugf("start to inject mock server ns[%s] deployment[%s] dport[%s] mockfile[%s]", c.Namespace, c.DeploymentName, c.Dport, c.MockFilePath)
err = injectMockServer(k8s, c.Namespace, c.DeploymentName, c.Dport, c.MockFilePath)
if err != nil {
handleErr(err, "Failed to setup the mock server. deployment=%s namespace=%s", c.Namespace, c.DeploymentName)
}
deploys[c.DeploymentName] = c.Namespace
}
}

err = waitDeploymentReady(k8s, deploys)
if err != nil {
fmt.Printf("err: %+v\n", err)
os.Exit(1)
}

log.Info("Done")
}

func handleErr(err error, message string, v ...interface{}) {
err = errors.WithMessagef(err, message, v)
fmt.Printf("err: %+v\n", err)
os.Exit(1)
}

func reset(k8s *kubernetes.Clientset, ns string, deploymentName string) error {
deployment, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
if err != nil {
return errors.Wrap(err, "Failed to get deployment")
}

initContainers := deployment.Spec.Template.Spec.InitContainers
for index, i := range initContainers {
if i.Name == "mock-init" {
initContainers = append(initContainers[:index], initContainers[index+1:]...)
}
}
deployment.Spec.Template.Spec.InitContainers = initContainers

Containers := deployment.Spec.Template.Spec.Containers
for index, i := range Containers {
if i.Name == "mock-server" {
Containers = append(Containers[:index], Containers[index+1:]...)
}
}
deployment.Spec.Template.Spec.Containers = Containers

_, err = k8s.AppsV1().Deployments(ns).Update(deployment)
if err != nil {
return errors.Wrap(err, "Failed to update deployment")
}

return nil
}

func injectMockServer(k8s *kubernetes.Clientset, ns string, deploymentName string, dport string, mockFilePath string) error {
deployment, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
if err != nil {
return errors.Wrap(err, "Failed to get deployment")
}

initContainers := deployment.Spec.Template.Spec.InitContainers
isExist := false
for _, i := range initContainers {
if i.Name == initContainerName {
isExist = true
}
}
//iptables -t nat -A PREROUTING -p tcp --dport 7777 -j REDIRECT --to-port 6666
if !isExist {
s := &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
},
}

mockServerInitContainer := corev1.Container{
Image: "biarca/iptables",
ImagePullPolicy: corev1.PullIfNotPresent,
Name: initContainerName,
Command: []string{
"iptables",
"-t",
"nat",
"-A",
"PREROUTING",
"-p",
"tcp",
"--dport",
dport,
"-j",
"REDIRECT",
"--to-port",
"1080",
},
SecurityContext: s,
}
initContainers = append(initContainers, mockServerInitContainer)
deployment.Spec.Template.Spec.InitContainers = initContainers
}

containers := deployment.Spec.Template.Spec.Containers
isExist = false
for _, i := range containers {
if i.Name == mockServerContainerName {
isExist = true
}
}
if !isExist {
c := corev1.Container{
Image: mockServerContainerImage,
ImagePullPolicy: corev1.PullAlways,
Name: mockServerContainerName,
Env: []corev1.EnvVar{
{
Name: "mock_file_path",
Value: mockFilePath,
},
{
Name: "dport",
Value: dport,
},
},
}
containers = append(containers, c)
deployment.Spec.Template.Spec.Containers = containers
deployment.Spec.Template.Spec.ImagePullSecrets = append(deployment.Spec.Template.Spec.ImagePullSecrets, corev1.LocalObjectReference{Name: secretName})

_, err = k8s.AppsV1().Deployments(ns).Update(deployment)
if err != nil {
return errors.Wrap(err, "Failed to update deployment")
}
}
return nil
}

type Config struct {
Namespace string `json:"namespace"`
//KubeConfigPath string `json:"kubeconfig_path"`
DeploymentName string `json:"deployment_name"`
Dport string `json:"dport"`
MockFilePath string `json:"mock_file_path"`
}

func readConfig(path string) ([]Config, error) {
var config []Config

data, err := ioutil.ReadFile(path)
if err != nil {
return nil, errors.Wrap(err, "ready file failed")
}

err = json.Unmarshal(data, &config)
if err != nil {
return nil, errors.Wrap(err, "unmarshal json failed")
}
return config, nil
}

func addSecrets(k8s *kubernetes.Clientset, ns string) error {
_, err := k8s.CoreV1().Secrets(ns).Get(secretName, metav1.GetOptions{})
if err != nil {
if !strings.Contains(err.Error(), "not found") {
return errors.Wrapf(err, "get secret %s", secretName)
}else {
log.Debugf("namespace[%s] has no secret, now create one", ns)
var (
err error
data []byte
)
if data, err = ioutil.ReadFile(secretFilePath); err != nil {
return errors.Wrapf(err, "read %s", secretFilePath)
}
if data, err = yamlUtil.ToJSON(data); err != nil {
return errors.Wrap(err, "covert yaml to json")
}
se := &corev1.Secret{}
if err = json.Unmarshal(data, se); err != nil {
return errors.Wrap(err, "json unmarshal to secret")
}
//cluster := se.ObjectMeta.ClusterName
//namespace := se.ObjectMeta.Namespace
//seName := se.ObjectMeta.Name
se.Namespace = ns
if _, err = k8s.CoreV1().Secrets(ns).Create(se); err != nil {
return errors.Wrap(err, "create secret failed")
}
return nil
}
}
return nil
}


func waitDeploymentReady(k8s *kubernetes.Clientset, deploys map[string]string) error {
now := time.Now()
time.Sleep(time.Second * 1)
for deploymentName, ns := range deploys {
deploy, err := k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
if err != nil {
return errors.Wrapf(err, "Failed to get deployment[%s] ns[%s]", deploymentName, ns)
}

sumReplica := deploy.Status.UnavailableReplicas + deploy.Status.AvailableReplicas
for deploy.Status.ReadyReplicas != *deploy.Spec.Replicas || sumReplica != *deploy.Spec.Replicas {
if time.Now().Sub(now) > time.Minute*timeout {
return errors.Wrapf(err, "deployment is not ready name:%s ns:%s", deploymentName, ns)
}

deploy, err = k8s.AppsV1().Deployments(ns).Get(deploymentName, metav1.GetOptions{})
if err != nil {
return errors.Wrapf(err, "Failed to get deployment[%s] ns[%s]", deploymentName, ns)
}
time.Sleep(time.Second * 5)
sumReplica = deploy.Status.UnavailableReplicas + deploy.Status.AvailableReplicas
log.Debugf("Waiting: the deploy[%s] the spec replica is %d, readyRelicas is %d, unavail replica is %d, avail replica is %d",
deploy.Name, *deploy.Spec.Replicas, deploy.Status.ReadyReplicas, deploy.Status.UnavailableReplicas, deploy.Status.AvailableReplicas)
}
}
return nil
}

dockerfile:

FROM docker.4pd.io/base-image-openjdk8:1.0.1

RUN yum install -y git
RUN wget -O "mockserver-netty-5.8.1-jar-with-dependencies.jar" "http://search.maven.org/remotecontent?filepath=org/mock-server/mockserver-netty/5.8.1/mockserver-netty-5.8.1-jar-with-dependencies.jar"

EXPOSE 1080

ENTRYPOINT git clone https://xxxx:xxxx@xxxxxxxxx.git && cp mock-server-tools/${mock_file_path} . && bash -x java -Dmockserver.persistExpectations=true -Dmockserver.persistedExpectationsPath=mockserverInitialization.json -Dmockserver.initializationJsonPath=${mock_file_path} -jar mockserver-netty-5.8.1-jar-with-dependencies.jar -serverPort 1080 -proxyRemotePort ${dport} -logLevel INFO

相關文章