首页 / 帮助文档 / Java动态代理在业务层统一注入权限校验的实践

Java动态代理在业务层统一注入权限校验的实践

业务代码里权限校验最让人头疼的问题不是逻辑复杂,而是散落各处。每个需要鉴权的方法开头都塞一段if判断,要么查数据库要么调缓存,核心业务逻辑被淹没在横切关注点里。更麻烦的是,哪天权限规则变了,比如从只看角色改成还要看数据归属,就得满项目找那些散落的校验代码逐个改。Java动态代理恰好能把这个横切关注点统一抽离出来,在业务层入口处一次性解决权限注入问题。

动态代理解决权限校验的核心思路

动态代理的本质是在运行时动态生成一个代理对象,这个代理对象会拦截对目标对象的所有方法调用。我们可以在拦截逻辑里插入权限校验,校验通过才放行到真实业务方法,不通过直接抛异常或返回拒绝信息。这样一来,业务类本身完全不需要写任何权限相关代码,它只关心自己的业务逻辑。权限校验的逻辑集中在一个地方,修改规则只改一处。

Java里实现动态代理主要有两种方式:JDK自带的Proxy类和CGLIB库。JDK动态代理要求目标类必须实现接口,代理对象会实现同样的接口;CGLIB则通过继承目标类来生成子类代理,不强制要求接口。在业务层做权限注入的场景下,通常业务类都有对应的Service接口,用JDK动态代理就足够,而且它是JDK内置的,不需要额外引入依赖。

具体实现步骤拆解

第一步,定义一个权限校验的注解。注解可以挂在方法上,用来声明该方法需要什么权限。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequirePermission {
    String value();
    String checkType() default "ROLE";
}

value存的是权限标识,比如"user:delete"或者"ADMIN"这样的角色编码。checkType用来区分校验类型,是角色校验还是具体权限校验,方便后续扩展。

第二步,写业务接口和实现类。业务方法上用刚才定义的注解标注所需权限。

public interface UserService {
    void createUser(User user);
    void deleteUser(Long userId);
    User getUser(Long userId);
}

public class UserServiceImpl implements UserService {
    
    @Override
    @RequirePermission("user:create")
    public void createUser(User user) {
        // 纯粹的业务逻辑,不掺杂权限代码
        System.out.println("创建用户:" + user.getName());
    }
    
    @Override
    @RequirePermission("user:delete")
    public void deleteUser(Long userId) {
        System.out.println("删除用户:" + userId);
    }
    
    @Override
    public User getUser(Long userId) {
        // 没有注解的方法不做权限校验
        System.out.println("查询用户:" + userId);
        return new User();
    }
}

注意getUser方法上没有加注解,这意味着它不需要特殊权限,代理拦截到它时会直接放行。这样权限控制的粒度可以精确到方法级别,哪些方法需要校验、需要什么权限,全由注解说了算。

第三步,实现InvocationHandler,这是动态代理的核心。所有对代理对象的方法调用都会进入invoke方法,在这里做权限校验。

public class PermissionInterceptor implements InvocationHandler {
    
    private Object target;
    private PermissionValidator validator;
    
    public PermissionInterceptor(Object target, PermissionValidator validator) {
        this.target = target;
        this.validator = validator;
    }
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RequirePermission annotation = method.getAnnotation(RequirePermission.class);
        
        if (annotation != null) {
            String requiredPermission = annotation.value();
            String checkType = annotation.checkType();
            
            // 从上下文获取当前用户信息,比如从ThreadLocal或SecurityContext
            CurrentUser currentUser = UserContextHolder.getCurrentUser();
            
            if (currentUser == null) {
                throw new PermissionDeniedException("用户未登录");
            }
            
            boolean hasPermission = validator.check(currentUser, requiredPermission, checkType);
            
            if (!hasPermission) {
                throw new PermissionDeniedException(
                    "权限不足,需要权限:" + requiredPermission
                );
            }
        }
        
        // 校验通过,执行真实业务方法
        return method.invoke(target, args);
    }
}

