• 相信大家看了肯定有收获!

    日志脱敏场景简介

    在日志里我们的日志一般打印的是 model 的 Json string,比如有以下 model 类

    public class Request {
        /**
         *  用户姓名
         */

        private String name;
        /**
         *  身份证 
         */

        private String idcard;
        /**
         *  手机号
         */

        private String phone;

        /**
         *  图片的 base64
         */

        private String imgBase64;
    }

    有以下类实例

    Request request = new Request();
    request.setName("爱新觉罗");
    request.setIdcard("450111112222");
    request.setPhone("18611111767");
    request.setImgBase64("xxx");

    我们一般使用 fastJson 来打印此 Request 的 json string:

    log.info(JSON.toJSONString(request));

    这样就能把 Request 的所有属性值给打印出来,日志如下:

    {"idcard":"450111112222","imgBase64":"xxx","name":"张三","phone":"17120227942"}

    这里的日志有两个问题

    {"idcard":"450******222","name":"爱**罗","phone":"186****1767","imgBase64":""}

    可以看到各个字段最后都脱敏了,不过需要注意的这几个字段的脱敏规则是不一样的

    该怎么实现呢,首先我们需要知道一个知识点,即 JSON.toJSONString 方法指定了一个参数 ValueFilter,可以定制要转化的属性。我们可以利用此 Filter 让最终的 JSON string 不展示或展示脱敏后的 value。大概逻辑如下

    public class Util {
        public static String toJSONString(Object object) {
            try {
                return JSON.toJSONString(object, getValueFilter());
            } catch (Exception e) {
                return ToStringBuilder.reflectionToString(object);
            }
        }
        
    private static ValueFilter getValueFilter() {
            return (obj, key, value) -> {
                // obj-对象 key-字段名 value-字段值
                return  格式化后的value
            };
    }

    如上图示,我们只要在 getValueFilter 方法中对 value 作相关的脱敏操作,即可在最终的日志中展示脱敏后的日志。现在问题来了,该怎么处理字段的脱敏问题,我们知道有些字段需要脱敏,有些字段不需要脱敏,所以有人可能会根据 key 的名称来判断是否脱敏,代码如下:

    private static ValueFilter getValueFilter() {
            return (obj, key, value) -> {
                // obj-对象 key-字段名 value-字段值
                if (Objects.equal(key, "phone")) {
                    return 脱敏后的phone
                }
                if (Objects.equal(key, "idcard")) {
                    return 脱敏后的idcard
                }
                if (Objects.equal(key, "name")) {
                    return 脱敏后的name
                }
                // 其余不需要脱敏的按原值返回
                return  value
            };
    }

    这样看起来确实实现了需求,但仅仅实现了需求就够了吗,这样的实现有个比较严重的问题:

    脱敏规则与具体的属性名紧藕合,需要在 valueFilter 里写大量的 if else 判断逻辑,可扩展性不高,通用性不强,举个简单的例子,由于业务原因,在我们的工程中电话有些字段名叫 phone, 有些叫 tel,有些叫 telephone,它们的脱敏规则是一样的,但你不得不在上面的方法中写出如下丑陋的代码。

    private static ValueFilter getValueFilter() {
            return (obj, key, value) -> {
                // obj-对象 key-字段名 value-字段值
                if (Objects.equal(key, "phone") || Objects.equal(key, "tel") || Objects.equal(key, "telephone") || ) {
                    return 脱敏后的phone
                }

                // 其余不需要脱敏的按原值返回
                return  value
            };
    }

    那么能否用一种通用的,可扩展性好的方法来解决呢,相信你看到文章的标题已经心中有数了,没错,就是用的注解,接下来我们来看看什么是注解以及如何自定义注解

    注解的定义与实现原理

    注解(Annotation)又称  Java 标注,是 JDK 5.0 引入的一种注释机制,如果说代码的注释是给程序员看的,那么注解就是给程序看的,程序看到注解后就可以在运行时拿到注解,根据注解来增强运行时的能力,常见的应用在代码中的注解有如下三个

    那这些注解是怎么实现的呢,我们打开 @Override 这个注解看看

    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})
    public @interface Deprecated {
    }

    可以看到 Deprecated 注解上又有 @Documented, @Retention, @Target 这些注解,这些注解又叫元注解,即注解 Deprecated 或其他自定义注解的注解,其他注解的行为由这些注解来规范和定义,这些元注解的类型及作用如下

    再来看 @interface, 这个是干啥用的,其实如果你反编译之后就会发现在字节码中编译器将其编码成了如下内容。

    public interface Override extends Annotation {   
    }

    Annotation 是啥

    我们可以看出注解的本质其实是继承了 Annotation 这个接口的接口,并且辅以 Retention,Target 这些规范注解运行时行为,作用域等的元注解。

    Deprecated 注解中没有定义属性,其实如果需要注解是可以定义属性的,比如 Deprecated 注解可以定义一个 value 的属性,在声明注解的时候可以指定此注解的 value 值

    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})
    public @interface Deprecated {
        String value() default "";
    }

    这样我将此注解应用于属性等地方时,可以指定此 value  值,如下所示

    public class Person {
        @Deprecated(value = "xxx")
        private String tail;
    }

    如果注解的保存策略为 RetentionPolicy.RUNTIME,我们就可以用如下方式在运行时获取注解,进而获取注解的属性值等

    field.getAnnotation(Deprecated.class);

    巧用注解解决日志脱敏问题

    上文简述了注解的原理与写法,接下来我们来看看如何用注解来实现我们的日志脱敏。

    首先我们要定义一下脱敏的注解,由于此注解需要在运行时被取到,所以保存策略要为 RetentionPolicy.RUNTIME,另外此注解要应用于 phone,idcard 这些字段,所以@Target 的值为 ElementType.FIELD,另外我们注意到,像电话号码,身份证这些字段虽然都要脱敏,但是它们的脱敏策略不一样,所以我们需要为此注解定义一个属性,这样可以指定它的属性属于哪种脱敏类型,我们定义的脱敏注解如下:

    // 敏感信息类型
    public enum SensitiveType {
        ID_CARD, PHONE, NAME, IMG_BASE64
    }

    @Target({ ElementType.FIELD })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SensitiveInfo {
        SensitiveType type();
    }

    定义好了注解,现在就可以为我们的敏感字段指定注解及其敏感信息类型了,如下

    public class Request {
        @SensitiveInfo(type = SensitiveType.NAME)
        private String name;
        @SensitiveInfo(type = SensitiveType.ID_CARD)
        private String idcard;
        @SensitiveInfo(type = SensitiveType.PHONE)
        private String phone;
        @SensitiveInfo(type = SensitiveType.IMG_BASE64)
        private String imgBase64;
    }

    为属性指定好了注解,该怎么根据注解来实现相应敏感字段类型的脱敏呢,可以用反射,先用反射获取类的每一个 Field,再判定 Field 上是否有相应的注解,若有,再判断此注解是针对哪种敏感类型的注解,再针对相应字段做相应的脱敏操作,直接上代码,注释写得很清楚了,相信大家应该能看懂

    private static ValueFilter getValueFilter() {
            return (obj, key, value) -> {
                // obj-对象 key-字段名 value-字段值
                try {
                    // 通过反射获取获取每个类的属性
                    Field[] fields = obj.getClass().getDeclaredFields();
                    for (Field field : fields) {
                        if (!field.getName().equals(key)) {
                            continue;
                        }
                        // 判定属性是否有相应的 SensitiveInfo 注解
                        SensitiveInfo annotation = field.getAnnotation(SensitiveInfo.class);
                        // 若有,则执行相应字段的脱敏方法
                        if (null != annotation) {
                            switch (annotation.type()) {
                                case PHONE:
                                    return 电话脱敏;
                                case ID_CARD:
                                    return 身份证脱敏;
                                case NAME:
                                    return 姓名脱敏;
                                case IMG_BASE64:
                                    return ""// 图片的 base 64 不展示,直接返回空
                                default:
                                    // 这里可以抛异常
                            }
                        }
                        }
                    }
                } catch (Exception e) {
                    log.error("To JSON String fail", e);
                }
                return value;
            };
        }

    有人可能会说了,使用注解的方式来实现脱敏代码量翻了一倍不止,看起来好像不是很值得,其实不然,之前的方式,脱敏规则与某个字段名强藕合,可维护性不好,而用注解的方式,就像工程中出现的 phone, tel,telephone 这些都属于电话脱敏类型的,只要统一标上  @SensitiveInfo(type = SensitiveType.PHONE)  这样的注解即可,而且后续如有新的脱敏类型,只要重新加一个 SensitiveType 的类型即可,可维护性与扩展性大大增强。所以在这类场景中,使用注解是强烈推荐的。

    注解的高级应用-利用注解消除重复代码

    在与银行对接的过程中,银行提供了一些 API 接口,对参数的序列化有点特殊,不使用 JSON,而是需要我们把参数依次拼在一起构成一个大字符串。

    1、创建用户

    2、支付接口一文学会注解的正确使用姿势-LMLPHP

    常规的做法是为每个接口都根据之前的规则填充参数,拼接,验签,以以上两个接口为例,先看看常规做法

    创建用户与支付的请求如下:

    // 创建用户 POJO
    @Data
    public class CreateUserRequest 
        private String name; 
        private String identity; 
        private String mobile;
        private int age;
    }

    // 支付 POJO
    @Data
    public class PayRequest 
        private long userId; 
        private BigDecimal amount;
    }

    public class BankService {

        //创建用户方法
        public static String createUser(CreateUserRequest request) throws IOException {
            StringBuilder stringBuilder = new StringBuilder();
            //字符串靠左,多余的地方填充_
            stringBuilder.append(String.format("%-10s", request.getName()).replace(' ''_'));
            //字符串靠左,多余的地方填充_
            stringBuilder.append(String.format("%-18s", request.getIdentity()).replace(' ''_'));
            //数字靠右,多余的地方用0填充
            stringBuilder.append(String.format("%05d", age));
            //字符串靠左,多余的地方用_填充
            stringBuilder.append(String.format("%-11s", mobile).replace(' ''_'));
            //最后加上MD5作为签名
            stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
            return Request.Post("http://baseurl/createUser")
                    .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
                    .execute().returnContent().asString();
        }
        
        //支付方法
        public static String pay(PayRequest request) throws IOException {
            StringBuilder stringBuilder = new StringBuilder();
            //数字靠右,多余的地方用0填充
            stringBuilder.append(String.format("%020d", request.getUserId()));
            //金额向下舍入2位到分,以分为单位,作为数字靠右,多余的地方用0填充
     stringBuilder.append(String.format("%010d",request.getAmount().setScale(2,RoundingMode.DOWN).multiply(new                                                                                  BigDecimal("100")).longValue())); 
            //最后加上MD5作为签名
            stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
            return Request.Post("http://baseurl//pay")
                    .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
                    .execute().returnContent().asString();
        }
    }

    可以看到光写这两个请求,逻辑就有很多重复的地方:

    1、 字符串,货币,数字三种类型的格式化逻辑大量重复,以处理字符串为例

    2、 处理流程中字符串拼接、加签和发请求的逻辑,在所有方法重复。

    3、 由于每个字段参与拼接的顺序不一样,这些需要我们人肉硬编码保证这些字段的顺序,维护成本极大,而且很容易出错,想象一下如果参数达到几十上百个,这些参数都需要按一定顺序来拼接,如果要人肉来保证,很难保证正确性,而且重复工作太多,得不偿失

    接下来我们来看看如何用注解来极大简化我们的代码。

    1、 首先对于每一个调用接口来说,它们底层都是需要请求网络的,只是请求方法不一样,针对这一点 ,我们可以搞一个如下针对接口的注解

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Documented
    @Inherited
    public @interface BankAPI { 
        String url() default "";
        String desc() default ""
    }

    这样在网络请求层即可统一通过注解获取相应接口的方法名

    2、 针对每个请求接口的 POJO,我们注意到每个属性都有 类型(字符串/数字/货币)长度顺序这三个属性,所以可以定义一个注解,包含这三个属性,如下

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.FIELD)
    @Documented
    @Inherited
    public @interface BankAPIField {
        int order() default -1;
        int length() default -1;
        String type() default ""// M代表货币,S代表字符串,N代表数字
    }

    接下来我们将上文中定义的注解应用到上文中的请求 POJO 中

    对于创建用户请求

    @BankAPI(url = "/createUser", desc = "创建用户接口")
    @Data
    public class CreateUserAPI extends AbstractAPI {
        @BankAPIField(order = 1, type = "S", length = 10)
        private String name;
        @BankAPIField(order = 2, type = "S", length = 18)
        private String identity;
        @BankAPIField(order = 4, type = "S", length = 11//注意这里的order需要按照API表格中的顺序
        private String mobile;
        @BankAPIField(order = 3, type = "N", length = 5)
        private int age;
    }

    对于支付接口

    @BankAPI(url = "/bank/pay", desc = "支付接口")
    @Data
    public class PayAPI extends AbstractAPI {
        @BankAPIField(order = 1, type = "N", length = 20)
        private long userId;
        @BankAPIField(order = 2, type = "M", length = 10)
        private BigDecimal amount;
    }

    接下来利用注解来调用的流程如下

    代码如下:


    private static String remoteCall(AbstractAPI api) throws IOException {
        //从BankAPI注解获取请求地址
        BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
        bankAPI.url();
        StringBuilder stringBuilder = new StringBuilder();
        Arrays.stream(api.getClass().getDeclaredFields()) //获得所有字段
                .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段
                .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根据注解中的order对字段排序
                .peek(field -> field.setAccessible(true)) //设置可以访问私有字段
                .forEach(field -> {
                    //获得注解
                    BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
                    Object value = "";
                    try {
                        //反射获取字段值
                        value = field.get(api);
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                    //根据字段类型以正确的填充方式格式化字符串
                    switch (bankAPIField.type()) {
                        case "S": {
                            stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ''_'));
                            break;
                        }
                        case "N": {
                            stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ''0'));
                            break;
                        }
                        case "M": {
                            if (!(value instanceof BigDecimal))
                                throw new RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field));
                            stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
                            break;
                        }
                        default:
                            break;
                    }
                });
        //签名逻辑
        stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
        String param = stringBuilder.toString();
        long begin = System.currentTimeMillis();
        //发请求
        String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url())
                .bodyString(param, ContentType.APPLICATION_JSON)
                .execute().returnContent().asString();
        log.info("调用银行API {} url:{} 参数:{} 耗时:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin);
        return result;
    }

    现在再来看一下创建用户和付款的逻辑


    //创建用户方法
    public static String createUser(CreateUserAPI request) throws IOException {
        return remoteCall(request);
    }
    //支付方法
    public static String pay(PayAPI request) throws IOException {
        return remoteCall(request);
    }

    可以看到所有的请求现在都只要统一调用  remoteCall 这个方法即可,remoteCall 这个方法统一了所有请求的逻辑,省略了巨量无关的代码,让代码的可维护性大大增强!使用注解和反射让我们可以对这类结构性的问题进行通用化处理,确实 Cool!

    总结

    如果说反射给了我们在不知晓类结构的情况下按照固定逻辑处理类成员的能力的话,注解则是扩展补充了这些成员的元数据的能力,使用得我们在利用反射实现通用逻辑的时候,可以从外部获取更多我们关心的数据,进而对这些数据进行通用的处理,巧用反射,确实能让我们达到事半功倍的效果,能极大的减少重复代码,有效解藕,使扩展性大大提升。



    一文学会注解的正确使用姿势-LMLPHP

    一文学会注解的正确使用姿势-LMLPHP


    本文分享自微信公众号 - Java建设者(javajianshe)。
    如有侵权,请联系 [email protected] 删除。
    本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

    09-03 16:06