环境:APISIX3.4.1 + JDK11 + SpringBoot2.7.12
一. APISIX简介
APISIX 网关作为所有业务的流量入口,它提供了动态路由、动态上游、动态证书、A/B 测试、灰度发布(金丝雀发布)、蓝绿部署、限速、防攻击、收集指标、监控报警、可观测、服务治理等功能。
为什么使用APISIX?
综上所述,APISIX是一个优秀的开源API网关,具有高性能、可扩展性、社区活跃、易于使用等特点,并且能够处理API和微服务流量,避免宕机、丢失数据等问题,实现实时配置更新。因此,许多企业和团队选择使用APISIX作为其API网关解决方案。
二. APISIX安装
关于APISIX的安装参考官方文档即可
APISIX安装指南
https://apisix.apache.org/zh/docs/apisix/installation-guide/
我使用的docker部署
三. 需求说明
由于安全需要,现有系统接口的请求数据需要加密(调用方必须加密传输)。
考虑到对现有系统的最小化影响,决定采用APISIX作为中间件,通过自定义Java插件来实现数据加密的功能。
-
数据加密:插件需要能够接收并解析请求数据,然后对数据进行解密处理(解密后的数据再提交到上游服务)。
-
安全性:加密算法和密钥管理应遵循业界最佳实践,确保数据安全。
-
错误处理与日志记录:插件应具备良好的错误处理机制,并能够记录详细的日志,以便于问题排查。(这通过记录日志即可)
非功能需求:
-
可维护性:插件代码应清晰、模块化,便于后续的维护和升级。
-
可扩展性:考虑到未来可能的加密需求变化,插件应具备良好的扩展性。
四. 插件工作原理
apisix-java-plugin-runner 设计为使用 netty 构建的 TCP 服务器,它提供了一个 PluginFilter 接口供用户实现。用户只需关注其业务逻辑,而无需关注 apisix java 插件运行程序如何与 APISIX 通信的细节;它们之间的进程间通信如下图所示。
图片
核心运行原理
官方的包是基于springboot,所以它自身提供了一个CommandLineRunner类,该类会在Springboot容器启动完成后运行,也就是下面的地方执行:
public class SpringApplication {
public ConfigurableApplicationContext run(String... args) {
// ...这里ApplicationContext等相关的初始化
callRunners(context, applicationArguments);
}
}
核心Runner类
public class ApplicationRunner implements CommandLineRunner {
private ObjectProvider filterProvider;
public void run(String... args) throws Exception {
if (socketFile.startsWith("unix:")) {
socketFile = socketFile.substring("unix:".length());
}
Path socketPath = Paths.get(socketFile);
Files.deleteIfExists(socketPath);
// 启动netty服务
start(socketPath.toString());
}
public void start(String path) throws Exception {
EventLoopGroup group;
ServerBootstrap bootstrap = new ServerBootstrap();
// 判断使用什么channel
bootstrap.group(group).channel(...)
try {
// 初始化netty服务
initServerBootstrap(bootstrap);
ChannelFuture future = bootstrap.bind(new DomainSocketAddress(path)).sync();
Runtime.getRuntime().exec("chmod 777 " + socketFile);
future.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
}
private void initServerBootstrap(ServerBootstrap bootstrap) {
bootstrap.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(DomainSocketChannel channel) {
channel.pipeline().addFirst("logger", new LoggingHandler())
//...
// 核心Handler
.addAfter("payloadDecoder", "prepareConfHandler", createConfigReqHandler(cache, filterProvider, watcherProvider))
// ...
}
});
}
}
五. 插件开发
5.1 依赖管理
11
2.7.12
0.4.0
1.1.4
org.apache.apisix
apisix-runner-starter
${apisix.version}
com.pack.components
pack-keys
${keys.version}
com.fasterxml.jackson.core
jackson-databind
org.springframework
spring-web
5.2 配置文件
这里的配置可有可无,都有默认值
cache.config:
expired: ${APISIX_CONF_EXPIRE_TIME}
capacity: 1000
socket:
file: ${APISIX_LISTEN_ADDRESS}
5.3 启动类配置
@SpringBootApplication(scanBasePackages = { "com.pack", "org.apache.apisix.plugin.runner" })
public class CryptoApisixPluginRunnerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(CryptoApisixPluginRunnerApplication.class).web(NONE).run(args);
}
}
注意:关键就是上面的"org.apache.apisix.plugin.runner"包路径。
5.4 Filter开发
总共2个插件:
- java插件该插件用来对数据解密。
- LUA插件该插件用来改写请求body,因为在java插件中无法改写。
定义一个抽象类,实现了通过的功能
public abstract class AbstractDecryptPreFilter implements PluginFilter {
// 具体细节由子类实现
protected abstract void doFilterInternal(HttpRequest request, HttpResponse response, PluginFilterChain chain,
CryptModel cryptModel, CacheModel cache);
// 工具类专门用来读取针对插件的配置信息
@Resource
protected ConfigProcessor configCryptoProcessor;
// 工具类专门用来处理加解密
@Resource
protected CryptoProcessor cryptoProcessor;
// 工具类专门用来判断路径匹配
@Resource
protected PathProcessor pathProcessor ;
// 是否开启了插件功能
protected boolean isEnabled(HttpRequest request, BaseCryptoModel cryptoModel) {
if (request == null || cryptoModel == null) {
return false;
}
return cryptoModel.isEnabled();
}
// 检查请求,对有些请求是不进行处理的比如OPTIONS,HEADER。
protected boolean checkRequest(HttpRequest request, CryptModel cryptModel, CacheModel cache) {
if (isOptionsOrHeadOrTrace(request)) {
return false ;
}
String contentType = request.getHeader("content-type") ;
logger.info("request method: {}, content-type: {}", request.getMethod(), contentType) ;
if (isGetOrPostWithFormUrlEncoded(request, contentType)) {
Optional optionalParams = this.pathProcessor.queryParams(request, cryptModel.getParams()) ;
if (optionalParams.isPresent() && !optionalParams.get().getKeys().isEmpty()) {
cache.getParamNames().addAll(optionalParams.get().getKeys()) ;
return true ;
}
return false ;
}
String body = request.getBody() ;
if (StringUtils.hasLength(body)) {
Body configBody = cryptModel.getBody();
if (this.pathProcessor.match(request, configBody.getExclude())) {
return false ;
}
if (configBody.getInclude().isEmpty()) {
return true ;
} else {
return this.pathProcessor.match(request, configBody.getInclude()) ;
}
}
return false ;
}
private boolean isOptionsOrHeadOrTrace(HttpRequest request) {
return request.getMethod() == Method.OPTIONS ||
request.getMethod() == Method.HEAD ||
request.getMethod() == Method.TRACE ;
}
private boolean isGetOrPostWithFormUrlEncoded(HttpRequest request, String contentType) {
return request.getMethod() == Method.GET ||
(request.getMethod() == Method.POST && PluginConfigConstants.X_WWW_FORM_URLENCODED.equalsIgnoreCase(contentType));
}
// PluginFilter的核心方法,内部实现都交给了子类实现doFilterInternal
@Override
public final void filter(HttpRequest request, HttpResponse response, PluginFilterChain chain) {
BaseCryptoModel cryptoModel = configCryptoProcessor.processor(request, this);
CryptModel model = null ;
if (cryptoModel instanceof CryptModel) {
model = (CryptModel) cryptoModel ;
}
logger.info("model: {}", model);
Assert.isNull(model, "错误的数据模型") ;
CacheModel cache = new CacheModel() ;
// 是否开启了加解密插件功能 && 当前请求路径是否与配置的路径匹配,只有匹配的才进行处理
if (isEnabled(request, cryptoModel) && checkRequest(request, model, cache)) {
this.doFilterInternal(request, response, chain, model, cache);
}
chain.filter(request, response);
}
// 插件中是否需要请求正文
@Override
public Boolean requiredBody() {
return Boolean.TRUE;
}
}
解密插件
@Component
@Order(1)
public class DecryptFilter extends AbstractDecryptPreFilter {
private static final Logger logger = LoggerFactory.getLogger(DecryptFilter.class) ;
@Override
public String name() {
return "Decrypt";
}
@Override
protected void doFilterInternal(HttpRequest request, HttpResponse response, PluginFilterChain chain,
CryptModel cryptModel, CacheModel cache
SecretFacade sf = this.cryptoProcessor.getSecretFacade(request, cryptModel) ;
String body = request.getBody() ;
if (StringUtils.hasLength(body)) {
logger.info("request uri: {}", request.getPath()) ;
// 解密请求body
String plainText = sf.decrypt(body);
request.setBody(plainText) ;
plainText = request.getBody() ;
// 下面设置是为了吧内容传递到lua脚本写的插件中,因为在java插件中无法改写请求body
request.setHeader(PluginConfigConstants.DECRYPT_DATA_PREFIX, Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8))) ;
request.setHeader(PluginConfigConstants.X_O_E, "1") ;
// 必须设置,不然响应内容类型就成了text/plain
request.setHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) ;
}
}
@Override
public Boolean requiredBody() {
return Boolean.TRUE;
}
}
LUA插件
local ngx = ngx;
local core = require "apisix.core"
local plugin_name = "modify-body"
local process_java_plugin_decrypt_data = "p_j_p_decrypt_data_"
local x_o_e_flag = "x-o-e-flag"
local schema = {
}
local metadata_schema = {
}
local _M = {
version = 0.1,
priority = 10,
name = plugin_name,
schema = schema,
metadata_schema = metadata_schema,
run_policy = 'prefer_route',
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
function _M.access(conf, ctx)
-- local cjson = require 'cjson'
-- ngx.req.read_body()
-- local body = ngx.req.get_body_data()
-- ngx.log(ngx.STDERR, "access content: ", body)
end
function _M.rewrite(conf, ctx)
local params, err = ngx.req.get_headers() --ngx.req.get_uri_args()
local flag = params[x_o_e_flag]
ngx.log(ngx.STDERR, "processor body, flag: ", flag)
if flag and flag == '1' then
local plain_data = params[process_java_plugin_decrypt_data]
if plain_data then
local data = ngx.decode_base64(plain_data)
-- 清除附加请求header
ngx.req.set_header(process_java_plugin_decrypt_data, nil)
-- 重写body数据
ngx.req.set_body_data(data)
-- 这里如果计算不准,最好不传
ngx.req.set_header('Content-Length', nil)
end
end
end
function _M.body_filter(conf, ctx)
end
return _M ;
接下来就是将该项目打包成jar。
以上就完成插件的开发,接下来就是配置
5.5 插件配置
Java插件配置
将上一步打包后的jar长传到服务器,在config.yaml中配置插件
ext-plugin:
cmd: ['java', '-Dfile.encoding=UTF-8', '-jar', '/app/plugins/crypto-apisix-plugin-runner-1.0.0.jar']
LUA插件配置
将lua脚本上传到docker容器
docker cp modify-body.lua apisix-java-apisix-1:/usr/local/apisix/apisix/plugins/modify-body.lua
配置该插件
plugins:
- ext-plugin-pre-req
- ext-plugin-post-req
- ext-plugin-post-resp
- modify-body
要想在apisix-dashboard中能够使用,需要导出schema.json文件
docker exec -it apisix-java-apisix-1 curl http://localhost:9092/v1/schema > schema.json
上传该schema.json到apisix-dashboard中
docker cp schema.json apisix-java-apisix-dashboard-1:/usr/local/apisix-dashboard/conf
重启相应服务
docker restart apisix-java-apisix-dashboard-1
docker restart apisix-java-apisix-1
完成以上步骤后,接下来就可以通过dashboard进行路径配置了。
六. 路由配置
这里直接贴插件的配置
"plugins": {
"ext-plugin-pre-req": {
"allow_degradation": false,
"conf": [
{
"name": "Decrypt",
"value": "{"enabled": "true","apiKey": "kzV7HpPsZfTwJnZbyWbUJw==", "alg": "sm", "params": [{"pattern": "/api-1/**", "keys": ["idNo"]}],"body": {"exclude": ["/api-a/**"],"include": ["/api-1/**"]}}"
}
]
},
"modify-body": {},
"proxy-rewrite": {
"regex_uri": [
"^/api-1/(.*)$",
"/$1"
]
}
}
注意:modify-body插件一定要配置,这个是专门用来改写请求body内容的。
到此一个完整的插件就开发完成了,希望本篇文章能够帮到你。如有需要,可提供其它代码。
完毕!!!