这段代码的逻辑很清晰:拿到被调用的方法,检查它上面有没有RequirePermission注解。没有就直接放行,有就提取注解里的权限信息,从上下文拿到当前用户,调用校验器做权限比对。校验失败抛异常,成功就继续执行。整个过程中业务代码完全不知道权限校验的存在。

第四步,创建代理工厂,方便在项目里获取代理对象。

public class ServiceProxyFactory {
    
    public static  T createProxy(T target, Class interfaceClass, PermissionValidator validator) {
        return (T) Proxy.newProxyInstance(
            interfaceClass.getClassLoader(),
            new Class[]{interfaceClass},
            new PermissionInterceptor(target, validator)
        );
    }
}

使用时只需要一行代码:

UserService userService = ServiceProxyFactory.createProxy(
    new UserServiceImpl(), UserService.class, new PermissionValidator()
);
userService.deleteUser(123L); // 这里会先经过权限校验
权限校验器的设计

上面代码里的PermissionValidator是真正执行权限判断的地方。它的设计可以根据项目实际情况灵活处理。常见的有几种模式:基于角色的校验,直接判断用户是否拥有某个角色;基于权限标识的校验,判断用户是否拥有某个具体的操作权限;还有更复杂的基于数据权限的校验,需要结合方法参数来判断。

public class PermissionValidator {
    
    public boolean check(CurrentUser user, String requiredPermission, String checkType) {
        switch (checkType) {
            case "ROLE":
                return user.getRoles().contains(requiredPermission);
            case "PERMISSION":
                return user.getPermissions().contains(requiredPermission);
            case "DATA_SCOPE":
                // 数据权限校验逻辑,可能需要额外从方法参数中提取数据ID
                return checkDataScope(user, requiredPermission);
            default:
                return false;
        }
    }
    
    private boolean checkDataScope(CurrentUser user, String requiredPermission) {
        // 具体的数据权限校验实现
        return true;
    }
}

这种设计的好处是校验逻辑完全集中,后续要加缓存、要改成从Redis查权限、要接入外部权限系统,都只改这一个类。

结合Spring容器实现自动代理

实际项目里不可能手动给每个Service创建代理,太繁琐。借助Spring的BeanPostProcessor,可以在Bean初始化完成后自动包装成代理对象,对业务代码完全透明。

@Component
public class PermissionProxyProcessor implements BeanPostProcessor {
    
    @Autowired
    private PermissionValidator validator;
    
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        Class beanClass = bean.getClass();
        // 检查类中是否有方法标注了RequirePermission注解
        boolean hasPermissionAnnotation = false;
        for (Method method : beanClass.getMethods()) {
            if (method.isAnnotationPresent(RequirePermission.class)) {
                hasPermissionAnnotation = true;
                break;
            }
        }
        if (hasPermissionAnnotation) {
            // 获取Bean实现的接口
            Class[] interfaces = beanClass.getInterfaces();
            if (interfaces.length > 0) {
                return Proxy.newProxyInstance(
                    beanClass.getClassLoader(),
                    interfaces,
                    new PermissionInterceptor(bean, validator)
                );
            }
        }
        return bean;
    }
}

这个处理器会在每个Bean初始化之后被调用。它检查Bean的方法上有没有RequirePermission注解,有的话就用JDK动态代理包装一层再返回,没有就原样返回。这样业务代码里注入的UserService实际上已经是代理对象了,调用任何方法都会自动经过权限拦截器。

处理异常和统一返回

权限校验失败时抛异常是一种方式,但有时候项目要求返回特定的错误码而不是抛异常。可以在InvocationHandler里做适配。比如项目里统一用Result类包装返回值,权限不足时直接返回一个错误Result,而不是中断执行流。

public class PermissionInterceptor implements InvocationHandler {
    private Object target;
    private PermissionValidator validator;
    // 构造器省略
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RequirePermission annotation = method.getAnnotation(RequirePermission.class);
        if (annotation != null) {
            CurrentUser currentUser = UserContextHolder.getCurrentUser();
            if (currentUser == null) {
                return Result.fail("401", "用户未登录");
            }
            if (!validator.check(currentUser, annotation.value(), annotation.checkType())) {
                return Result.fail("403", "权限不足");
            }
        }
        return method.invoke(target, args);
    }
}

