Sharingan(写轮眼)是一个基于golang的流量录制回放工具,适合项目重构、回归测试等。

Sharingan

Build Status Gitter License GoDoc

Sharingan

Sharingan,中文名:写轮眼,是漫画《火影忍者》中的一种瞳术,具有复制、幻术等能力,在幻术世界里,一切因素包括时间,地点,质量等,都由施术者掌控。

一、简介

Sharingan是一个基于golang的流量录制回放工具,录制线上真实请求流量进行回放测试,适合项目重构、回归测试等。

1.1、背景

随着微服务架构的兴起,服务之间的依赖关系变的越来越复杂,软件测试也面临新的挑战:系统升级频繁、服务依赖众多等等。

  • 常见的测试方案(如:单元测试、系统测试等)构造和维护测试用例成本高,特别是业务复杂的场景。「构造测试数据
  • 依赖第三方服务众多,线下测试环境不太稳定,经常出现下游服务不可用导致测试失败的情况发生。「维护测试环境成本

为此,我们需要开发一套工具来缓解上述问题。

1.2、方案

  • 录制线上服务真实请求流量(包括下游调用流量),在线下进行回放,解决构造测试数据难的问题。「复制能力」
  • 回放的时候匹配Mock下游调用,不再依赖具体的下游服务,解决维护测试环境成本高的问题。「幻术能力」

1.3、特性

  • 支持下游流量录制。相比tcpcopygoreplay等方案,回放不依赖下游环境。
  • 支持并发流量录制和回放。录制对服务影响小,回放速度更快。
  • 支持时间重置、噪音去除、批量回放、覆盖率报告、常见协议解析等等。
  • 支持写流量回放,不会污染应用数据。
  • 不依赖业务框架,低应用浸入。

二、快速开始

2.1、使用示例

# Step1: 下载sharingan项目
$ git clone https://github.com/didi/sharingan.git
$ cd sharingan

# Step2: 使用定制版golang,以go1.13为例「慢?科学上网试试」
$ sh install.sh go1.13 # 支持go1.10 ~ go1.15,限mac、linux amd64系统
$ export GOROOT=/tmp/recorder-go1.13
$ export PATH=$GOROOT/bin:$PATH

# Step3: 编译、后台启动replayer-agent「默认会占用3515、8998端口,可修改」
# [回放接入文档]内有直接下载bin文件的链接,无需build
$ cd replayer-agent
$ go build
$ nohup ./replayer-agent >> run.log 2>&1 &

# Step4: 编译、后台启动example示例「默认会占用9999端口,可修改」
$ cd ../example
$ go build -tags="replayer" -gcflags="all=-N -l"
$ nohup ./example >> run.log 2>&1 &

# Step5: 打开回放页面
$ 浏览器打开,http://127.0.0.1:8998 # 非本机替换IP即可
$ 页面选择要回放的流量点执行          # 内置提前录制好的3条example示例流量

2.2、接入文档

三、技术方案

3.1、模块划分

  • recorder: 流量录制模块,录制流量本地文件存储、发送流量到录制agent等。
  • recorder-agent:流量录制agent,单独进程启动,控制录制比例、流量存储等。
  • replayer: 流量回放模块,重定向连接到Mock Server、Mock时间、添加流量标识等。
  • replayer-agent:流量回放agent,单独进程启动,查询流量、查询/上报噪音、流量diff、批量回放、生成覆盖率报告等。

3.2、整体架构图

架构图

3.3、录制方案

  • 修改golang源码,对外暴露Hook接口。「所有改动通过官方测试用例」
  • 提供API串联不同goroutine之间的网络调用。「常见的http、mysql、redis流量都不需要特别设置」
  • 提供单独的agent筛选流量、控制比例。
  • 更多参考:流量录制实现原理

3.4、回放方案

  • 连接重定向:将服务所有Connect网络调用重定向到Mock Server。「安装replayer-agent时候自带」
  • 流量匹配:Mock Server会根据服务真实的下游请求匹配一个返回。「mock下游调用」
  • 时间重置:将程序执行时间回退到录制执行时刻,尽量避免时间因素带来的干扰。
  • 噪音去除:提供API可以将已知的噪音流量去掉,如:traceID,每次请求本来就不一样。
  • 常见协议解析:会解析http、mysql、redis、thrift等协议,方便diff对比。
  • 更多参考:流量回放实现原理

