Spring Boot(0):3种方式动态屏蔽API返回的JSON数据字段

8,137 阅读6分钟

Spring Boot版本:2.3.0

JSON序列化库:Jackson

Demo的Github地址

在日常的API开发中我们可能会有这样的需求:将一个对象转为JSON数据返回给前端时,里面的某些字段需要根据不同的业务场景进行屏蔽。

假设现在这个需求是这样的,用户的个人信息保存在UserProfile对象中:

public class UserProfile {
    // 用户名
    private String username;

    // 地址
    private Address address;

    public UserProfile(String username, Address address) {
        this.username = username;
        this.address = address;
    }

    public String getUsername() {
        return username;
    }
    
    public Address getAddress() {
        return address;
    }
}

我们希望在Address对象中的家庭详细地址只有用户本人才能看到,而用户所在的身份与城市则是公开的,任何人都可以看到:


public class Address {
    // 省
    private String province;

    // 市
    private String city;

    // 家庭详细地址
    private String detailed;

    public Address(String province, String city, String detailed) {
        this.province = province;
        this.city = city;
        this.detailed = detailed;
    }

    public String getProvince() {
        return province;
    }

    public String getCity() {
        return city;
    }

    public String getDetailed() {
        return detailed;
    }
}

然后我们编写下面的Controller用于返回UserProfile

public class UserController {
    private final UserProfile userProfile = new UserProfile("sword4j", new Address("湖北", "武汉市", "光谷大道xx小区xx号"));

    @GetMapping("/profile")
    public UserProfile getDefaultInfo() {
        return userProfile;
    }
}

默认情况下访问/profile,它会返回所有字段给前端:

{"username":"sword4j","address":{"province":"湖北","city":"武汉市","detailed":"光谷大道xx小区xx号"}}

现在我们开始实现前面提出的需求,对于该需求至少有三个方式可以实现。

方式1:DTO对象

第一个方法是对detailed字段进行“物理阉割”,我们通过新增一个与前端要求JSON数据结构一致的DTO对象,然后在这个DTO对象中去掉detailed字段:

public class UserProfileWithoutDetailedAddressDTO {
    private String username;
    private AddressDTO address;

    public static class AddressDTO {
        private String province;
        private String city;

        public AddressDTO(String province, String city) {
            this.province = province;
            this.city = city;
        }

        public String getProvince() {
            return province;
        }

        public String getCity() {
            return city;
        }
    }

    private UserProfileWithoutDetailedAddressDTO(String username, AddressDTO address) {
        this.username = username;
        this.address = address;
    }

    public static UserProfileWithoutDetailedAddressDTO from(UserProfile userProfile) {
        Address address = userProfile.getAddress();
        return new UserProfileWithoutDetailedAddressDTO(userProfile.getUsername(),
                new AddressDTO(address.getProvince(), address.getCity()));
    }

    public String getUsername() {
        return username;
    }

    public AddressDTO getAddress() {
        return address;
    }
}

Controller中的改动如下:

@RestController
public class UserController {
    private final UserProfile userProfile = new UserProfile("sword4j", new Address("湖北", "武汉市", "光谷大道xx小区xx号"));

    @GetMapping("/profile-using-dto")
    public UserProfileWithoutDetailedAddressDTO getProfileUsingDTO() {
        return UserProfileWithoutDetailedAddressDTO.from(userProfile);
    }
}

通过访问/profile-using-dto,可以看到输出的JSON数据满足我们的需求:

{"username":"sword4j","address":{"province":"湖北","city":"武汉市"}}

方式2:清空字段值

第二个方法是对detailed字段进行“化学阉割”,我们复用之前的UserProfileAddress对象,但是在返回数据给前端之前需要把detailed字段设为null

@RestController
public class UserController {
    private final UserProfile userProfile = new UserProfile("sword4j", new Address("湖北", "武汉市", "光谷大道xx小区xx号"));

    @GetMapping("/profile-using-null")
    public UserProfile getProfileUsingNull() {
        // 把detailed字段设为null
        Address address = new Address(userProfile.getAddress().getProvince(),
                userProfile.getAddress().getCity(), null);
        return new UserProfile(userProfile.getUsername(), address);
    }
}

通过访问/profile-using-null,可以看到输出的JSON数据也满足需求:

{"username":"sword4j","address":{"province":"湖北","city":"武汉市","detailed":null}}

但是响应结果中的detailed只能看不能用,有点讨厌,下面我们把它隐藏掉。

我们在Address类上加上注解@JsonInclude(JsonInclude.Include.NON_NULL)。它表示把对象转为JSON时,只处理值不为null的字段。

