深入解析Spring SpEL中的PropertyAccessor机制与自定义实现

📅 发布时间:2026/7/11 8:51:33 👁️ 浏览次数:
深入解析Spring SpEL中的PropertyAccessor机制与自定义实现
1. 从Value说起SpEL表达式背后的“翻译官”大家好我是老张在Java和Spring生态里摸爬滚打了十来年。今天咱们不聊那些高大上的架构就聊聊一个大家几乎天天用但可能没深究过的东西Spring的Value注解。你是不是也经常这么写Value(${server.port})或者Value(#{userService})前者从配置文件里读端口后者直接从Spring容器里捞一个Bean出来。你有没有好奇过Spring是怎么做到这一点的它怎么知道${}要去读配置文件而#{}里面又能干那么多花样比如调用方法、访问属性甚至做运算这背后的大功臣就是Spring Expression Language简称SpEL。你可以把它理解成一个超级智能的“翻译官”。你写下一串字符串表达式SpEL引擎负责把它“翻译”成Java世界里实实在在的值或对象。但这个翻译官不是凭空工作的它需要一个“工作台”和一套“工具手册”。这个工作台就是今天的主角——EvaluationContext求值上下文。而工具手册里有一类非常重要的工具叫做PropertyAccessor属性访问器。简单说当SpEL表达式里出现一个点号.比如user.name或者一个简单的属性名SpEL引擎就会在EvaluationContext这个工作台上翻出PropertyAccessor这本工具手册看看谁能处理这个“点”后面的东西。所以理解PropertyAccessor就等于拿到了自定义SpEL行为的一把钥匙。你可以告诉SpEL“嘿下次遇到这种格式的属性别用你默认的反射那套了用我写的逻辑来读” 这能干嘛太有用了比如你可以让SpEL直接从Redis、从数据库、从一个特定的加密配置文件、甚至从某个远程服务接口里读取属性值。今天我就带你彻底搞懂这个机制并手把手教你实现自己的PropertyAccessor。2. 庖丁解牛StandardEvaluationContext与PropertyAccessor2.1 StandardEvaluationContextSpEL的“运行时环境”原始文章里提到了StandardEvaluationContext它是EvaluationContext接口的默认实现也是我们最常打交道的类。你可以把它想象成SpEL表达式执行时的**“运行时环境”或者“沙箱”**。这个环境里配置了所有SpEL引擎需要知道的信息。我们来看看这个环境里都有些什么“家具”根对象 (Root Object)这是表达式计算的起点。比如你有一个User对象作为根表达式name就会从这个User对象开始找name属性。变量 (Variables)通过setVariable方法放入的一些临时值在表达式里用#变量名来引用。比如context.setVariable(currentUser, user)表达式里就可以用#currentUser.age。函数 (Functions)通过registerFunction方法注册的静态方法可以在表达式里直接调用非常强大。属性访问器列表 (PropertyAccessors)这就是我们今天要拆解的核心。它是一个ListPropertyAccessorSpEL会按顺序遍历这个列表为表达式中的属性解析寻找合适的“处理器”。当你创建一个StandardEvaluationContext时Spring已经默认放入了一些“开箱即用”的PropertyAccessor比如ReflectivePropertyAccessor它用Java反射来访问对象的字段和getter方法。所以你写#{user.name}默认就是靠它通过反射调用user.getName()来拿到值的。2.2 PropertyAccessor接口一个标准的“处理器”协议PropertyAccessor是一个接口定义了“属性访问器”需要遵守的协议。我们仔细看看它的每个方法这就像一份“开发手册”public interface PropertyAccessor { // 1. 我能处理哪些类型的“目标对象” Nullable Class?[] getSpecificTargetClasses(); // 2. 对于这个目标对象和属性名我能“读”吗 boolean canRead(EvaluationContext context, Nullable Object target, String name) throws AccessException; // 3. 具体执行“读”操作的逻辑 TypedValue read(EvaluationContext context, Nullable Object target, String name) throws AccessException; // 4. 对于这个目标对象和属性名我能“写”吗 boolean canWrite(EvaluationContext context, Nullable Object target, String name) throws AccessException; // 5. 具体执行“写”操作的逻辑 void write(EvaluationContext context, Nullable Object target, String name, Nullable Object newValue) throws AccessException; }我来给你翻译一下getSpecificTargetClasses(): 这个方法回答“我是为谁服务的”。比如我写的访问器只处理java.util.Properties类型的对象那我就返回new Class[]{Properties.class}。如果返回null或空数组表示我是一个“通用”的访问器愿意尝试处理任何类型但通常会在canRead里做更精确的判断。canRead(): 这是“资格预审”。当SpEL引擎拿到一个目标对象target和一个属性名name时它会拿着这些信息来问每一个PropertyAccessor“你能读它吗” 你在这里实现你的判断逻辑返回true或false。read(): 这是“正式干活”。当canRead返回true并且这个访问器被选中后SpEL就会调用它的read方法。你需要在这里实现从target中取出name对应值的核心逻辑并包装成TypedValue返回。canWrite()和write(): 同理用于判断和执行“写”操作。很多自定义访问器只读不写直接返回false并抛异常即可。SpEL的选择逻辑是这样的当遇到一个属性如obj.property时它会遍历EvaluationContext中所有的PropertyAccessor。对于每个访问器先看getSpecificTargetClasses返回的类型是否匹配target的类型。如果匹配再调用canRead进行二次确认。一旦某个访问器通过了这两关SpEL就会立即使用它不再继续遍历后面的访问器。这个顺序很重要你通过context.addPropertyAccessor(new MyAccessor())添加的访问器默认是加在列表最前面的所以拥有最高的优先级可以覆盖默认的反射访问器。3. 实战打造你的专属属性读取器光说不练假把式。原始文章里举了一个从Properties文件读取的例子很棒但有点简单。我们搞点更贴近实际需求的。假设我们有一个需求项目里有些配置项不是放在application.yml里而是放在一个专门的、格式有点特殊的secure.config文件里每行是keyencryptedValue的形式并且值是用我们自己的算法加密过的。我们希望在SpEL表达式里能像#{secureConfig(db.password)}这样直接解密并读取。3.1 第一步设计我们的专属配置读取器首先我们得有一个能加载并解密这个特殊配置文件的组件。为了简单演示我们不搞真的加密算法假设“解密”就是把字符串反转一下。import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; /** * 模拟一个安全的配置读取器。 * 假设从 secure.config 文件读取格式为 keyencryptedValue。 * “解密”逻辑这里简化为字符串反转。 */ public class SecureConfigReader { private final MapString, String configMap new HashMap(); public SecureConfigReader(Path configPath) throws IOException { // 加载并“解密”配置 Files.lines(configPath) .filter(line - line.contains()) .forEach(line - { String[] parts line.split(, 2); if (parts.length 2) { String key parts[0].trim(); String encryptedValue parts[1].trim(); // 模拟解密字符串反转 String decryptedValue new StringBuilder(encryptedValue).reverse().toString(); configMap.put(key, decryptedValue); } }); } public String getProperty(String key) { return configMap.get(key); } public boolean containsKey(String key) { return configMap.containsKey(key); } }3.2 第二步实现自定义PropertyAccessor现在我们要实现一个PropertyAccessor它的target就是我们的SecureConfigReader对象。当SpEL表达式试图从它身上读取属性时我们就调用getProperty方法。import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; import org.springframework.expression.PropertyAccessor; import org.springframework.expression.TypedValue; /** * 自定义属性访问器用于从 SecureConfigReader 中读取解密后的配置。 */ public class SecureConfigPropertyAccessor implements PropertyAccessor { Override public Class?[] getSpecificTargetClasses() { // 明确声明我只为 SecureConfigReader 类型的目标对象服务 return new Class?[]{SecureConfigReader.class}; } Override public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException { // 再次确认目标是 SecureConfigReader 实例并且它包含这个配置项 if (target instanceof SecureConfigReader) { return ((SecureConfigReader) target).containsKey(name); } return false; } Override public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException { // 核心读取逻辑 if (target instanceof SecureConfigReader) { String value ((SecureConfigReader) target).getProperty(name); if (value ! null) { // 将值包装成 TypedValue 返回 return new TypedValue(value); } } // 如果走到这里理论上不应该发生因为 canRead 已经过滤了 throw new AccessException(Cannot read property name from target: target); } Override public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException { // 我们的安全配置是只读的 return false; } Override public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException { throw new AccessException(SecureConfigReader properties are read-only.); } }关键点解读getSpecificTargetClasses: 我们明确返回SecureConfigReader.class这样SpEL在处理类型不匹配的对象时会直接跳过我们这个访问器效率更高。canRead: 我们做了双重保障先判断target类型再判断是否包含该key。这确保了read方法被调用时一定是能成功读取的。read: 这里是业务核心调用SecureConfigReader.getProperty并返回。注意返回值要用TypedValue包装。canWrite/write: 直接禁用写操作符合配置信息只读的特性。3.3 第三步在SpEL中使用我们的访问器好了工具造好了看看怎么用。我们模拟一个在非Spring环境比如单元测试下使用SpEL的场景。import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import java.nio.file.Paths; public class SecureConfigSpELDemo { public static void main(String[] args) throws Exception { // 1. 创建我们的安全配置读取器 (假设 secure.config 文件内容: db.passworddrowssap) SecureConfigReader configReader new SecureConfigReader(Paths.get(secure.config)); // 2. 创建SpEL解析器 ExpressionParser parser new SpelExpressionParser(); // 3. 创建求值上下文并将我们的读取器设置为根对象 StandardEvaluationContext context new StandardEvaluationContext(configReader); // 4. 将自定义的属性访问器注册到上下文中优先级高于默认的反射访问器 context.addPropertyAccessor(new SecureConfigPropertyAccessor()); // 5. 现在可以愉快地使用SpEL了 // 表达式直接写属性名SpEL会从根对象configReader开始解析 // 由于我们注册了访问器它会处理 SecureConfigReader 类型的属性读取 Expression exp1 parser.parseExpression(db.password); String password exp1.getValue(context, String.class); System.out.println(解密后的数据库密码: password); // 输出: password // 更复杂的例子在表达式中混合使用 context.setVariable(prefix, jdbc:mysql://localhost:3306/); Expression exp2 parser.parseExpression(#prefix db.password); String url exp2.getValue(context, String.class); System.out.println(拼接的URL: url); // 输出: jdbc:mysql://localhost:3306/password } }运行这段代码你会发现SpEL表达式db.password神奇地变成了password因为原始加密字符串drowssap反转后就是password。我们成功地将一个特殊的配置读取逻辑无缝地集成到了SpEL的表达式中4. 深入Spring内部Value(“#{beanName}”)的魔法揭秘现在让我们把视线拉回Spring容器内部。原始文章最后部分点出了Spring中Value(#{beanName})能直接引用Bean的奥秘这里我再带你更细致地走一遍这个流程这能极大地加深你对PropertyAccessor在真实框架中应用的理解。当你在Bean的属性上使用Value(#{userService})时Spring在创建这个Bean、进行属性填充populate的阶段会调用BeanFactory的相关逻辑来处理这个表达式。关键的一步发生在StandardBeanExpressionResolver.evaluate方法里。我简化一下核心代码并加上注释public Object evaluate(String value, BeanExpressionContext evalContext) throws BeansException { // ... 解析表达式、缓存等逻辑 ... // 1. 创建或获取一个StandardEvaluationContext StandardEvaluationContext sec this.evaluationCache.get(evalContext); if (sec null) { // 2. 注意这里根对象是传入的 BeanExpressionContext sec new StandardEvaluationContext(evalContext); // 3. 注册一系列Spring内置的属性访问器 sec.addPropertyAccessor(new BeanExpressionContextAccessor()); sec.addPropertyAccessor(new BeanFactoryAccessor()); sec.addPropertyAccessor(new MapAccessor()); sec.addPropertyAccessor(new EnvironmentAccessor()); // 4. 设置Bean解析器用于处理以开头的Bean引用如 service sec.setBeanResolver(new BeanFactoryResolver(evalContext.getBeanFactory())); // ... 设置其他组件如TypeConverter ... this.evaluationCache.put(evalContext, sec); } // 5. 在配置好的上下文中求值 return expr.getValue(sec); }看第2和第3步Spring创建了一个以BeanExpressionContext为根对象的求值上下文然后主动添加了四个PropertyAccessor。其中排在第一位的BeanExpressionContextAccessor就是处理#{beanName}的关键。我们看看BeanExpressionContextAccessor的canRead和read方法源码简化public class BeanExpressionContextAccessor implements PropertyAccessor { Override public Class?[] getSpecificTargetClasses() { return new Class?[] {BeanExpressionContext.class}; // 只为BeanExpressionContext服务 } Override public boolean canRead(EvaluationContext context, Object target, String name) { // 判断条件是目标对象是BeanExpressionContext并且它“包含”这个名字的对象 return (target instanceof BeanExpressionContext ((BeanExpressionContext) target).containsObject(name)); } Override public TypedValue read(EvaluationContext context, Object target, String name) { // 读取逻辑调用BeanExpressionContext的getObject方法 return new TypedValue(((BeanExpressionContext) target).getObject(name)); } // ... canWrite和write省略 ... }那么BeanExpressionContext.containsObject(name)和getObject(name)到底做了什么最终它调用了BeanFactory.containsBean(name)和BeanFactory.getBean(name)整个链条就清晰了表达式#{userService}被解析。SpEL在StandardEvaluationContext中求值根对象是BeanExpressionContext。SpEL遍历属性访问器列表第一个就是BeanExpressionContextAccessor。BeanExpressionContextAccessor的getSpecificTargetClasses匹配了根对象类型它的canRead方法被调用内部去检查容器中是否存在名为userService的Bean。如果存在canRead返回trueSpEL调用其read方法。read方法调用BeanExpressionContext.getObject(userService)最终等价于beanFactory.getBean(userService)。userService这个Bean实例被返回注入到Value标注的属性中。这就是框架设计的精妙之处通过一个自定义的PropertyAccessorSpring巧妙地将SpEL表达式中的名字与IoC容器中的Bean对象桥接了起来。EnvironmentAccessor也是类似的原理它负责处理#{environment[app.name]}这种从Spring环境里取属性的表达式。5. 举一反三更多自定义PropertyAccessor的应用场景理解了基本原理和Spring内部的例子你的思路应该打开了。PropertyAccessor的用武之地远不止于此。下面我分享几个我在实际项目中遇到过或者觉得非常有潜力的应用场景希望能给你带来启发。场景一动态数据源属性读取假设我们有一个DynamicDataSource类它内部维护了一个Map根据当前线程上下文比如一个TenantId返回不同的数据库连接属性URL, Username等。你可以写一个DynamicDataSourcePropertyAccessor当SpEL表达式求#{dataSource.url}时它并不是返回dataSource对象本身的某个字段而是根据当前租户从Map里取出对应的URL返回。这样在配置Value(#{dataSource.url})时就能实现租户隔离的数据库连接注入。场景二统一缓存访问系统里可能有多种缓存本地Caffeine、Redis集群、Memcached。你可以设计一个UnifiedCacheManager作为门面然后为其配套一个CachePropertyAccessor。表达式如#{cache.get(user:1)}经过你的访问器会先查本地缓存再查分布式缓存实现一个多级缓存的透明访问。这在一些配置化的缓存规则读取上特别有用。场景三外部服务集成有些属性值可能需要调用一个外部HTTP服务比如配置中心来获取。直接写死在配置文件里不动态。你可以实现一个RemoteConfigPropertyAccessor它的target是一个配置中心客户端。当SpEL求值#{remoteConfig[feature.flag]}时访问器会发起一个轻量级的HTTP请求可能带缓存去获取最新的特性开关状态。这里要注意性能确保canRead方法能快速判断避免不必要的远程调用并且read方法内部要做好缓存和降级。场景四对象图导航的增强默认的反射访问器对于普通的JavaBean很好用但对于一些特殊对象比如JsonNodeJackson库、DocumentXML DOM或者你自定义的GenericRecordAvro对象反射就无能为力了。为这些类型编写专用的PropertyAccessor可以让SpEL直接支持像#{jsonNode.user.address.city}这样的表达式极大地简化了数据提取的代码。实现时的注意事项性能第一canRead方法要尽可能快因为它会被频繁调用。避免在里面做耗时的IO操作或复杂计算。复杂的判断可以移到read方法里但要做好缓存。注意访问器顺序通过context.addPropertyAccessor()添加的访问器在列表前端。如果你的访问器是处理特定类型的确保它的getSpecificTargetClasses足够精确避免“误伤”其他类型的属性访问。处理好null和异常在read方法中如果属性不存在是返回null还是抛AccessException这取决于你的设计。通常遵循“快速失败”原则如果canRead返回了true但实际读不到抛异常更合适。考虑线程安全你的PropertyAccessor实现和它内部引用的target对象是否是线程安全的如果read操作会修改内部状态比如缓存需要做好同步。PropertyAccessor机制是Spring SpEL留给我们的一个非常优雅的扩展点。它不像AOP或BeanPostProcessor那样广为人知但在需要将复杂数据访问逻辑与表达式语言解耦的场景下它能发挥出四两拨千斤的效果。下次当你在SpEL表达式里感到“束手束脚”时不妨想想是不是可以写一个自己的PropertyAccessor让表达式直接获得访问新数据源的能力这往往比在业务代码里写一堆解析逻辑要干净、强大得多。