四、演进之路

关于流量录制和回放,在内部进行过多次探索,主要经历下面三个阶段:

4.1、月光宝盒(串行录制、串行回放)

录制:利用tcpdump录制流量,改造router层将请求串行化,利用时间来分割请求。

回放:利用iptables转发流量到mock服务,匹配请求并mock返回。支持时间重置、流量对比等。

不足:录制流量覆盖率低,一次只能录制一个请求。iptables转发,噪音干扰严重。

4.2、Fastdev(并行录制、串行回放)

录制:改造golang源码,利用goroutine+工作委托技术串联区分请求。链路追踪原理

回放:利用mock库重定向connect系统调用,转发流量到mock服务。支持Dashboard、噪音去除等。monkey mock原理

不足:录制接口和实现混合,golang多版本支持困难。不支持并发回放,启动阶段流量无法代理会失败,定时任务流量干扰严重。

4.3、Sharingan(并行录制、并行回放)

录制:接口和实现分离,golang源码改造部分只暴露接口,具体录制实现单独提供包支持。确保golang源码改动通过官方测试,支持1.10~1.14所有版本;优化录制服务性能。

回放:添加流量标识,支持并发回放;支持启动阶段流量代理;利用定制版golang,消除定时任务流量干扰;时间重置不再依赖本地文件,支持replayer-agent单独部署;支持常见协议解析。

五、效果展示

5.1、流量回放

5.1.1、单个回放

单个回放

对于下游请求很多且复杂的情况,支持对下游协议进行筛选 单个回放-协议刷选

5.1.2、批量回放

批量回放的并发度默认是10,可通过增加-parallel参数修改。 批量回放

5.2、覆盖率报告

5.2.1、整体报告

覆盖率报告支持覆盖率结果累计,即支持 多次 单个回放和批量回放后,统一生成覆盖率结果。 整体报告

5.2.1、覆盖详情

覆盖详情

六、更多

6.1、如何贡献

欢迎大家参与进来,更多参考Contribute

6.2、许可

基于Apache-2.0协议进行分发和使用,更多参考LICENSE

6.3、成员

huengyj20060714qiaodandedidibikong0411plpanfzl-yty

6.4、感谢

特别感谢TaoWen ,流量录制和回放初版设计者,为后续开源奠定了很好的基础。

6.5、联系我们

  • 微信交流群 【加管理员微信,拉进交流群】

WEIXIN