@JsonInclude(JsonInclude.Include.NON_NULL)
public class Address {
    // 省略内部代码
}

上面这种方式可以实现在局部隐藏值为null的字段,如果想进行全局的配置,我们可以在application.properties文中添加:

spring.jackson.default-property-inclusion=non_null

我们再来看下响应结果,现在detailed字段也消失了:

{"username":"sword4j","address":{"province":"湖北","city":"武汉市"}}

方式3:@JsonView

第三个方法就是Jackson提供的@JsonView注解,下面介绍如何使用它实现我们的需求。

@JsonView的定义如下:

public @interface JsonView {
    Class<?>[] value() default {};
}

我们对UserProfile类做一些改变:


public class UserProfile {
    public interface PublicView {}
    public interface PrivateView extends PublicView {}

    // 用户名所有人可见
    @JsonView(PublicView.class)
    private String username;

    // 支持对象嵌套
    @JsonView(PublicView.class)
    private Address address;
    
    // 省略构造器与Getter方法
}

在上面的代码中,我们添加了两个空接口:PublicViewPrivateView

PublicView用于标识可以被所有人访问的字段。

PrivateView用于表示自能被自己访问的字段。因为它继承自PublicView,所以它也包括了被PublicView标记的字段,换句话说,它可以看见所有字段。

我们在字段username上用注解@JsonView(PublicView.class)进行标记。

另外笔者发现,在字段address上用@JsonView(PublicView.class)或者@JsonView(PrivateView.class)标记都可以实现对address字段的正确解析。但是仅仅使用不带参数的@JsonView或者不对这个字段进行标记都会导致address字段被屏蔽掉。

此外,我们也要对Address类进行一些改变:

public class Address {
    @JsonView(UserProfile.PublicView.class)
    private String province;

    @JsonView(UserProfile.PublicView.class)
    private String city;

    @JsonView(UserProfile.PrivateView.class)
    private String detailed;
    
    // 省略构造器与Getter方法
}

最后在Controller中对于的方法上也要标记@JsonView

@RestController
public class UserController {
    private final UserProfile userProfile = new UserProfile("sword4j", new Address("湖北", "武汉市", "光谷大道xx小区xx号"));

    @GetMapping("/profile-public-view")
    @JsonView(UserProfile.PublicView.class)
    public UserProfile getPublicInfo() {
        return userProfile;
    }

    @GetMapping("/profile-private-view")
    @JsonView(UserProfile.PrivateView.class)
    public UserProfile getPrivateInfo() {
        return userProfile;
    }

    @GetMapping("/profiles/public-view")
    @JsonView(UserProfile.PublicView.class)
    public List<UserProfile> getPublicInfoOnList() {
        return Collections.singletonList(userProfile);
    }

    @GetMapping("/profiles/private-view")
    @JsonView(UserProfile.PrivateView.class)
    public List<UserProfile> getPrivateInfoOnList() {
        return Collections.singletonList(userProfile);
    }
}

访问被@JsonView(UserProfile.PublicView.class)标注的/profile-public-view会返回结果:

{"username":"sword4j"}

访问被@JsonView(UserProfile.PrivateView.class)标注的/profile-private-view会返回结果:

{"username":"sword4j","address":{"province":"湖北","city":"武汉市","detailed":"光谷大道xx小区xx号"}}

另外对于返回List<UserProfile>的情况,@JsonView也是可以生效的。

访问/profiles/public-view会返回结果:

[{"username":"sword4j"}]

访问/profiles/private-view会返回结果:

[{"username":"sword4j","address":{"province":"湖北","city":"武汉市","detailed":"光谷大道xx小区xx号"}}]

总结

本文介绍了对API返回的JSON数据字段进行屏蔽的3种方式,在使用的场景与便利性方面这三种方式各有不同:

  • 第一个方式需要新增一个或多个DTO对象,这种方式在多种使用场景下都可以使用,不仅可以用于屏蔽字段,还可用于:重组字段、字段重命名、展平字段等场景,它主要用于一个DTO对象是由多个业务值对象组合而成的情况。这种方式目前笔者最近在项目中用的比较多,它的缺点是编码工作较多,但是在配合上Lombok时也还不错;
  • 第二种方式里面有点“打补丁”的意味,手动编码实现的方式有点丑陋,这种方式笔者不是很喜欢,笔者认为它只适用于一些简单的业务场景;
  • 第三种方式笔者把它理解为是第二种方式的一种自动化流程,它与第二种方式的使用场景类似,但是在编码实现上要比第二种方式漂亮一些。