概述
头像上传要卡一道"必须是真人脸"的关口,项目把百度人脸检测也做成了自动装配组件:业务侧只注入一个 AipFaceTemplate,调 detect(imageUrl) 拿布尔值,不碰 appId/apiKey/secretKey,也不碰 AipFace 客户端。代码很短,但里面藏着一个被讲错了很多次的注入问题------本文会把结论用实测数据钉死,再给出更清晰的写法。
纲要
- 为什么要抽组件:与短信、OSS 组件同一套路,业务侧零配置类
- 组件四件套 :
AipFaceProperties/AipFaceTemplate/mfAutoConfiguration/META-INF/spring.factories - 两个被传错的问题(实测澄清)
@ConfigurationProperties类里写@Bean,这个@Bean到底会不会被登记?@Bean方法里new出来的对象,字段上的@Autowired会不会被注入?
- 真正会炸的四个位置 :
required=false、容器外new、配置前缀写错、响应体判空 - 更清晰的两种写法:修法 A(最小改动)/ 修法 B(推荐,构造注入)
detect()的健壮性改造 :error_code不等于"合规头像",要看face_num- 配置、坑表、可复现验证步骤
为什么要把人脸识别也抽成组件
上一节的 OSS 组件已经把"文件上传"这件事收口成一个 OssTemplate,人脸识别是同一类问题:
- 业务代码不该出现
new AipFace(...),更不该关心凭证怎么来的; - 业务代码不该关心"图片是以 URL 还是 BASE64 传"、"要不要开活体检测"这类调用细节;
- 业务代码只该有一行:
boolean detect = aipFaceTemplate.detect(imageUrl);
所以组件要对外暴露的就两样东西:配置(mf.aip)+ 一个模板类 。这也是项目整个 mf-autoconfig 模块的统一思路,短信、OSS、人脸识别三件套长得一模一样。
#mermaid-svg-hkhYceW58wEeWlhC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hkhYceW58wEeWlhC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hkhYceW58wEeWlhC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hkhYceW58wEeWlhC .error-icon{fill:#552222;}#mermaid-svg-hkhYceW58wEeWlhC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hkhYceW58wEeWlhC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hkhYceW58wEeWlhC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hkhYceW58wEeWlhC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hkhYceW58wEeWlhC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hkhYceW58wEeWlhC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hkhYceW58wEeWlhC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hkhYceW58wEeWlhC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hkhYceW58wEeWlhC .marker.cross{stroke:#333333;}#mermaid-svg-hkhYceW58wEeWlhC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hkhYceW58wEeWlhC p{margin:0;}#mermaid-svg-hkhYceW58wEeWlhC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hkhYceW58wEeWlhC .cluster-label text{fill:#333;}#mermaid-svg-hkhYceW58wEeWlhC .cluster-label span{color:#333;}#mermaid-svg-hkhYceW58wEeWlhC .cluster-label span p{background-color:transparent;}#mermaid-svg-hkhYceW58wEeWlhC .label text,#mermaid-svg-hkhYceW58wEeWlhC span{fill:#333;color:#333;}#mermaid-svg-hkhYceW58wEeWlhC .node rect,#mermaid-svg-hkhYceW58wEeWlhC .node circle,#mermaid-svg-hkhYceW58wEeWlhC .node ellipse,#mermaid-svg-hkhYceW58wEeWlhC .node polygon,#mermaid-svg-hkhYceW58wEeWlhC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hkhYceW58wEeWlhC .rough-node .label text,#mermaid-svg-hkhYceW58wEeWlhC .node .label text,#mermaid-svg-hkhYceW58wEeWlhC .image-shape .label,#mermaid-svg-hkhYceW58wEeWlhC .icon-shape .label{text-anchor:middle;}#mermaid-svg-hkhYceW58wEeWlhC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hkhYceW58wEeWlhC .rough-node .label,#mermaid-svg-hkhYceW58wEeWlhC .node .label,#mermaid-svg-hkhYceW58wEeWlhC .image-shape .label,#mermaid-svg-hkhYceW58wEeWlhC .icon-shape .label{text-align:center;}#mermaid-svg-hkhYceW58wEeWlhC .node.clickable{cursor:pointer;}#mermaid-svg-hkhYceW58wEeWlhC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hkhYceW58wEeWlhC .arrowheadPath{fill:#333333;}#mermaid-svg-hkhYceW58wEeWlhC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hkhYceW58wEeWlhC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hkhYceW58wEeWlhC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hkhYceW58wEeWlhC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hkhYceW58wEeWlhC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hkhYceW58wEeWlhC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hkhYceW58wEeWlhC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hkhYceW58wEeWlhC .cluster text{fill:#333;}#mermaid-svg-hkhYceW58wEeWlhC .cluster span{color:#333;}#mermaid-svg-hkhYceW58wEeWlhC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hkhYceW58wEeWlhC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hkhYceW58wEeWlhC rect.text{fill:none;stroke-width:0;}#mermaid-svg-hkhYceW58wEeWlhC .icon-shape,#mermaid-svg-hkhYceW58wEeWlhC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hkhYceW58wEeWlhC .icon-shape p,#mermaid-svg-hkhYceW58wEeWlhC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hkhYceW58wEeWlhC .icon-shape .label rect,#mermaid-svg-hkhYceW58wEeWlhC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hkhYceW58wEeWlhC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hkhYceW58wEeWlhC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hkhYceW58wEeWlhC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 注入
注入 AipFace
appId/apiKey/secretKey
HTTP
零配置类
业务模块
mf-app-server
AipFaceTemplate
AipFace 客户端
AipFaceProperties
绑定 mf.aip
百度云 人脸检测 API
不需要
@EnableConfigurationProperties
组件长在哪
组件放在独立的 mf-autoconfig 模块里,和其它模块解耦,业务模块只依赖它。
tree
mf-test
├── pom.xml
├── mf-autoconfig # 自动装配组件模块(本篇主角)
│ ├── pom.xml # 引入 baidu-aip-java-sdk、aliyun-sdk 等
│ └── src/main
│ ├── java/com/mf/autoconfig
│ │ ├── mfAutoConfiguration.java # 装配入口:造各种 Template
│ │ ├── properties
│ │ │ ├── SmsProperties.java
│ │ │ ├── OssProperties.java
│ │ │ └── AipFaceProperties.java # 绑定 mf.aip
│ │ └── template
│ │ ├── SmsTemplate.java
│ │ ├── OssTemplate.java
│ │ └── AipFaceTemplate.java # 对外唯一出口:detect()
│ └── resources/META-INF
│ └── spring.factories # 把自己登记为自动配置
├── mf-app-server # 业务模块:只有 application.yml 配置,没有配置类
├── mf-commons
├── mf-dubbo
└── mf-model
spring.factories 是这套机制的入口,内容只有一行键值:
properties
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.mf.autoconfig.mfAutoConfiguration
Spring Boot 2.7 之后这个文件不再是官方推荐写法,改成了 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容直接一行类名:
text
com.mf.autoconfig.mfAutoConfiguration
项目工程用的是 Spring Boot 2.3.9(见根 pom.xml 的 spring-boot-starter-parent 版本),所以走的是 spring.factories。两套机制不要混着放,放错位置组件不生效而且没有任何提示。
四件套与原始代码
先看课程里给出的原始版本,一字不改地贴出来,后面所有讨论都基于它。
AipFaceProperties
注意它在 @ConfigurationProperties 类里额外声明了一个 @Bean 方法。
java
package com.mf.autoconfig.properties;
import com.baidu.aip.face.AipFace;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
@Data
@ConfigurationProperties("mf.aip")
public class AipFaceProperties {
private String appId;
private String apiKey;
private String secretKey;
@Bean
public AipFace aipFace() {
AipFace client = new AipFace(appId, apiKey, secretKey);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}
}
AipFaceTemplate
java
package com.mf.autoconfig.template;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.HashMap;
public class AipFaceTemplate {
@Autowired
private AipFace client;
/**
* 检测图片中是否包含人脸
* true:包含
* false:不包含
*/
public boolean detect(String imageUrl) {
// 调用接口
String imageType = "URL";
HashMap<String, String> options = new HashMap<String, String>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
// 人脸检测
JSONObject res = client.detect(imageUrl, imageType, options);
System.out.println(res.toString(2));
Integer error_code = (Integer) res.get("error_code");
return error_code == 0;
}
}
mfAutoConfiguration
java
package com.mf.autoconfig;
import com.mf.autoconfig.properties.AipFaceProperties;
import com.mf.autoconfig.properties.OssProperties;
import com.mf.autoconfig.properties.SmsProperties;
import com.mf.autoconfig.template.AipFaceTemplate;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.autoconfig.template.SmsTemplate;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@EnableConfigurationProperties({
SmsProperties.class,
OssProperties.class,
AipFaceProperties.class
})
public class mfAutoConfiguration {
@Bean
public SmsTemplate smsTemplate(SmsProperties properties) {
return new SmsTemplate(properties);
}
@Bean
public OssTemplate ossTemplate(OssProperties properties) {
return new OssTemplate(properties);
}
@Bean
public AipFaceTemplate aipFaceTemplate() {
return new AipFaceTemplate();
}
}
装配链路
AipFace AipFaceTemplate AipFaceProperties mfAutoConfiguration spring.factories Spring Boot 启动 AipFace AipFaceTemplate AipFaceProperties mfAutoConfiguration spring.factories Spring Boot 启动 #mermaid-svg-I5DwH7XNU6HISJqW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-I5DwH7XNU6HISJqW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I5DwH7XNU6HISJqW .error-icon{fill:#552222;}#mermaid-svg-I5DwH7XNU6HISJqW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I5DwH7XNU6HISJqW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I5DwH7XNU6HISJqW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I5DwH7XNU6HISJqW .marker.cross{stroke:#333333;}#mermaid-svg-I5DwH7XNU6HISJqW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I5DwH7XNU6HISJqW p{margin:0;}#mermaid-svg-I5DwH7XNU6HISJqW .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I5DwH7XNU6HISJqW text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-I5DwH7XNU6HISJqW .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-I5DwH7XNU6HISJqW .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-I5DwH7XNU6HISJqW .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-I5DwH7XNU6HISJqW .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-I5DwH7XNU6HISJqW #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-I5DwH7XNU6HISJqW .sequenceNumber{fill:white;}#mermaid-svg-I5DwH7XNU6HISJqW #sequencenumber{fill:#333;}#mermaid-svg-I5DwH7XNU6HISJqW #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-I5DwH7XNU6HISJqW .messageText{fill:#333;stroke:none;}#mermaid-svg-I5DwH7XNU6HISJqW .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I5DwH7XNU6HISJqW .labelText,#mermaid-svg-I5DwH7XNU6HISJqW .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-I5DwH7XNU6HISJqW .loopText,#mermaid-svg-I5DwH7XNU6HISJqW .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-I5DwH7XNU6HISJqW .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-I5DwH7XNU6HISJqW .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-I5DwH7XNU6HISJqW .noteText,#mermaid-svg-I5DwH7XNU6HISJqW .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-I5DwH7XNU6HISJqW .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I5DwH7XNU6HISJqW .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I5DwH7XNU6HISJqW .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-I5DwH7XNU6HISJqW .actorPopupMenu{position:absolute;}#mermaid-svg-I5DwH7XNU6HISJqW .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-I5DwH7XNU6HISJqW .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-I5DwH7XNU6HISJqW .actor-man circle,#mermaid-svg-I5DwH7XNU6HISJqW line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-I5DwH7XNU6HISJqW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 容器对 @Bean 返回的对象执行字段注入 读取 EnableAutoConfiguration 条目 加载装配类 @EnableConfigurationProperties 注册属性类为 bean 触发 @Bean aipFace 工厂方法(工厂 = 属性类实例) 提供已绑定的 appId/apiKey/secretKey @Bean aipFaceTemplate 返回 new AipFaceTemplate() @Autowired 注入完成
先回答两个被传错的问题
这两个问题在网上、在不少面经里都有确定答案,但答案是反的。下面每条结论都来自实测,环境与工程完全对齐。
问题一:@ConfigurationProperties 类里写 @Bean,会被登记吗
会被登记。 实测 AipFace 类型的 bean 确实存在于容器中,而且它的 bean 定义是"以属性类实例为工厂、以 aipFace 为工厂方法":
text
bd[mf.aip-com.mf.autoconfig.properties.AipFaceProperties] factoryBean=null factoryMethod=null
bd[aipFace] factoryBean=mf.aip-com.mf.autoconfig.properties.AipFaceProperties factoryMethod=aipFace
机制在 Spring 这边:判断一个类要不要当配置类处理时,除了 @Configuration、@Component、@Import 这些显式标记,还有一条兜底规则------类里只要声明了 @Bean 方法,就按 "lite 配置类"(精简配置类)处理 。而 @EnableConfigurationProperties(AipFaceProperties.class) 会把这个属性类注册成一个普通 bean;这个 bean 一旦进了 beanDefinitionNames,就会在配置类候选扫描里被命中,它的 @Bean 方法随之被登记。
我把四种注册方式都跑了一遍,全部都会生成 aipFace bean:
| 属性类的注册方式 | AipFace bean 是否生成 |
生成时的工厂 bean 名 |
|---|---|---|
@EnableConfigurationProperties(X.class)(普通类承载) |
是 | mf.aip-<全限定类名> |
@EnableConfigurationProperties(X.class) + @Configuration 承载 |
是 | mf.aip-<全限定类名> |
@Import(X.class) |
是 | 全限定类名 |
context.registerBean(X.class) 当普通 bean |
是 | aipFaceProperties(默认名) |
问题二:@Bean 方法里 new 出来的对象,@Autowired 会生效吗
会生效。 实测 AipFaceTemplate 里的 client 字段被正确注入,而且注入的是已经绑定过配置的实例:
text
Client bean 个数 = 1
aipFace = Client{appId=your-app-id, apiKey=<已绑定>, secretKey=<已绑定>}
[PROBE] client == null ? false
[PROBE] client.info = Client{appId=your-app-id, apiKey=<已绑定>, secretKey=<已绑定>}
这里容易绕进去的点是:return new AipFaceTemplate(); 看起来是"手工 new",但它是 @Bean 工厂方法的返回值 。@Bean 方法返回的对象是正儿八经的容器 bean,容器拿到它之后照样走 populateBean 阶段,AutowiredAnnotationBeanPostProcessor 照样会处理它字段上的 @Autowired。
"@Autowired 在手工 new 的对象里不生效"这句话只在对象脱离容器 时成立------比如在业务代码里 new AipFaceTemplate(),或者在工具类里自己造实例。这两种情况下字段就是 null,实测确认。
实测环境与复现方法
不依赖百度云、不需要真实凭证,写一个最小复现就能验证上面两条:
java
package com.mf.autoconfig;
import com.baidu.aip.face.AipFace;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
// 与工程版完全同构:属性类里带一个 @Bean
@ConfigurationProperties(prefix = "mf.aip")
public class ProbeProperties {
private String appId;
private String apiKey;
private String secretKey;
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public String getSecretKey() { return secretKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
@Bean
public AipFace aipFace() {
return new AipFace(appId, apiKey, secretKey);
}
}
java
package com.mf.autoconfig;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.env.MapPropertySource;
import java.util.HashMap;
import java.util.Map;
@EnableConfigurationProperties(ProbeProperties.class)
public class ProbeConfig {
public static void main(String[] args) {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
Map<String, Object> props = new HashMap<>();
props.put("mf.aip.appId", "fake-app-id");
props.put("mf.aip.apiKey", "fake-ak");
props.put("mf.aip.secretKey", "fake-sk");
ctx.getEnvironment().getPropertySources().addFirst(new MapPropertySource("probe", props));
ctx.register(ProbeConfig.class);
ctx.refresh();
// 1) AipFace bean 是否存在
String[] names = ctx.getBeanNamesForType(com.baidu.aip.face.AipFace.class);
System.out.println("AipFace bean 个数 = " + names.length);
for (String n : names) {
System.out.println(" " + n);
}
}
}
实测输出(JDK 11 + Spring Boot 2.3.9 + Spring Framework 5.2.11,与工程 pom.xml 的版本组合一致)为 AipFace bean 个数 = 1。
真正会炸的四个位置
既然原写法能跑,那"人脸检测功能完全不可用、一调就 NPE"这个说法就不成立。但下面这四种情况确实会出问题,其中两种是 NPE,两种更隐蔽。
#mermaid-svg-HOPMORmdUGKRJUeD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HOPMORmdUGKRJUeD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HOPMORmdUGKRJUeD .error-icon{fill:#552222;}#mermaid-svg-HOPMORmdUGKRJUeD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HOPMORmdUGKRJUeD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HOPMORmdUGKRJUeD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HOPMORmdUGKRJUeD .marker.cross{stroke:#333333;}#mermaid-svg-HOPMORmdUGKRJUeD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HOPMORmdUGKRJUeD p{margin:0;}#mermaid-svg-HOPMORmdUGKRJUeD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HOPMORmdUGKRJUeD .cluster-label text{fill:#333;}#mermaid-svg-HOPMORmdUGKRJUeD .cluster-label span{color:#333;}#mermaid-svg-HOPMORmdUGKRJUeD .cluster-label span p{background-color:transparent;}#mermaid-svg-HOPMORmdUGKRJUeD .label text,#mermaid-svg-HOPMORmdUGKRJUeD span{fill:#333;color:#333;}#mermaid-svg-HOPMORmdUGKRJUeD .node rect,#mermaid-svg-HOPMORmdUGKRJUeD .node circle,#mermaid-svg-HOPMORmdUGKRJUeD .node ellipse,#mermaid-svg-HOPMORmdUGKRJUeD .node polygon,#mermaid-svg-HOPMORmdUGKRJUeD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HOPMORmdUGKRJUeD .rough-node .label text,#mermaid-svg-HOPMORmdUGKRJUeD .node .label text,#mermaid-svg-HOPMORmdUGKRJUeD .image-shape .label,#mermaid-svg-HOPMORmdUGKRJUeD .icon-shape .label{text-anchor:middle;}#mermaid-svg-HOPMORmdUGKRJUeD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HOPMORmdUGKRJUeD .rough-node .label,#mermaid-svg-HOPMORmdUGKRJUeD .node .label,#mermaid-svg-HOPMORmdUGKRJUeD .image-shape .label,#mermaid-svg-HOPMORmdUGKRJUeD .icon-shape .label{text-align:center;}#mermaid-svg-HOPMORmdUGKRJUeD .node.clickable{cursor:pointer;}#mermaid-svg-HOPMORmdUGKRJUeD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HOPMORmdUGKRJUeD .arrowheadPath{fill:#333333;}#mermaid-svg-HOPMORmdUGKRJUeD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HOPMORmdUGKRJUeD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HOPMORmdUGKRJUeD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HOPMORmdUGKRJUeD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HOPMORmdUGKRJUeD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HOPMORmdUGKRJUeD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HOPMORmdUGKRJUeD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HOPMORmdUGKRJUeD .cluster text{fill:#333;}#mermaid-svg-HOPMORmdUGKRJUeD .cluster span{color:#333;}#mermaid-svg-HOPMORmdUGKRJUeD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HOPMORmdUGKRJUeD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HOPMORmdUGKRJUeD rect.text{fill:none;stroke-width:0;}#mermaid-svg-HOPMORmdUGKRJUeD .icon-shape,#mermaid-svg-HOPMORmdUGKRJUeD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HOPMORmdUGKRJUeD .icon-shape p,#mermaid-svg-HOPMORmdUGKRJUeD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HOPMORmdUGKRJUeD .icon-shape .label rect,#mermaid-svg-HOPMORmdUGKRJUeD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HOPMORmdUGKRJUeD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HOPMORmdUGKRJUeD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HOPMORmdUGKRJUeD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} @Bean 方法返回(容器管理)
业务代码 new / 工具类 new
存在
不存在且 required=true
不存在且 required=false
绑上了
前缀写错/漏配置
AipFaceTemplate 从哪来?
字段 @Autowired 生效
字段保持 null
调用即 NPE
AipFace bean 存在吗?
正常调用
启动即失败
UnsatisfiedDependencyException
启动成功
调用时 NPE
mf.aip 绑上了吗?
正常返回
启动成功,不报错
鉴权失败 error_code!=0
detect 返回 false
位置一:required = false 才是"静默炸弹"
@Autowired 默认 required = true。把客户端 bean 拿掉之后,默认写法是启动期就直接失败,报得很响:
text
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'aipFaceTemplate':
Unsatisfied dependency expressed through field 'client':
No qualifying bean of type 'com.baidu.aip.face.AipFace' available:
expected at least 1 bean which qualifies as autowire candidate.
Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
注意这段话:缺依赖是启动时 暴露的,不是"编译过、启动过、调用才炸"。什么时候才会变成"启动过、调用才炸"?把它改成 @Autowired(required = false) 的时候:
java
@Autowired(required = false)
private AipFace client;
实测结果:
text
==== 启动成功 ====
[PROBE] client == null ? true
==== 调用失败 ====
java.lang.NullPointerException
这才是真正的静默炸弹。不要给必填依赖加 required = false------它会把一个启动期的明确报错,换成运行期的含糊 NPE。
位置二:容器外 new 出来的模板
java
// 反例:业务代码里自己造
AipFaceTemplate template = new AipFaceTemplate();
template.detect(imageUrl); // client == null -> NPE
这个实例从来没进过容器,没有任何后置处理器碰过它,@Autowired 字段就是 null。工具类、静态方法、new 出来的 service 都容易踩这个。
位置三:配置缺失或前缀写错(最容易被忽略)
这一条实测起来最值得记住:把 mf.aip 配置整个删掉,应用照样启动成功 ,AipFace bean 也照样创建成功,只是三个字段全是 null:
text
启动结果: 成功(没有任何属性配置)
注入进模板的 client = Client{appId=null, apiKey=null, secretKey=null}
到了调用的时候,百度云返回鉴权失败,error_code != 0,detect() 返回 false,业务层抛出的却是"不包含人脸"。用户以为自己照片有问题,运维以为百度挂了,实际是配置文件里 prefix 写成了 mf.aipface 或者少缩进了一级。 这类错误排查起来最费时间,因为错误信息指向了完全错误的方向。
位置四:响应体判空
原始实现里这两行是没有防护的:
java
JSONObject res = client.detect(imageUrl, imageType, options);
Integer error_code = (Integer) res.get("error_code");
res为null(网络异常、SDK 内部异常时可能返回null)→ NPE;res里没有error_code字段 →get返回null,Integer拆箱比较时 NPE;System.out.println(res.toString(2))在生产环境会把完整响应体(含face_list、72 点关键点坐标)全部打到标准输出,体积大,还含用户生物特征信息。
可验证的确认方法
怀疑组件没装配好,不用猜,开个 ApplicationRunner 打一行日志就能看清:
java
package com.mf.server.config;
import com.baidu.aip.face.AipFace;
import com.mf.autoconfig.template.AipFaceTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Profile("dev")
@Configuration
public class AutoconfigProbeConfig {
private static final Logger log = LoggerFactory.getLogger(AutoconfigProbeConfig.class);
@Bean
public ApplicationRunner autoconfigProbe(ApplicationContext ctx) {
return args -> {
String[] clientBeans = ctx.getBeanNamesForType(AipFace.class);
log.info("容器中 AipFace bean 数量 = {}", clientBeans.length);
for (String name : clientBeans) {
log.info(" AipFace bean name = {}", name);
}
String[] templateBeans = ctx.getBeanNamesForType(AipFaceTemplate.class);
log.info("容器中 AipFaceTemplate bean 数量 = {}", templateBeans.length);
};
}
}
启动后看到 AipFace bean 数量 = 1 就说明装配链路是通的。如果模板内部有疑虑,在 detect() 第一行加一行 log.debug("client = {}", client) 也能直接看到是不是 null。注意别把这个探针类留在生产环境 ,@Profile("dev") 就是干这个用的。
更清晰的写法
原写法能跑,但它踩了一个隐含前提:"属性类里写 @Bean 会被当成 lite 配置类"。这是框架行为,不是代码意图。表达同一个意图,有两种更直白的写法。
修法 A:只把 @Bean 挪个位置(最小改动)
AipFaceProperties 只留配置字段,@Bean aipFace() 移到装配类里。AipFaceTemplate 本身不用动。
java
package com.mf.autoconfig.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "mf.aip")
public class AipFaceProperties {
private String appId;
private String apiKey;
private String secretKey;
}
java
package com.mf.autoconfig;
import com.baidu.aip.face.AipFace;
import com.mf.autoconfig.properties.AipFaceProperties;
import com.mf.autoconfig.properties.OssProperties;
import com.mf.autoconfig.properties.SmsProperties;
import com.mf.autoconfig.template.AipFaceTemplate;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.autoconfig.template.SmsTemplate;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@EnableConfigurationProperties({
SmsProperties.class,
OssProperties.class,
AipFaceProperties.class
})
public class mfAutoConfiguration {
@Bean
public SmsTemplate smsTemplate(SmsProperties properties) {
return new SmsTemplate(properties);
}
@Bean
public OssTemplate ossTemplate(OssProperties properties) {
return new OssTemplate(properties);
}
@Bean
public AipFace aipFace(AipFaceProperties properties) {
AipFace client = new AipFace(properties.getAppId(), properties.getApiKey(), properties.getSecretKey());
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}
@Bean
public AipFaceTemplate aipFaceTemplate() {
return new AipFaceTemplate();
}
}
改动只有一个:@Bean aipFace 从属性类搬到装配类。装配语义从此写在装配类里,一眼能看懂。
修法 B:构造注入(推荐)
在修法 A 的基础上,把 AipFaceTemplate 的字段注入改成构造注入,client 字段设为 final。
java
package com.mf.autoconfig.template;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
public class AipFaceTemplate {
private static final Logger log = LoggerFactory.getLogger(AipFaceTemplate.class);
private final AipFace client;
public AipFaceTemplate(AipFace client) {
this.client = client;
}
public boolean detect(String imageUrl) {
String imageType = "URL";
HashMap<String, String> options = new HashMap<>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
JSONObject res = client.detect(imageUrl, imageType, options);
if (res == null) {
log.warn("人脸检测返回为空, imageUrl={}", imageUrl);
return false;
}
Integer errorCode = (Integer) res.get("error_code");
return errorCode != null && errorCode == 0;
}
}
装配类里的 @Bean 方法相应地传参:
java
@Bean
public AipFaceTemplate aipFaceTemplate(AipFace aipFace) {
return new AipFaceTemplate(aipFace);
}
构造注入带来三个实际好处:
- 依赖是显式的 。看构造器签名就知道这个类需要
AipFace,不用翻字段注解,也不用猜"它是不是容器创建的"。 final保证了不可变。字段只能在构造时赋值,之后不会被改,也逼你早点发现依赖缺失。- 可以脱离容器做单测 。
new AipFaceTemplate(mockAipFace)直接就能测,不需要起 Spring 上下文。
两种写法对照
| 维度 | 原写法(属性类带 @Bean) |
修法 A(@Bean 挪走) |
修法 B(构造注入) |
|---|---|---|---|
| 能否正常运行 | 能(依赖框架 lite 配置规则) | 能 | 能 |
| 装配意图是否显式 | 否,靠框架兜底规则 | 是 | 是 |
| 属性类是否只放配置 | 否,混了装配 | 是 | 是 |
| 依赖缺失时的报错 | 启动期 NoSuchBeanDefinitionException |
启动期 NoSuchBeanDefinitionException |
启动期 NoSuchBeanDefinitionException |
| 能否脱离容器单测 | 否(字段注入需容器) | 否 | 可以 |
被别人误改成 new 的风险 |
有 | 有 | 低(构造器签名会提醒) |
为什么"配置类里写 @Bean"仍然是坏做法
它现在能跑,但它把两种职责塞进了一个类 :属性类负责"从 mf.aip 读配置",同时又负责"造一个 HTTP 客户端"。更要紧的是,它能成立完全取决于 Spring 的一条隐式规则(有 @Bean 方法的 bean 按 lite 配置类处理),而不是取决于你写了什么意图明确的注解。这种代码的维护成本在于:
- 新人看到
@ConfigurationProperties类里冒出个@Bean,第一反应一定是"这能生效吗",于是要么去查、要么去改------一次不必要的排查成本; - 一旦有人按"配置类只放配置"的常识把
@Bean删掉,客户端 bean 就没了,应用直接起不来。改动是"看起来更规范"的,结果却是故障; - 它和同模块的 OSS、短信写法不一致,一个靠方法参数注入,一个靠内部
@Bean,读代码的人需要多切一次思路。
一句话:能跑不等于该这么写。装配写进装配类,配置只留在配置类。
同模块其它组件:OSS 和短信是怎么做的
这两个组件用的是同一种更直白的写法------@Bean 方法把 Properties 当方法参数 接进来,再传给模板的构造器。先看 OssProperties:
java
package com.mf.autoconfig.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "mf.oss")
public class OssProperties {
private String accessKey;
private String secret;
private String bucketName;
private String url; //域名
private String endpoint;
}
纯配置,一个 @Bean 都没有。OssTemplate 用构造注入:
java
package com.mf.autoconfig.template;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.mf.autoconfig.properties.OssProperties;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
public class OssTemplate {
private OssProperties properties;
public OssTemplate(OssProperties properties) {
this.properties = properties;
}
/**
* 文件上传
* 1:文件名称
* 2:输入流
*/
public String upload(String filename, InputStream is) {
//3、拼写图片路径
filename = new SimpleDateFormat("yyyy/MM/dd").format(new Date())
+"/"+ UUID.randomUUID().toString() + filename.substring(filename.lastIndexOf("."));
String endpoint = properties.getEndpoint();
String accessKeyId = properties.getAccessKey();
String accessKeySecret = properties.getSecret();
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId,accessKeySecret);
ossClient.putObject(properties.getBucketName(), filename, is);
// 关闭OSSClient。
ossClient.shutdown();
String url = properties.getUrl() +"/" + filename;
return url;
}
}
SmsTemplate 也是同一个套路:
java
package com.mf.autoconfig.template;
import com.mf.autoconfig.properties.SmsProperties;
public class SmsTemplate {
private SmsProperties properties;
public SmsTemplate(SmsProperties properties) {
this.properties = properties;
}
public void sendSms(String mobile, String code) {
// 内部使用 properties.getAccessKey() / getSecret() / getSignName() / getTemplateCode()
// 具体调用阿里云 dysmsapi 的代码略,结构与本类一致
}
}
对比下来结论很清楚:整个模块里只有 AipFace 这一个组件把 @Bean 写进了属性类,属于写法不统一。这也是建议按修法 B 收口的原因------收口之后三个组件的结构完全一致,新人读一遍就能套用到第四个组件上。
detect() 的健壮性改造
语义问题:error_code == 0 不等于"是合规头像"
这是比 NPE 更值得纠正的一点。百度人脸检测的响应里,两个字段的含义完全不同:
| 字段 | 位置 | 含义 | 能不能用来判"合规头像" |
|---|---|---|---|
error_code |
响应顶层 | 0 表示这次调用成功(鉴权、限流、参数都没问题) |
不能。调用成功 ≠ 检测结果符合业务要求 |
error_msg |
响应顶层 | 错误描述,成功时是 SUCCESS |
用作排查线索 |
result.face_num |
result 内 |
实际检测到的人脸数量 | 能。头像场景应为 1 |
result.face_list |
result 内 |
人脸明细(位置、置信度、关键点等) | 按需使用 |
log_id |
响应顶层 | 日志 id,排查问题时提供给百度 | 排查用 |
原实现返回 error_code == 0,并且把 max_face_num 设成了 2,意思是"最多处理 2 张脸"。于是一张两个人的合照,error_code 是 0、face_num 是 2,原实现会返回 true------双人合照会被当成合格头像放行,这不是业务想要的结果。
反过来,没有检测到人脸时百度返回的是非 0 的 error_code(例如 222202 face is not found),所以风景图恰好也能被原实现拦下来,但这是"撞对了",不是设计对了。真正该表达的语义是:
text
合规头像 == error_code == 0 && result.face_num == 1
判空与日志
改造点归纳成四条:
client为null时不直接抛 NPE,而是记日志、返回false;res为null时返回false;error_code字段缺失(get返回null)时返回false,不要让Integer拆箱炸掉;- 用日志框架替掉
System.out.println,并且不要在生产打印完整响应体 ------那条 JSON 里有face_list、置信度、关键点坐标,属于用户生物特征数据,用log.debug级别并且只打关键字段。
改造后的完整实现
java
package com.mf.autoconfig.template;
import com.baidu.aip.face.AipFace;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
public class AipFaceTemplate {
private static final Logger log = LoggerFactory.getLogger(AipFaceTemplate.class);
/** 百度人脸检测:调用成功 */
private static final int SUCCESS = 0;
/** 头像场景要求的人脸数量 */
private static final int EXPECTED_FACE_NUM = 1;
private final AipFace client;
public AipFaceTemplate(AipFace client) {
this.client = client;
}
/**
* 检测图片是否为"恰好一张人脸"的合规头像
* true:恰好检测到 1 张人脸
* false:无脸、多脸、调用失败、参数非法
*/
public boolean detect(String imageUrl) {
if (imageUrl == null || imageUrl.trim().isEmpty()) {
log.warn("人脸检测入参为空,直接判不通过");
return false;
}
if (client == null) {
log.error("AipFace 客户端未注入,检查装配配置");
return false;
}
String imageType = "URL";
HashMap<String, String> options = new HashMap<>();
options.put("face_field", "age");
options.put("max_face_num", "2");
options.put("face_type", "LIVE");
options.put("liveness_control", "LOW");
JSONObject res;
try {
res = client.detect(imageUrl, imageType, options);
} catch (Exception e) {
log.error("调用百度人脸检测异常, imageUrl={}", imageUrl, e);
return false;
}
if (res == null) {
log.warn("百度人脸检测返回为空, imageUrl={}", imageUrl);
return false;
}
Object codeObj = res.opt("error_code");
if (!(codeObj instanceof Number)) {
log.warn("百度人脸检测响应缺少 error_code, imageUrl={}, res={}", imageUrl, res);
return false;
}
int errorCode = ((Number) codeObj).intValue();
if (errorCode != SUCCESS) {
// 只打关键字段,不打整个响应体
log.warn("百度人脸检测调用失败, error_code={}, error_msg={}, imageUrl={}",
errorCode, res.optString("error_msg"), imageUrl);
return false;
}
JSONObject result = res.optJSONObject("result");
int faceNum = result == null ? 0 : result.optInt("face_num", 0);
if (faceNum != EXPECTED_FACE_NUM) {
log.info("人脸数量不符合要求, face_num={}, imageUrl={}", faceNum, imageUrl);
return false;
}
log.info("人脸检测通过, face_num={}, imageUrl={}", faceNum, imageUrl);
return true;
}
}
配置项与使用方式
应用侧配置
mf-app-server/src/main/resources/application.yml 里加一段(真实凭证不要提交到仓库,用环境变量或占位符):
yaml
mf:
aip:
appId: ${mf_AIP_APP_ID:your-app-id}
apiKey: ${mf_AIP_API_KEY:your-api-key}
secretKey: ${mf_AIP_SECRET_KEY:your-secret-key}
业务模块不需要 任何 @EnableConfigurationProperties,自动装配会通过 spring.factories 生效。
业务侧调用
java
package com.mf.server.service;
import com.mf.autoconfig.template.AipFaceTemplate;
import com.mf.autoconfig.template.OssTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Service
public class UserInfoService {
@Autowired
private OssTemplate ossTemplate;
@Autowired
private AipFaceTemplate aipFaceTemplate;
public void saveAvatar(Long userId, MultipartFile file) throws IOException {
// 1、先把头像传到 OSS,拿到公网可访问的 URL
String imageUrl = ossTemplate.upload(file.getOriginalFilename(), file.getInputStream());
// 2、调用人脸检测,判断是否为合规头像(恰好一张人脸)
boolean detect = aipFaceTemplate.detect(imageUrl);
if (!detect) {
throw new RuntimeException("不包含人脸");
}
// 3、通过校验,落库更新用户头像
// userInfoApi.updateAvatar(userId, imageUrl);
}
}
这段代码里没有任何一行涉及 appId、apiKey、secretKey,也没有 new 客户端------这就是组件抽取要达成的效果。
实战坑
| 坑 | 现象 | 原因与排查 |
|---|---|---|
prefix 写错(如 mf.aipface、漏一级缩进) |
启动正常,不报错;调用时百度鉴权失败,业务报"不包含人脸" | 属性没绑上,三个字段全是 null,客户端被正常造出来但是空凭证。排查:启动时打日志看 AipFaceProperties 的三个字段是否为 null |
spring.factories 路径或类名写错 |
组件完全不生效,业务侧注入 AipFaceTemplate 报 NoSuchBeanDefinitionException,且没有任何提示 |
检查 META-INF/spring.factories 是否在 src/main/resources 下;Boot 2.7+ 要改用 AutoConfiguration.imports |
在容器外 new 模板类 |
调用时 NPE | @Autowired 只对容器管理的实例生效。工具类、静态方法里不要自己造模板 |
给必填依赖加 @Autowired(required = false) |
启动成功,调用时 NPE | 把一个启动期明确报错换成了运行期含糊 NPE。除非依赖确实可选,否则别加 |
| 图片 URL 不是公网可访问 | error_code 非 0,返回"下载失败"类错误,detect() 返回 false |
人脸检测是百度云服务端 去拉这张图。localhost、内网地址、带鉴权签名的临时 URL 都不行,必须是公网直链 |
| 调用量超限流 | error_code 非 0(QPS/并发超限) |
免费额度有 QPS 和总量限制。生产要加限流降级,并把"检测失败"和"确认无人脸"区分开,不要都返回 false |
| 改了组件代码后业务侧不生效 | 改了 AipFaceTemplate 但 app-server 行为没变 |
组件是独立模块,必须先 mvn install 装进本地仓库(或重建聚合模块),业务模块才能拿到新版本 |
| 生产打完整响应体 | 日志暴涨,且记录了用户生物特征 | System.out.println(res.toString(2)) 换成 log.debug 且只打关键字段 |
可复现的验证步骤
- 在
mf-app-server的application.yml里补上mf.aip三段配置(用占位符填自己的凭证)。 - 业务模块不要 加任何配置类,直接
@Autowired AipFaceTemplate。 - 用一张自己或已明确授权的单人自拍 (先传到 OSS 拿到公网 URL)调用
detect(imageUrl),期望true。 - 用一张风景图 调用,期望
false------注意这里验证的是:无脸时百度返回非 0error_code,所以原始实现和改造实现都会返回false,两者在这张图上没有区别。 - 再用一张双人合照 调用,期望
false------这一步才是改造真正生效的证据 :原始实现(error_code == 0)会返回true,改造实现(error_code == 0 && face_num == 1)会返回false。 - 顺手把
application.yml里mf.aip的缩进故意写错一位,重启应用,观察它仍然启动成功 ,然后调用时返回false并打出鉴权失败日志------这一条能把"配置写错最难查"这件事在本地复现出来。
头像上传原型
人脸检测是"完善个人信息"页里头像上传的校验环节,原型是移动端一屏。
doc
┌──────────────────────────────────────────┐
│ ← 完善信息 │
├──────────────────────────────────────────┤
│ │
│ ╭────╮ │
│ │ ⊕ │ ← 头像上传位 │
│ ╰────╯ (点击替换) │
│ │
│ 点击上传真人头像 │
│ 需通过人脸检测校验 │
│ │
├──────────────────────────────────────────┤
│ 昵称 │
│ ┌────────────────────────────────────┐ │
│ │ 请输入昵称 │ │
│ └────────────────────────────────────┘ │
│ 性别 │
│ ┌────────────────────────────────────┐ │
│ │ 女 ● 男 ○ │ │
│ └────────────────────────────────────┘ │
│ 生日 │
│ ┌────────────────────────────────────┐ │
│ │ 1998-06-01 ▾ │ │
│ └────────────────────────────────────┘ │
│ 城市 │
│ ┌────────────────────────────────────┐ │
│ │ 北京 ▸ │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ ┌────────────────────────────────────┐ │
│ │ 保存 │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
要点:
- 头像位是唯一触发人脸检测的入口 。选中图片后先上传 OSS 换到公网 URL,再用该 URL 调
detect()。 - 校验失败要给出可操作的提示 。当前实现抛的是
RuntimeException("不包含人脸"),但按上面的分析,失败原因至少有三类------没有脸、脸多于一张、服务调用失败。前两类应该提示"请上传单人清晰正面照",第三类应该提示"服务繁忙,请稍后重试",并且不要提示用户"照片里没有人脸"。 - 上传中的中间态要有反馈。上传 + 检测是两次网络往返,期间按钮要置灰,否则用户重复点击会产生多次 OSS 上传和人脸检测调用,很快触达限流。
- 昵称、性别、生日、城市是同一屏的其它必填项,与头像一起提交,其中只有头像会走人脸检测。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 编译通过就说明注入没问题 | 编译期只能保证类型存在,@Autowired 是否被满足要到运行期容器启动时才知道。类型对了但容器里没有对应 bean,编译一样通过 |
@ConfigurationProperties 类里可以随便写 @Bean |
能跑,因为"类里有 @Bean 方法就当 lite 配置类处理"这条规则兜住了它。但这属于依赖框架隐式行为,职责也混了,不建议这么写 |
@Bean 方法里 new 出来的对象,@Autowired 不生效 |
不成立 。@Bean 方法的返回值是容器 bean,字段注入照样生效。只有容器外 new 出来的实例才不生效 |
error_code == 0 就代表"是合规头像" |
error_code == 0 只表示这次调用成功 。是否合规要看 result.face_num,头像场景应为 1;当前 max_face_num=2 会让双人合照也通过 |
手工 new 出来的类里 @Autowired 会生效 |
不会。后置处理器只处理容器创建的 bean |
| 组件改完立刻生效 | 不会。组件是独立模块,要 mvn install 后业务模块才能用到新版本 |
| 缺 jar 依赖会在启动时立刻报出来 | 缺 AipFace 这类强依赖 bean 会在启动时通过 NoSuchBeanDefinitionException 报出来;缺配置值不会,应用照常启动,只在调用时表现为鉴权失败 |
官方文档
总结
- 人脸识别组件和短信、OSS 是同一套路,业务侧零配置类,只注入
AipFaceTemplate调detect(imageUrl)。 - 原始写法里"
@ConfigurationProperties类里写@Bean、模板由@Bean方法new出来且用字段@Autowired"能正常运行 ------实测确认 Spring 会把这个属性类当 lite 配置类处理,AipFacebean 存在,字段注入也生效。所以"一调就 NPE、功能完全不可用"的说法不成立。 - 真正的坑有四个:
@Autowired(required = false)会把启动期报错变成运行期 NPE;容器外new模板一定 NPE;mf.aip前缀写错是启动不报错、调用才失败的最隐蔽场景;响应体不判空会额外引入 NPE 风险。 - 建议按修法 B 收口:装配写进
mfAutoConfiguration,属性类只放配置,AipFaceTemplate改构造注入。这样三个组件结构一致,依赖显式,还能脱离容器做单测。 detect()的返回值语义要跟着业务走。头像要的是"恰好一张人脸",所以判定条件应该是error_code == 0 && result.face_num == 1,并且用日志框架替换System.out.println、不上生产打印完整响应体。
下一篇是同章的《百度人脸识别:总结》,会把这一节用到的账号申请、SDK 依赖、检测参数、组件封装串起来收个尾。