• 接口管理工具Apifox在前后端分离项目中的实践


    前言

    最近在做的集团SaaS平台的派车模块,因实际使用中司机无法操作电脑端,所以又开发了派车小程序以方便司机角色去接单、派车和送货签收操作。小程序端直接调用的是后台的派车模块的接口,这就涉及到了前后端分离中的一个痛点-接口的文档维护和接口的联调测试问题。幸好,在这个全民脱贫、码农翻身把歌唱的时代,我们有了比postman更好用的接口管理工具-Apifox

    官方👉[点我直达]给出的介绍:

    Apifox 是接口管理、开发、测试全流程集成工具,定位 Postman + Swagger + Mock + JMeter。通过一套系统、一份数据,解决多个系统之间的数据同步问题。只要定义好接口文档,接口调试、数据 Mock、接口测试就可以直接使用,无需再次定义;接口文档和接口开发调试使用同一个工具,接口调试完成后即可保证和接口文档定义完全一致。高效、及时、准确!

    Apifox在项目中的实践应用

    一、后端接口服务的签名验证规则

    1. 调用 JSON 格式为:

      {
      "accessKey":, //访问key(由系统分配给用户)
      "reqSign":xxxxxxxxxxxxxxxxxxxxxxxxxx, //用一定规则生成的签名
      "timestamp":2022-01-20 13:15:15, //请求时间记录
      "nonce":123456, //小于6位的随机数,用来标识每个被签名的请求
      // "data":{} //查询参数
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    2. Signature 参数签名生成规则:

      ① 按照请求参数名的字母升序排列非空请求参数(包含AccessKey),使用URL键值对的格式(即 key1=value1&key2=value2…)拼接成字符串stringA

      ② 在stringA最后拼接上用户密钥(32位UUID)得到字符串stringSignTemp
      stringSignTemp进行MD5运算,并将得到的字符串所有字符转换为大写,得到Signature 值。

      返回 JSON 格式为:

      {
      "ok":true, //查询是否成功
      "errorCode":null, //错误码
      "errors":null, //错误信息
      “data”: {} //查询结果数据
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

      具体的调用参数和返回结果中 data 的内容各个功能详细描述。

      验证失败的返回结果是:

      {
      "ok":false,
      "errorCode":-1
      "errors":”用户验证失败”
      "data": null
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

      另外错误返回可能还包括:

      -2:服务过期

      -3: 未购买指定的服务

      -4: 内部错误

    二、后端权限过滤器AuthFilter

    权限过滤器:

    package com.jieguan.filter;
    
    import com.jieguan.entity.ParamDTO;
    import com.jieguan.utils.ServiceLicKit;
    import com.yorma.constant.RspCode;
    import io.zbus.rpc.RpcFilter;
    import io.zbus.rpc.annotation.FilterDef;
    import io.zbus.transport.Message;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    import java.util.HashMap;
    
    /**
     * 权限过滤器
     *
     * @author ZHANGCHAO
     * @date 2022/3/31 16:56
     */
    @Slf4j
    @Component("authFilter1")
    @FilterDef("jieguanAuthFilter")
    public class AuthFilter implements RpcFilter {
    
        @Override
        public boolean doFilter(Message request, Message response, Throwable exception) {
            boolean auth = false;
            ParamDTO param = ServiceLicKit.checkParams(request);
            if (!param.isOk()) {
                response.setStatus(RspCode.REQ_ERR);
                response.setBody(param.getErrorMsg());
                return false;
            }
            //校验 NONCE 防重放
            if (!ServiceLicKit.verifyNonce(param.getTimeStamp(), param.getNonce())) {
                response.setStatus(RspCode.UNAUTH);
                response.setHeaders(new HashMap<>());
                response.setBody("校验NONCE未通过,请求拒绝!");
                //校验 URI访问控制
            } else if (!ServiceLicKit.verifyUri(request, param.getLic())) {
                response.setStatus(RspCode.UNAUTH);
                response.setBody("访问受限!");
                //校验 请求签名 防篡改
            } else if (!ServiceLicKit.verifySign(param, request)) {
                response.setStatus(RspCode.UNAUTH);
                response.setBody("非法请求!");
            } else {
                auth = true;
            }
            return auth;
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    权限验证处理类:

    package com.jieguan.utils;
    
    import cn.hutool.core.date.DateUnit;
    import cn.hutool.core.date.DateUtil;
    import com.alibaba.fastjson.JSON;
    import com.alibaba.fastjson.JSONArray;
    import com.alibaba.fastjson.JSONObject;
    import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
    import com.jieguan.config.SpringUtil;
    import com.jieguan.entity.Nonce;
    import com.jieguan.entity.ParamDTO;
    import com.jieguan.entity.ServiceLic;
    import com.jieguan.entity.ServiceLicUrl;
    import com.jieguan.mapper.NonceMapper;
    import com.jieguan.mapper.ServiceLicMapper;
    import com.jieguan.mapper.ServiceLicUrlMapper;
    import com.yorma.util.FileKit;
    import com.yorma.util.MD5Util;
    import com.yorma.util.StringUtil;
    import io.zbus.transport.Message;
    import lombok.extern.slf4j.Slf4j;
    
    import java.io.File;
    import java.util.*;
    
    import static cn.hutool.core.util.ObjectUtil.isEmpty;
    import static cn.hutool.core.util.ObjectUtil.isNotEmpty;
    import static cn.hutool.core.util.StrUtil.isBlank;
    import static cn.hutool.core.util.StrUtil.isNotBlank;
    
    /**
     * AppKeyKit
     *
     * @author 张杰  2021/11/8 10:39
     * @version 1.0
     * @apiNote 
     *   类简介
     * 
    */
    @Slf4j public class ServiceLicKit { public static final String TIMESTAMP = "timestamp"; public static final String NONCE = "nonce"; public static final String MD5 = "MD5"; //摘要算法: SM3/MD5 public static final String SM3 = "SM3"; //摘要算法: SM3/MD5 public static final int MAX_DELAY = 5; // NONCE间隔时间 private static final String ACCESS_KEY = "accessKey"; private static final String SECRET_KEY = "secretKey"; private static final String SIGN = "reqSign"; private static final String BODY_HASH = "bodyHash"; /** * 提前验证参数 * * @param request 请求 * @return com.jieguan.entity.ParamDTO * @author ZHANGCHAO * @date 2022/1/17 22:56 */ public static ParamDTO checkParams(Message request) { ParamDTO param = new ParamDTO(); String accessKey = ServiceLicKit.getKey(ACCESS_KEY, request); String sign = ServiceLicKit.getKey(SIGN, request); String bodyHash = ServiceLicKit.getKey(BODY_HASH, request); ServiceLic lic = ServiceLicKit.getLicByAccessKey(accessKey); String timeStamp = ServiceLicKit.getKey(ServiceLicKit.TIMESTAMP, request); Long nonce; try { nonce = Long.valueOf(ServiceLicKit.getKey(ServiceLicKit.NONCE, request)); } catch (Exception e) { param.setErrorMsg("防重放标识nonce不存在或格式错误!"); return param; } if (isBlank(accessKey)) { param.setErrorMsg("未获取到用户标识AccessKey!"); return param; } if (isBlank(sign)) { param.setErrorMsg("未获取到参数签名sign!"); return param; } if (isBlank(timeStamp)) { param.setErrorMsg("未获取到请求时间戳timestamp!"); return param; } if (isEmpty(nonce)) { param.setErrorMsg("未获取到防重放标识nonce!"); return param; } if (isEmpty(lic)) { param.setErrorMsg("未获取到此用户标识的许可信息!"); return param; } param.setOk(true) .setAccessKey(accessKey) .setSign(sign) .setBodyHash(bodyHash) .setTimeStamp(timeStamp) .setNonce(nonce) .setLic(lic); log.info("[参数检查]最终的Param:" + param); return param; } /** * 查询许可, 根据accessKey * * @param accessKey * @return */ public static ServiceLic getLicByAccessKey(String accessKey) { ServiceLicMapper licMapper = (ServiceLicMapper) SpringUtil.getBean("serviceLicMapper"); ServiceLic serviceLic = licMapper.selectOne(new QueryWrapper<ServiceLic>().lambda() .eq(ServiceLic::getAccessKey, accessKey) .eq(ServiceLic::getIsWhite, true)); if (isEmpty(serviceLic)) { return null; } ServiceLicUrlMapper licUrlMapper = (ServiceLicUrlMapper) SpringUtil.getBean("serviceLicUrlMapper"); List<ServiceLicUrl> serviceLicUrls = licUrlMapper.selectList(new QueryWrapper<ServiceLicUrl>().lambda() .eq(ServiceLicUrl::getLicId, serviceLic.getId())); Set<String> urlSet = new HashSet<>(); if (isNotEmpty(serviceLicUrls)) { for (ServiceLicUrl url : serviceLicUrls) { urlSet.add(url.getLicUrl()); } } serviceLic.setUrlSet(urlSet); return serviceLic; } /** * 取参数或头的属性值(参数优先) * * @param key * @param msg * @return */ public static String getKey(String key, Message msg) { String val = null; if (StringUtil.isNotEmpty(key) && msg != null) { val = msg.getParam(key) == null ? msg.getHeader(key) : msg.getParam(key, String.class); } return val; } /** * 校验 NONCE * * @param timeStamp * @param nonce * @return */ public static boolean verifyNonce(String timeStamp, long nonce) { long betweenTime = DateUtil.between(DateUtil.parseDateTime(timeStamp), new Date(), DateUnit.MINUTE, false); // 超出5分钟时间范围? if (betweenTime > MAX_DELAY || betweenTime < 0) { log.info("[校验NONCE]超出时间范围,请求拒绝!"); return false; } NonceMapper nonceMapper = (NonceMapper) SpringUtil.getBean("nonceMapper"); Nonce nonceRecord = nonceMapper.selectOne(new QueryWrapper<Nonce>().lambda().eq(Nonce::getNonce, nonce)); if (isNotEmpty(nonceRecord)) { log.info("[校验NONCE]已存在的NONCE,请求拒绝!"); nonceRecord.setAttackTimes(isNotEmpty(nonceRecord.getAttackTimes()) ? nonceRecord.getAttackTimes() + 1 : 1); nonceMapper.updateById(nonceRecord); return false; } Nonce nonceNew = new Nonce(); nonceNew.setNonce(nonce).setReqTime(DateUtil.parseDateTime(timeStamp)); nonceMapper.insert(nonceNew); return true; } /** * 访问权限验证 * * @param msg * @param lic * @return */ public static boolean verifyUri(Message msg, ServiceLic lic) { String uri = msg.getUrl(); String queryStr = msg.getQueryString(); uri = uri.replace("?" + queryStr, ""); return isNotEmpty(lic.getUrlSet()) && lic.getUrlSet().contains(uri); } /** * 验证请求签名 * 原文= paramStr[&timeValue][&nonceValue][&bodyHashHEXValue]&secretKeyValue] * paramStr: 请求参数原文(不含‘?’,保持顺序) * bodyHashHEXValue:POST/PUT需要计算 bodyHash值,算法 MD5/SM3, 格式 HEX * secretKeyValue: 根据 accessKey 获取服务端记录的 secretKeyValue * * @param param * @param req * @return */ public static boolean verifySign(ParamDTO param, Message req) { boolean rt = false; String src = isBlank(req.getQueryString()) ? "" : req.getQueryString().replaceFirst("&?" + SIGN + "=[0-9,a-f,A-F]+", ""); String sign = param.getSign(); // 时间戳 if (!src.contains(TIMESTAMP + "=")) { src += "&" + param.getTimeStamp(); } // nonce if (!src.contains(NONCE + "=")) { src += "&" + param.getNonce(); } // bodyHash if (param.getBodyHash() != null && !src.contains(BODY_HASH + "=")) { src += "&" + param.getBodyHash(); } src += "&" + param.getLic().getSecretKey(); // MD5 16byte, SM3 32byte String alg = sign.length() == 64 ? SM3 : MD5; //param.getLic().getAlgorithm();// String localSign = signature(src, alg); String localBodyHash = ""; if (isNotBlank(param.getBodyHash())) { String sourtJson = getSortJson(req.getBody()); log.info("sortJson:" + sourtJson); localBodyHash = signature(sourtJson, alg); } log.info("[src]:" + src); if (!localSign.equalsIgnoreCase(sign)) { log.info("[src]:" + src); log.info("[sign]:" + sign); log.info("[localSign]:" + localSign); } else if (isNotBlank(param.getBodyHash()) && !localBodyHash.equalsIgnoreCase(param.getBodyHash())) { log.info("[bodyHash]:" + param.getBodyHash()); log.info("[localBodyHash]:" + localBodyHash); } else { rt = true; } return rt; } /** * 对请求签名 *

    * MD5{参数串|body串|key} * * @param src * @param alg * @return */ public static String signature(String src, String alg) { // FIXME 原文结构待定 String sign = null; if (StringUtil.isNotEmpty(alg)) { switch (alg.toUpperCase()) { case MD5: sign = MD5Util.MD5Encode(src, "UTF-8"); break; case SM3: sign = SM3Digest.hashHex(src, "UTF-8"); break; default://不支持的算法 log.info("不支持算法:" + alg); } } return sign; } /** * 对单层json进行key字母排序 * * @param json * @return */ public static String getSortJson(Object json) { if (json instanceof JSONArray || json instanceof JSONObject) { return JSONObject.toJSONString(getSortMap(json)); } else if (json instanceof String) { JSONObject jsonObject; try { jsonObject = JSONObject.parseObject((String) json); } catch (Exception e) { throw new RuntimeException("不是 JSON 对象: " + json); } return JSONObject.toJSONString(getSortMap(jsonObject)); } else { throw new RuntimeException("不是 JSON 对象: " + json); } } public static Object getSortMap(Object json) { SortedMap map = new TreeMap(); if (json instanceof JSONArray && !((JSONArray) json).isEmpty() && ((JSONArray) json).get(0) instanceof JSONObject) { JSONArray va = (JSONArray) json; for (int i = 0; i < va.size(); i++) { va.set(i, getSortMap(va.get(i))); } return json; } else if (json instanceof JSONObject) { Iterator<String> iteratorKeys = ((JSONObject) json).keySet().iterator(); while (iteratorKeys.hasNext()) { String key = iteratorKeys.next(); Object value = ((JSONObject) json).get(key); if (value instanceof JSONObject || value instanceof JSONArray) { map.put(key, getSortMap(value)); } else if (value != null) { map.put(key, value); } } return map; } else { return json; } } }

    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322

    三、Apifox编写公共脚本用于前置操作,设置接口请求签名sign

    公共脚本主要用途是实现脚本复用,避免多处重复编写相同功能的脚本

    可以将多处都会用到的相同功能的脚本或者通用的类、方法,放到公共脚本里,然后所有接口直接引用公共脚本即可使用。

    在项目设置里新建一个公共脚本,请求前根据一定规则生成公共的请求头,编写生成签名的代码,可参考官方使用文档,讲的都很详细👉接口签名如何处理

    请添加图片描述

    请添加图片描述

    脚本代码:

    // 设置请求头timestamp
    var moment = require("moment");
    var timestamp = moment().format('YYYY-MM-DD HH:mm:ss')
    console.log(timestamp)
    
    /**
     * 6位随机数
     */
    function getNonce() {
      let nonce = Math.random().toString().slice(-6);
      if (nonce.startsWith("0")) {
        nonce = getNonce();
      }
      return nonce;
    }
    var nonce = getNonce();
    console.log(nonce)
    
    // // 获取 Header 参数对象
    // var headers = pm.request.headers;
    // // 获取 key 为 field1 的 header 参数的值
    // var accessKey = pm.variables.replaceIn(headers.get("accessKey"));
    // console.log(accessKey)
    
    // 存放所有需要用来签名的参数
    let param = {};
    
    // 加入 query 参数
    let queryParams = pm.request.url.query;
    
    queryParams.each(item => {
      // if (item.value !== '') { // 非空参数值的参数才参与签名
      param[item.key] = item.value;
      // }
    });
    
    // 取 key
    let keys = [];
    for (let key in param) {
        // 注意这里,要剔除掉 sign 参数本身
        if (key !== 'sign') {
            keys.push(key);
        }
    }
    
    // 转成键值对
    let paramPair = [];
    for (let i = 0, len = keys.length; i < len; i++) {
        let k = keys[i];
        paramPair.push(k + '=' + encodeURIComponent(param[k])) // urlencode 编码
    }
    
    paramPair.push(timestamp);
    paramPair.push(nonce);
    paramPair.push("cjf9hbd4rln75a58o3tc");
    
    // 最后加上 key
    // paramPair.push("key=" + key);
    
    // 拼接
    let stringSignTemp = paramPair.join('&');
    
    if (queryParams == null || queryParams == '') {
      stringSignTemp = "&" + stringSignTemp;
    }
    console.log(stringSignTemp);
    
    let sign = CryptoJS.MD5(stringSignTemp).toString();
    console.log(sign);
    
    // 方案一:直接修改接口请求的 query 参数,注入 sign,无需使用环境变量。
    // 参考文档:https://www.apifox.cn/help/app/scripts/examples/request-handle/
    // queryParams.upsert({
    //     key: 'sign',
    //     value: sign,
    // });
    
    // 方案二:写入环境变量,此方案需要在接口里设置参数引用环境变量
    // 设置全局变量
    pm.globals.set("reqSign", sign);
    pm.globals.set("timestamp", timestamp);
    pm.globals.set("nonce", nonce);
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83

    四、调用接口,验证权限

    请添加图片描述

    通过Apifox调用接口,成功返回数据,可以在控制台查看调用时发送的参数等信息:

    请添加图片描述

    请添加图片描述

    另外分享一个MD5加密的脚本:

    请添加图片描述

    let password = pm.request.url.query.get('password');
    console.log('原密码:' + password);
    let newPwd = CryptoJS.MD5(password).toString();
    console.log('MD5加密后:' + newPwd);
    pm.request.url.query.upsert({
        key: "password",
        value: newPwd,
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    总结

    作为一款国人开发的工具,Apifox已经很优秀了,起码比postman“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!

    以上

  • 相关阅读:
    CSS笔记-狂神
    分享一下便利店怎么做微信小程序
    【PAT甲级 - C++题解】1013 Battle Over Cities
    vue2 基础指令、事件修饰符
    HarmonyOS学习路之方舟开发框架—学习ArkTS语言(渲染控制 一)
    【zip密码】zip压缩包删除密码方法
    【多线程(四)】线程状态介绍、线程池基本原理、Executors默认线程池、ThreadPoolExecutor线程池
    互联网电视流氓乱收费被市场惩罚,传统品牌合力挤压互联网电视
    2023-09-21 buildroot linux 查看应用的log打印信息,命令cat /var/log/messages
    基于Spring Boot应用@FunctionalInterface注解
  • 原文地址:https://blog.csdn.net/qq_26030541/article/details/126290372