深入探討API網(wǎng)關(guān)APISIX中自定義Java插件在真實(shí)項(xiàng)目中的運(yùn)用
環(huán)境:APISIX3.4.1 + JDK11 + SpringBoot2.7.12
一. APISIX簡介
APISIX 網(wǎng)關(guān)作為所有業(yè)務(wù)的流量入口,它提供了動(dòng)態(tài)路由、動(dòng)態(tài)上游、動(dòng)態(tài)證書、A/B 測試、灰度發(fā)布(金絲雀發(fā)布)、藍(lán)綠部署、限速、防攻擊、收集指標(biāo)、監(jiān)控報(bào)警、可觀測、服務(wù)治理等功能。
為什么使用APISIX?
- 高性能和可擴(kuò)展性:APISIX是基于Nginx和OpenResty構(gòu)建的,具有高性能和可擴(kuò)展性。它支持動(dòng)態(tài)路由、限流、緩存、認(rèn)證等功能,并可以通過插件擴(kuò)展其他功能。
- 社區(qū)活躍,易于使用:APISIX的社區(qū)非?;钴S,提供了完整的文檔,使其易于使用。此外,它也支持類似于Kubernetes中的自動(dòng)化部署,適合對(duì)網(wǎng)絡(luò)部署和管理要求高的團(tuán)隊(duì)。
- 處理API和微服務(wù)流量的強(qiáng)大工具:Apache APISIX是一個(gè)動(dòng)態(tài)、實(shí)時(shí)、高性能的開源API網(wǎng)關(guān),可以快速、安全地處理API和微服務(wù)流量,包括網(wǎng)關(guān)、Kubernetes Ingress和服務(wù)網(wǎng)格等。全球已有數(shù)百家企業(yè)使用Apache APISIX處理關(guān)鍵業(yè)務(wù)流量,涵蓋金融、互聯(lián)網(wǎng)、制造、零售、運(yùn)營商等各個(gè)領(lǐng)域。
- 云原生技術(shù)體系統(tǒng)一:APISIX從底層架構(gòu)上避免了宕機(jī)、丟失數(shù)據(jù)等情況的發(fā)生。在控制面上,APISIX使用了etcd存儲(chǔ)配置信息,這與云原生技術(shù)體系更為統(tǒng)一,能更好地體現(xiàn)高可用特性。
- 實(shí)時(shí)配置更新:使用etcd作為存儲(chǔ)后,APISIX可以在毫秒級(jí)別內(nèi)獲取到最新的配置信息,實(shí)現(xiàn)實(shí)時(shí)生效。相比之下,如果采用輪詢數(shù)據(jù)庫的方式,可能需要5-10秒才能獲取到最新的配置信息。
綜上所述,APISIX是一個(gè)優(yōu)秀的開源API網(wǎng)關(guān),具有高性能、可擴(kuò)展性、社區(qū)活躍、易于使用等特點(diǎn),并且能夠處理API和微服務(wù)流量,避免宕機(jī)、丟失數(shù)據(jù)等問題,實(shí)現(xiàn)實(shí)時(shí)配置更新。因此,許多企業(yè)和團(tuán)隊(duì)選擇使用APISIX作為其API網(wǎng)關(guān)解決方案。
二. APISIX安裝
關(guān)于APISIX的安裝參考官方文檔即可
APISIX安裝指南
https://apisix.apache.org/zh/docs/apisix/installation-guide/
我使用的docker部署
三. 需求說明
- 目的與背景:
由于安全需要,現(xiàn)有系統(tǒng)接口的請(qǐng)求數(shù)據(jù)需要加密(調(diào)用方必須加密傳輸)。
考慮到對(duì)現(xiàn)有系統(tǒng)的最小化影響,決定采用APISIX作為中間件,通過自定義Java插件來實(shí)現(xiàn)數(shù)據(jù)加密的功能。
- 功能需求:
數(shù)據(jù)加密:插件需要能夠接收并解析請(qǐng)求數(shù)據(jù),然后對(duì)數(shù)據(jù)進(jìn)行解密處理(解密后的數(shù)據(jù)再提交到上游服務(wù))。
安全性:加密算法和密鑰管理應(yīng)遵循業(yè)界最佳實(shí)踐,確保數(shù)據(jù)安全。
錯(cuò)誤處理與日志記錄:插件應(yīng)具備良好的錯(cuò)誤處理機(jī)制,并能夠記錄詳細(xì)的日志,以便于問題排查。(這通過記錄日志即可)
非功能需求:
可維護(hù)性:插件代碼應(yīng)清晰、模塊化,便于后續(xù)的維護(hù)和升級(jí)。
可擴(kuò)展性:考慮到未來可能的加密需求變化,插件應(yīng)具備良好的擴(kuò)展性。
四. 插件工作原理
apisix-java-plugin-runner 設(shè)計(jì)為使用 netty 構(gòu)建的 TCP 服務(wù)器,它提供了一個(gè) PluginFilter 接口供用戶實(shí)現(xiàn)。用戶只需關(guān)注其業(yè)務(wù)邏輯,而無需關(guān)注 apisix java 插件運(yùn)行程序如何與 APISIX 通信的細(xì)節(jié);它們之間的進(jìn)程間通信如下圖所示。
圖片
核心運(yùn)行原理
官方的包是基于springboot,所以它自身提供了一個(gè)CommandLineRunner類,該類會(huì)在Springboot容器啟動(dòng)完成后運(yùn)行,也就是下面的地方執(zhí)行:
public class SpringApplication {
public ConfigurableApplicationContext run(String... args) {
// ...這里ApplicationContext等相關(guān)的初始化
callRunners(context, applicationArguments);
}
}
核心Runner類
public class ApplicationRunner implements CommandLineRunner {
private ObjectProvider<PluginFilter> 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);
// 啟動(dòng)netty服務(wù)
start(socketPath.toString());
}
public void start(String path) throws Exception {
EventLoopGroup group;
ServerBootstrap bootstrap = new ServerBootstrap();
// 判斷使用什么channel
bootstrap.group(group).channel(...)
try {
// 初始化netty服務(wù)
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<DomainSocketChannel>() {
@Override
protected void initChannel(DomainSocketChannel channel) {
channel.pipeline().addFirst("logger", new LoggingHandler())
//...
// 核心Handler
.addAfter("payloadDecoder", "prepareConfHandler", createConfigReqHandler(cache, filterProvider, watcherProvider))
// ...
}
});
}
}
五. 插件開發(fā)
5.1 依賴管理
<properties>
<java.version>11</java.version>
<spring-boot.version>2.7.12</spring-boot.version>
<apisix.version>0.4.0</apisix.version>
<keys.version>1.1.4</keys.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.apisix</groupId>
<artifactId>apisix-runner-starter</artifactId>
<version>${apisix.version}</version>
</dependency>
<!-- 封裝了各類加解密功能如:SM,AES,RSA等算法-->
<dependency>
<groupId>com.pack.components</groupId>
<artifactId>pack-keys</artifactId>
<version>${keys.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies>
5.2 配置文件
這里的配置可有可無,都有默認(rèn)值
cache.config:
expired: ${APISIX_CONF_EXPIRE_TIME}
capacity: 1000
socket:
file: ${APISIX_LISTEN_ADDRESS}
5.3 啟動(dòng)類配置
@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);
}
}
注意:關(guān)鍵就是上面的"org.apache.apisix.plugin.runner"包路徑。
5.4 Filter開發(fā)
總共2個(gè)插件:
- java插件
該插件用來對(duì)數(shù)據(jù)解密。 - LUA插件
該插件用來改寫請(qǐng)求body,因?yàn)樵趈ava插件中無法改寫。
定義一個(gè)抽象類,實(shí)現(xiàn)了通過的功能
public abstract class AbstractDecryptPreFilter implements PluginFilter {
// 具體細(xì)節(jié)由子類實(shí)現(xiàn)
protected abstract void doFilterInternal(HttpRequest request, HttpResponse response, PluginFilterChain chain,
CryptModel cryptModel, CacheModel cache);
// 工具類專門用來讀取針對(duì)插件的配置信息
@Resource
protected ConfigProcessor<BaseCryptoModel> 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();
}
// 檢查請(qǐng)求,對(duì)有些請(qǐng)求是不進(jìn)行處理的比如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<Params> 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的核心方法,內(nèi)部實(shí)現(xiàn)都交給了子類實(shí)現(xiàn)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, "錯(cuò)誤的數(shù)據(jù)模型") ;
CacheModel cache = new CacheModel() ;
// 是否開啟了加解密插件功能 && 當(dāng)前請(qǐng)求路徑是否與配置的路徑匹配,只有匹配的才進(jìn)行處理
if (isEnabled(request, cryptoModel) && checkRequest(request, model, cache)) {
this.doFilterInternal(request, response, chain, model, cache);
}
chain.filter(request, response);
}
// 插件中是否需要請(qǐng)求正文
@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()) ;
// 解密請(qǐng)求body
String plainText = sf.decrypt(body);
request.setBody(plainText) ;
plainText = request.getBody() ;
// 下面設(shè)置是為了吧內(nèi)容傳遞到lua腳本寫的插件中,因?yàn)樵趈ava插件中無法改寫請(qǐng)求body
request.setHeader(PluginConfigConstants.DECRYPT_DATA_PREFIX, Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8))) ;
request.setHeader(PluginConfigConstants.X_O_E, "1") ;
// 必須設(shè)置,不然響應(yīng)內(nèi)容類型就成了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)
-- 清除附加請(qǐng)求header
ngx.req.set_header(process_java_plugin_decrypt_data, nil)
-- 重寫body數(shù)據(jù)
ngx.req.set_body_data(data)
-- 這里如果計(jì)算不準(zhǔn),最好不傳
ngx.req.set_header('Content-Length', nil)
end
end
end
function _M.body_filter(conf, ctx)
end
return _M ;
接下來就是將該項(xiàng)目打包成jar。
以上就完成插件的開發(fā),接下來就是配置
5.5 插件配置
Java插件配置
將上一步打包后的jar長傳到服務(wù)器,在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中能夠使用,需要導(dǎo)出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
重啟相應(yīng)服務(wù)
docker restart apisix-java-apisix-dashboard-1
docker restart apisix-java-apisix-1
完成以上步驟后,接下來就可以通過dashboard進(jìn)行路徑配置了。
六. 路由配置
這里直接貼插件的配置
"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插件一定要配置,這個(gè)是專門用來改寫請(qǐng)求body內(nèi)容的。
到此一個(gè)完整的插件就開發(fā)完成了,希望本篇文章能夠幫到你。如有需要,可提供其它代碼。
完畢!??!