深入探讨API网关APISIX中自定义Java插件在真实项目中的运用

2024年 1月 18日 65.2k 0

环境:APISIX3.4.1 + JDK11 + SpringBoot2.7.12

一. APISIX简介

APISIX 网关作为所有业务的流量入口,它提供了动态路由、动态上游、动态证书、A/B 测试、灰度发布(金丝雀发布)、蓝绿部署、限速、防攻击、收集指标、监控报警、可观测、服务治理等功能。

为什么使用APISIX?

  • 高性能和可扩展性:APISIX是基于Nginx和OpenResty构建的,具有高性能和可扩展性。它支持动态路由、限流、缓存、认证等功能,并可以通过插件扩展其他功能。
  • 社区活跃,易于使用:APISIX的社区非常活跃,提供了完整的文档,使其易于使用。此外,它也支持类似于Kubernetes中的自动化部署,适合对网络部署和管理要求高的团队。
  • 处理API和微服务流量的强大工具:Apache APISIX是一个动态、实时、高性能的开源API网关,可以快速、安全地处理API和微服务流量,包括网关、Kubernetes Ingress和服务网格等。全球已有数百家企业使用Apache APISIX处理关键业务流量,涵盖金融、互联网、制造、零售、运营商等各个领域。
  • 云原生技术体系统一:APISIX从底层架构上避免了宕机、丢失数据等情况的发生。在控制面上,APISIX使用了etcd存储配置信息,这与云原生技术体系更为统一,能更好地体现高可用特性。
  • 实时配置更新:使用etcd作为存储后,APISIX可以在毫秒级别内获取到最新的配置信息,实现实时生效。相比之下,如果采用轮询数据库的方式,可能需要5-10秒才能获取到最新的配置信息。
  • 综上所述,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内容的。

    到此一个完整的插件就开发完成了,希望本篇文章能够帮到你。如有需要,可提供其它代码。

    完毕!!!

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论