随着微服务架构的流行,API网关在各互联网公司越来越受欢迎。API网关并不是微服务架构中的必须组件,没有API网关,客户端也能够正常访问各个后端服务。有了API网关,只是可以更好的支持客户端的访问。
如上图,API网关可以充当客户端所需请求微服务的中央接口,客户端不必访问数百个单独的服务(客户端不用请求数百个服务的域名),客户端只需要通过API网关发送请求,只需要同API网关做交互。所以API网关的主要功能就是路由透传,这也是测试过程中要测的最基本的功能之一。
API网关不仅可以通过路由透传客户请求,接管所有的入口流量,类似Nginx,还可以对API进行统一管理和安全管理,也会针对流量做一些扩展,比如鉴权、限流、熔断、错误码统一、日志、监控、告警等,将接口所需要的通用逻辑抽出来。这里就要引入API网关中的插件概念。常用的插件有:鉴权插件;限流插件;降级插件;极验插件;cors跨域插件;abtest插件;mock插件等。如何理解将这些功能插件化?假如我们开发一个网站,需要调用不同的业务方,如订单,用户中心,商城等,每个业务线都需要鉴权,限流,熔断等功能,如果每个业务线自己开发,这就会是对研发人员的资源浪费。将通用功能抽出来,以插件的形式,只要在需要鉴权功能的API配置鉴权插件,在需要限流的API配置限流插件即可。
API网关中最重要的就是路由透传与插件功能,这边也主要是总结透传与其中一些重要插件的测试思路。在测试前,你需要开发一个简单的微服务项目,这里我称为gateway-testing
,把它当作一个业务后端服务。这里选择自己开发,是因为路由透传所需各种请求,公司已有的项目请求也许会无法覆盖完全,东拼西凑的调用也不利于测试管理,所以自己开发,统一管理,不影响正常业务,也能更好的模拟API网关和后端服务的交互。
gateway-testing
需要支持各种类型的请求(GET,POST,PUT,DELETE,HEAD,PATCH,OPTIONS)这些常用的请求接口,同时也需要准备好文件上传接口,超时接口,responseCode=500 等各种类型得接口,接口逻辑不需要多复杂,功能支持即可。
文章最后是我自己在测试写的一些支持路由测试的接口
,接口不复杂,但是支持的类型一定要全。比如,POST请求,要包含(json和form表单),GET请求无参数,有参数,以及路径请求等等。
项目是要完全模仿公司服务,需要找运维配置域名,可部署测试环境,预发环境,以及线上,因为这样也有利于在各个环境对API网关的测试。
测试内容 | 备注 |
---|---|
get请求正常转发 | 带参数与不带参数 |
post请求正常转发 | json,form, x-www-form-urlencoded |
delete请求正常转发 | 带参数与路径带参 |
put请求正常转发 | json,form, x-www-form-urlencoded |
patch请求正常转发 | json,form, x-www-form-urlencoded |
HEAD请求正常转发 | 打印请求head并看是否有成功返回 |
OPTIONS请求正常转发 | 打印header,在跨域插件用中用的较多 |
接口超时测试 | |
文件上传功能 | 文件上传大小限制,上传文件名乱码问题 |
路由规则(默认/API/项目) | 不发布接口,是否可以自由切换 |
负载策略 | 轮询 |
注:
1.eureka负载策略测试轮询,需要进行小压力测试与大压力测试,如一个服务部署在三台机器,此时你发送三个请求,可在三台机器上看日志确定是否每台机器都接收到了一条请求。之后可增加压力,观察日志,观察每台机器接收的请求是否均衡。
2.接口超时测试:超时时间在API网关管理平台可配。例如配置2s,在你所请求的超时接口,先让接口sleep 3s,看接口超时后网关的返回是否是你所配置的返回。这里只是粗略的提供测试思想,具体一些边界值的测试,还是需要根据自己的业务场景设计case进行测试。
API网关插件各个公司根据不同需求有不同的插件,这里主要记录一些通用插件,例如降级、限流,熔断,跨域,abtest插件。插件是可以运用在集群/项目/API。至于在集群/项目/API间的测试,以及优先级是根据自己公司的业务需求进行测试,这里只简单的记录一些测试思想。
在具体介绍前,需要明白一些概念。熔断,限流,降级,通俗说熔断/断路器是为了保护网关自己的服务,而降级、限流是为了保护后端服务。
限流: 客户端请求太多,超出了服务端的承受能力,导致服务端不可用或无法响应,耗尽服务端资源甚至服务崩溃。解决方案:服务端对客户端进行限流,保护服务端资源。对各类请求设置最高的QPS阈值,当请求高于阈值时直接阻断。
限流插件测试: 在API网关平台为对应测试接口配置限流策略,如1s内最高QPS为20,用Jmeter进行小压力测试和大压力测试,1s内分别给到10QPS.20QPS,21QPS,30QPS,统计阻断接口数,这里具体是数值可根据自己的业务场景进行测试。
时间 | QPS |
---|---|
1s | 10QPS.20QPS,21QPS,30QPS |
1min | 10QPS.20QPS,21QPS,30QPS |
1s | 1000QPS.200QPS,201QPS,300QPS |
1min | 1000QPS.200QPS,201QPS,300QPS |
降级: 服务降级是指当服务器压力剧增的情况下,根据实际业务情况,将一些不重要的接口换种简单的方式处理,从而将服务器的资源都释放给当前重要的核心业务使其可以高效运作。简单说,就是尽可能的把系统资源让给优先级高的服务。资源有限,请求无限,比如在并发高峰期,如果不做降级处理,可能会影响整体的服务性能。这里最简单的例子就是双十一活动时,把跟交易无关的服务都进行降级,比如修改用户信息,淘宝人生换衣,查看历史订单等等不重要不紧急的服务延迟或者暂停使用。
降级插件测试: 我这边主要的降级就是让请求无法访问到后端服务,接口暂停使用,当接口配置降级插件。插件开关打开,返回API网关所配置的响应信息状态码等,接口是无法真正的请求到后端服务。
熔断: 在微服务架构中,各个微服务间相互依赖是非常普遍的,例如,客户端调用业务服务端,业务服务端调用底层中台,如果服务端调用底层中台,中台服务超时,服务端一直处于等待状态,而此时客户端一直在调用服务端,此时就有可能造成服务端宕机。由此可见,在整个链路中,有一个环节出现问题,都会造成整个上下游服务调用出现问题,服务出现宕机。
具体说,熔断就是调用方发起服务调用时,如果被调用方返回的错误率超过一定的阈值,那么后续的请求将不会真正发起请求,而是在调用方直接返回错误。例如,客户端发现服务端出现异常,比如大量超时,500等,客户端主动暂停对服务端请求。客户端对服务端熔断后,通常可以用mock数据,例如返回是429。
熔断的设计通常有2个关键点:1.判断何时熔断(错误率)2.何时从熔断状态恢复。
熔断插件测试: 例如在API网关平台设置:1分钟内,总请求达到10个,错误数达到5个。
不同得网关有不同得熔断策略,我这边主要是针对5XX,开发接口,根据不同得入参控制返回,可返回2XX,4XX,5XX,当规定的时间5XX数达到设定得阈值,看是否服务开启熔断。熔断恢复测试也一样,比如5s,允许部分请求通过,你可以继续控制请求,若这部分请求都成功,恢复熔断。具体case设计还是需要根据自己的实际业务设计。
cors跨域插件:
首先需要大致了解:什么是跨域?跨域是指:只要协议、域名、端口
有任何一个不相同,都被当作是在不同的域。所谓同源策略就是指:协议,域名,端口都要相同,其中有一个不同都会产生跨域。
CORS是一种基于HTTP头的机制,该机制通过允许服务器标示除了它自己以外的其他origin(域名、协议、端口),这样浏览器可以访问加载这些资源。浏览器必须首先使用 OPTIONS
方法发起一个预检请求,从而获知服务端是否允许该跨源请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
参数了解
参数 | 说明 |
---|---|
allowedOrigins | 配置允许访问的源,如:http://demo.com,*表示允许全部的域名 |
allowedMethods | 配置跨域请求支持的方式,如:GET、POST,且一次性返回全部支持的方式 |
allowedHeaders | 配置允许的自定义请求头,用于 预检请求 |
exposedHeaders | 配置响应的头信息, 在其中可以设置其他的头信息,不进行配置时, 默认可以获取到Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma字段 |
allowCredentials | 配置是否允许发送Cookie,用于 凭证请求, 默认不发送cookie |
maxAge | 配置预检请求的有效时间, 单位是秒,表示:在多长时间内,不需要发出第二次预检请求 |
preflightContinue | 一个布尔值,指示插件将OPTIONS预检请求代理到上游服务。 |
测试内容 | 预期结果 |
---|---|
数据库down或者数据库重启 | 新发布的路由或者插件设置等数据操作可能失败,但是不影响已生效的路由和插件 |
后端服务其中一台down或者重启,或者添加新得节点 | 负载策略能够自动踢出不可用的服务节点和自动增加新的服务节点 |
redis服务down一台,或者down多台 | 不影响已生效路由和插件 |
eureka挂一台或者多台 | 不影响已生效负责策略 |
注:数据库down,因为有本地缓存,验证本地缓存是否生效,所以数据库重启或者down掉,不能影响已经生效的路由和插件;后端服务down掉一台,验证eureka是否有将死掉的节点删除,若eureka并没有将死掉的节点删除,则会报错。添加新的节点,需要看请求是否有轮询;redis主要用于限流,在redis down掉限流策略失效,但是其他插件功能及路由应该不受影响;eureka是注册中心,注册中心在启动的时候会将所有资源加载本地,所以eureka挂一台或者多台,不影响已经加载到本地的。
总上所述,总结来说就是API网关的所有依赖都可以down,但是gateway不可以不用
测试内容 | 预期结果 |
---|---|
正常压测 | 压配置在API网关平台的API即可 |
项目资源测试 | 超过配置资源返回错误 |
断路器测试 | 超过配置的错误率返回错误 |
雪崩测试 | 一个项目资源打满,或者超时严重,不影响其他项目正常访问 |
负载测试 | 压测时,增加和减少后端服务节点 |
切换路由配置 | 切换路由配置(默认,API,项目) |
注:项目资源的作用是为了进行线程隔离,例如网关的一台机器分配600个线程,每个项目资源分配64个资源,此时将一个项目的的资源打满,应该是返回错误,而不是占用其他项目的资源。如果没有项目资源的分配,后端一个服务突然将600个线程占满,那么会导致其他服务不可用;进行负载测试时,在增加压力时,增加和减少后端服务节点,请求应该不报错。
**
以上内容,是对一些通用功能测试思想的记录总结。然而网关的测试也不仅仅是以上功能,还有集群的热加载,插件在集群项目与API见的运用,API的发布,下线,插件的随时切换,监控等等需求,可根据公司的具体业务进行case设计。
@RestController
@Slf4j
public class routeDemo {
@RequestMapping("/testGet")
public RetDto<User> get(@RequestParam("username") String username, @RequestParam("age") Integer age, @RequestParam("address") String address) {
User user = new User();
user.setUsername(username);
user.setAge(age);
user.setAddress(address);
return RetDto.getReturnJson(user);
}
@RequestMapping("/testGetNoParams")
public RetDto<String> testGet() {
return RetDto.getReturnJson("suc");
}
@RequestMapping(value = "/testPost", method = RequestMethod.POST)
public RetDto<String> postJson(@RequestBody User user) {
String username = user.getUsername();
String address = user.getAddress();
Integer age = user.getAge();
return RetDto.getReturnJson(username + address + age);
}
@RequestMapping(value = "/testformpost", method = RequestMethod.POST)
public RetDto<String> postForm(@RequestParam("username") String username) {
return RetDto.getReturnJson(username);
}
@RequestMapping(value = "/testdelete/{apiId}", method = RequestMethod.DELETE)
public RetDto<String> delete(@PathVariable String apiId) {
log.info("apiId:{}", apiId);
return RetDto.getReturnJson(apiId);
}
@RequestMapping(value = "/testdeleteParam", method = RequestMethod.DELETE)
public RetDto<String> deleteParam(@RequestParam("apiId") String apiId) {
log.info("apiId:{}", apiId);
return RetDto.getReturnJson(apiId);
}
@RequestMapping(value = "/testdeleteJson", method = RequestMethod.DELETE)
public RetDto<String> deleteForm(@RequestBody User user) {
String username = user.getUsername();
String address = user.getAddress();
Integer age = user.getAge();
return RetDto.getReturnJson(username + address + age);
}
@RequestMapping(value = "/testPut", method = RequestMethod.PUT)
public RetDto<User> testPut(@RequestParam("username") String username, @RequestParam("address") String address, @RequestParam("age") Integer age) {
User user = new User();
user.setUsername("小猪");
user.setUsername(username);
user.setAge(age);
user.setAddress(address);
return RetDto.getReturnJson(user);
}
@RequestMapping(value = "/testPutjson", method = RequestMethod.PUT)
public RetDto<User> testPut2(@RequestBody User u) {
User user = new User();
user.setUsername("小猪");
user.setAge(u.getAge());
user.setAddress(u.getAddress());
return RetDto.getReturnJson(user);
}
/**
* patch请求json
*/
@RequestMapping(value = "/testPatch", method = RequestMethod.PATCH)
public RetDto<User> testPatch(@RequestBody User user) {
User u = new User();
u.setUsername("小花");
u.setAddress("北京");
u.setAge(10);
u.setUsername(user.getUsername());
return RetDto.getReturnJson(u);
}
/**
* patch请求form
*/
@RequestMapping(value = "/testPatchForm", method = RequestMethod.PATCH)
public RetDto<String> testPatchForm(String s) {
return RetDto.getReturnJson(s);
}
/**
* HEAD请求
*/
@RequestMapping(value = "/testHEAD", method = RequestMethod.HEAD)
@ResponseBody
public RetDto<String> testHEAD(HttpServletRequest request, HttpServletResponse response) {
Enumeration<String> requestHeader = request.getHeaderNames();
while (requestHeader.hasMoreElements()) {
String headerKey = requestHeader.nextElement().toString();
//打印所有Header值
log.info("headerKey={};value={}", headerKey, request.getHeader(headerKey));
}
response.addHeader("test", "test");
return RetDto.getReturnJson("ok");
}
@RequestMapping(value = "/testHead", method = RequestMethod.HEAD)
public RetDto<String> testHEAD(HttpServletResponse response, @RequestParam("test") String test) {
response.addHeader("test", test);
return RetDto.getReturnJson(test);
}
/**
* option请求
*/
@RequestMapping(value = "/testOptions", method = RequestMethod.OPTIONS)
@ResponseBody
public RetDto<String> testOptions(HttpServletRequest request, @RequestParam("test") String test) {
Enumeration<String> requestHeader = request.getHeaderNames();
while (requestHeader.hasMoreElements()) {
String headerKey = requestHeader.nextElement().toString();
//打印所有Header值
log.info("headerKey={};value={}", headerKey, request.getHeader(headerKey));
}
return RetDto.getReturnJson(test);
}
@RequestMapping(value = "/testtimeout", method = RequestMethod.OPTIONS)
@ResponseBody
public RetDto<String> testTimeout(HttpServletRequest request, @RequestParam("test") String test) {
try {
sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
return RetDto.getReturnJson(test);
}
@RequestMapping(value = "/testsensitiveheader", method = RequestMethod.POST)
public RetDto<HashMap> postSensitiveheader(HttpServletRequest request, @RequestParam("test") String test) {
Enumeration<String> headerNames = request.getHeaderNames();
HashMap header = new HashMap();
header.put("test", test);
while (headerNames.hasMoreElements()) {
String headerKey = headerNames.nextElement().toString();
//打印所有Header值
log.info("headerKey={};value={}", headerKey, request.getHeader(headerKey));
header.put(headerKey, request.getHeader(headerKey));
}
return RetDto.getReturnJson(header);
}
/**
* 路由断路器
*/
@RequestMapping(value = "/testRoutturnoff", method = RequestMethod.GET)
public RetDto<String> testRoutturnoff(HttpServletResponse response) {
response.setStatus(500);
Integer code = 500;
String msg = "error";
JSONObject json = new JSONObject();
RetDto rs = new RetDto(code, msg);
json.put("msg", "error");
json.put("code", code);
rs.setCode(code);
rs.setMsg(msg);
return rs;
}
/**
* cors跨域接口
* */
@CrossOrigin
@RequestMapping(value = "/testCors",method = RequestMethod.PUT)
public RetDto<String> testCors(HttpServletRequest req,HttpServletResponse resp){
resp.setHeader("Access-Control-Allow-Origin","*");
resp.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
resp.setHeader("Access-Control-Max-Age", "0");
resp.setHeader("Access-Control-Allow-Headers", "Origin, No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, Content-Type, X-E4M-With,userId,token");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Expose-Headers","env");
resp.setHeader("env", "test");
return null;
}
/**
* abtest:接口名相同,服务不同的接口
* */
@CrossOrigin
@RequestMapping(value = "/account/modifyMobile/getUsernameByToken",method = RequestMethod.GET)
public RetDto<String> testCors(){
return RetDto.getReturnJson("ok");
}
}
@RestController
@Slf4j
@RequestMapping(value = "/file")
public class FileUploadAndDownload {
String pathR = System.getProperties().get("user.dir").toString() + File.separator + "file";
public String folder = pathR;
@PostMapping
public FileInfo update(MultipartFile file) throws IOException {
System.getProperties();
File fileS = new File(folder);
if (!fileS.exists()) {
fileS.mkdir();
}
log.info("file name:{}", file.getName());
log.info("origin file name:{}", file.getOriginalFilename());
log.info("file size:{}", file.getSize());
File localFile = new File(folder, file.getOriginalFilename());
IOUtils.copy(file.getInputStream(), new FileOutputStream(localFile));
return new FileInfo(localFile.getAbsolutePath());
}
/**
* 文件下载
*/
@RequestMapping(value = "/download/{id}", method = RequestMethod.GET)
public void downLoad(@PathVariable("id") String id, HttpServletResponse response) throws IOException {
FileInputStream fis = null;
BufferedInputStream bis = null;
try {
String nameSource = null;
InputStream inputStream = null;
String nameNext = null;
String path = null;
for (int i = 0; i < new File(folder).list().length; i++) {
//获取文件名
nameSource = new File(folder).list()[i].substring(0, new File(folder).list()[i].lastIndexOf('.')).toString();
//获取文件后缀
nameNext = new File(folder).list()[i].substring(new File(folder).list()[i].lastIndexOf('.'), new File(folder).list()[i].length()).toString();
if (id.equals(nameSource)) {
inputStream = new FileInputStream(folder + File.separator + new File(folder).list()[i]);
path = folder + File.separator + new File(folder).list()[i];
break;
}
}
File file1 = new File(path);
//指明为下载
OutputStream outputStream = response.getOutputStream();
response.setCharacterEncoding("utf-8");
String fileName = "download" + nameNext;
//设置文件名
response.addHeader("Content-Disposition", "attachment;fileName=" + URLEncoder.encode(fileName, "UTF-8"));
fis = new FileInputStream(file1);
byte[] buffer = new byte[fis.available()];
bis = new BufferedInputStream(fis);
OutputStream osm = response.getOutputStream();
FileWriter fileWriter = new FileWriter(folder + File.separator + "download" + nameNext);
int i = bis.read(buffer);
while (i != -1) {
osm.write(buffer, 0, i);
i = bis.read(buffer);
}
//把输入流copy到输出流
IOUtils.copy(inputStream, outputStream);
outputStream.flush();
fileWriter.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (bis != null) {
bis.close();
}
if (fis != null) {
fis.close();
}
}
}
}