Owner
Similar Resources
Comments
  • docs(README.md): Quick start of problems in Mac

    docs(README.md): Quick start of problems in Mac

    • 目前的 Mac 系统放权是严格的,建议用户使用 sudo 执行命令。 修改前:sh install.sh go1.13; 修改后:sudo install.sh go1.13

    • 部分 Mac 用户可能没有安装 wget,执行 install.sh 会失败。 修改前:没有常见问题模块; 修改后:新增常见问题模块,并给出解决方案。

    • 修改了部分格式问题。

  • 不太理解为什么要改造源代码来传递id?

    不太理解为什么要改造源代码来传递id?

    关于上下游流量inbound/outbound的对应匹配: 这应该是框架做的事情,框架应该维护一个traceid,跟着流量在全链路传播

    关于工作委托: 在业务层,应该通过context来传递信息吧,而不是修改源码,把数据存在源码未导出的结构体.

    请问使用一个context来传递相关的threadlocal信息有什么局限吗?

  • goroutine中的redis回放无法捕捉到发起的请求

    goroutine中的redis回放无法捕捉到发起的请求

    测试代码 `package main

    import ( "github.com/didi/sharingan" "database/sql" "github.com/garyburd/redigo/redis" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" "log" "time" ) var ( SqlDB *sql.DB // mysql连接 RedisConn redis.Conn // redis连接 err error )

    func init() { SqlDB, err = sql.Open("mysql", "root:my-secret-pw@tcp(192.168.174.134:3306)/meng") if err != nil { log.Println("初始化mysql连接失败.", err) } else { SqlDB.SetConnMaxLifetime(100 * time.Second) //最大连接周期,超过时间的连接就close SqlDB.SetMaxOpenConns(100) //设置最大连接数 SqlDB.SetMaxIdleConns(16) //设置闲置连接数 log.Println("初始化mysql连接成功", SqlDB.Stats()) }

    RedisConn, err = redis.Dial("tcp", "192.168.174.134:6379")
    if err != nil {
    	log.Println("Connect to redis error", err)
    } else {
    	log.Println("初始化redis连接成功")
    }
    

    } func SetTaskRouter() *gin.Engine { router := gin.Default() api := router.Group("/api") { api.POST("/book",ShanT) } return router }

    func main(){ r := SetTaskRouter() r.Use(cors.Default()) r.Run(":8090") }

    var msg chan string

    func init() { msg = make(chan string) }

    type Book struct { BookName string db:"name" json:"name" BookAuthor string db:"author" json:"author" }

    func ShanT(c *gin.Context) { book := Book{} c.ShouldBindJSON(&book) if book.BookName == "" || book.BookAuthor == "" { c.JSON(500,"需要用户信息") } // 写mysql InsertMysql(book) // 查询mysql mbook,err := GetMysqlBookByAuthor(book.BookAuthor) if err != nil { log.Println("从mysql查询失败,停止查询") } else { log.Println("mysql中查询到的book",mbook) // 读写redis go Redis(mbook,sharingan.GetCurrentGoRoutineID()) // 从channel中读取数据 m := <- msg c.JSON(200,m) }

    }

    func InsertMysql(book Book) { result,err := SqlDB.Exec("insert INTO book(name,author) values (?,?)",book.BookName,book.BookAuthor) if err != nil { log.Println("插入myql失败",err) } else { num,_ := result.RowsAffected() log.Println("插入mysql成功,影响行数",num) } }

    func GetMysqlBookByAuthor(author string) (Book,error){ book := new(Book) row := SqlDB.QueryRow("select * from book where author=?",author) //row.scan中的字段必须是按照数据库存入字段的顺序,否则报错 if err :=row.Scan(&book.BookName,&book.BookAuthor); err != nil{ log.Println("从mysql查询失败",err) return *book,err } else { return *book,nil } }

    func Redis(book Book,delegatedID int64) { sharingan.SetDelegatedFromGoRoutineID(delegatedID) defer sharingan.SetDelegatedFromGoRoutineID(0) _, err = RedisConn.Do("SET", book.BookName,book.BookAuthor) if err != nil { log.Println("redis set failed:", err) } author, err := redis.String(RedisConn.Do("GET", book.BookName)) if err != nil { log.Println("redis get failed:", err) } else { log.Println("Get mykey: %v \n", author) } msg <- author

    } 可以连接mysql,但是redis连接异常[GIN-debug] Listening and serving HTTP on :8090 2020/05/22 19:48:44 插入mysql成功,影响行数 1 2020/05/22 19:48:44 mysql中查询到的book {b b} 2020/05/22 19:48:44 redis set failed: EOF 2020/05/22 19:48:44 redis get failed: write tcp 127.0.0.1:45722->127.0.0.1:3515: use of closed network connection [GIN] 2020/05/22 - 19:48:44 | 200 | 0s | 127.0.0.1 | POST "/api/book" 2020/05/22 19:48:39 插入mysql成功,影响行数 1 2020/05/22 19:48:39 mysql中查询到的book {a a} 2020/05/22 19:48:39 redis set failed: write tcp 127.0.0.1:45722->127.0.0.1:3515: use of closed network connection 2020/05/22 19:48:39 redis get failed: write tcp 127.0.0.1:45722->127.0.0.1:3515: use of closed network connection ` 回放页面显示miss

  • Improved unit testing

    Improved unit testing

    For the integration test coverage, modify the original behavior of go language by modifying TestMain (m * testing.M) Minimize users' code refactoring of SUT