这种处理方式要求所有业务方法的返回值类型一致,或者至少能接受Result类型。如果项目里返回值类型多样,还是抛异常配合全局异常处理器更合适。两种方案各有利弊,根据项目规范选择即可。

方法参数的权限校验

有些场景下权限校验不能只看用户本身,还要看操作的目标对象。比如“只能删除自己创建的用户”,这就需要拿到方法参数里的用户ID,去查这个用户是谁创建的,再跟当前用户比对。动态代理同样能处理这种情况。

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    RequirePermission annotation = method.getAnnotation(RequirePermission.class);
    if (annotation != null) {
        CurrentUser currentUser = UserContextHolder.getCurrentUser();
        // 获取方法参数上的注解,标记哪个参数是目标ID
        Parameter[] parameters = method.getParameters();
        Long targetId = null;
        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(TargetId.class)) {
                targetId = (Long) args[i];
                break;
            }
        }
        if (!validator.checkWithTarget(currentUser, annotation.value(), targetId)) {
            throw new PermissionDeniedException("权限不足");
        }
    }
    return method.invoke(target, args);
}

定义一个TargetId注解标记哪个参数是目标资源ID,代理里提取出来传给校验器。校验器根据ID查出资源归属,跟当前用户比对。这样数据级别的权限控制也能统一在代理层解决,业务方法里完全不需要写归属校验的代码。

性能考量与缓存策略

每次方法调用都做权限校验,如果校验逻辑涉及数据库查询,高并发下会有性能压力。可以在PermissionValidator里加一层缓存。用户登录后把该用户的权限列表加载到本地缓存或Redis里,校验时直接从缓存取。缓存可以设置合理的过期时间,用户权限变更时主动失效缓存。

public class PermissionValidator {
    private LoadingCache> permissionCache = CacheBuilder.newBuilder()
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build(new CacheLoader>() {
            @Override
            public Set load(Long userId) {
                return loadPermissionsFromDB(userId);
            }
        });
    
    public boolean check(CurrentUser user, String requiredPermission, String checkType) {
        Set permissions = permissionCache.get(user.getId());
        return permissions.contains(requiredPermission);
    }
}

用Guava的LoadingCache做本地缓存,五分钟过期,既减少了数据库压力,也保证了权限变更后最多五分钟生效。如果对实时性要求更高,可以用Redis加消息通知来做缓存失效。

与AOP的对比选择

有人会问,Spring AOP也能做同样的事,为什么非要用动态代理。实际上Spring AOP的底层就是动态代理,只不过它封装了一层,用起来更简单。但手写动态代理有几个优势:不依赖Spring容器,可以在非Spring环境里使用;粒度控制更灵活,可以精确到每个方法做不同的处理逻辑;性能上少了一层AOP的拦截链,稍微轻量一点。如果项目本身就是Spring体系,用AOP加注解的方式也完全可行,本质是一样的。理解动态代理的实现原理,有助于更深入地理解AOP的工作机制,遇到问题时也更容易排查。

注意事项和常见坑

JDK动态代理只能代理接口方法,如果业务类没有实现接口,代理会失败。这时候要么给业务类提取接口,要么换成CGLIB。另外,代理对象内部的自我调用不会触发代理逻辑。比如UserServiceImpl里createUser方法内部调用了deleteUser,这个deleteUser的调用不会经过代理,因为this指向的是目标对象本身而不是代理对象。解决方法是注入自己,或者把内部调用拆到不同的Service里。

还有一个点是注解的继承问题。RequirePermission注解默认是不继承的,如果接口方法上有注解而实现类方法上没有,代理通过实现类的方法反射是拿不到注解的。所以注解要加在实现类的方法上,或者用Spring提供的AnnotationUtils工具类去查找合并后的注解。

动态代理统一注入权限校验,本质上是把横切关注点从业务代码里剥离出来,让业务层回归纯粹。权限规则集中管理、集中缓存、集中变更,代码干净了,维护成本也降下来了。这个思路不只适用于权限校验,日志记录、事务管理、参数校验这些横切逻辑都可以用同样的方式处理。