Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件

概述

头像上传要卡一道"必须是真人脸"的关口,项目把百度人脸检测也做成了自动装配组件:业务侧只注入一个 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 且只打关键字段

可复现的验证步骤

  1. 在 mf-app-server 的 application.yml 里补上 mf.aip 三段配置(用占位符填自己的凭证)。
  2. 业务模块不要 加任何配置类,直接 @Autowired AipFaceTemplate。
  3. 用一张自己或已明确授权的单人自拍 (先传到 OSS 拿到公网 URL)调用 detect(imageUrl),期望 true。
  4. 用一张风景图 调用,期望 false------注意这里验证的是:无脸时百度返回非 0 error_code,所以原始实现和改造实现都会返回 false,两者在这张图上没有区别。
  5. 再用一张双人合照 调用,期望 false------这一步才是改造真正生效的证据 :原始实现(error_code == 0)会返回 true,改造实现(error_code == 0 && face_num == 1)会返回 false。
  6. 顺手把 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 配置类处理,AipFace bean 存在,字段注入也生效。所以"一调就 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 依赖、检测参数、组件封装串起来收个尾。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源
希洛兔1 小时前
开闭原则详解:对扩展开放,对修改关闭(含图形编辑器反例 + 抽象正例 + JDK/Spring 实战)
java·开闭原则
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 03 章 · 模块化的自动配置:`*.imports`机制、classic 兼容层与 Starter 生成
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理
java·后端·开源
geats人山人海1 小时前
java 8 字符串
java·开发语言
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 04 章 · `SpringApplication` 全景:`run()` 的十二步状态机
java·后端·开源
智购科技自动售货机工厂1 小时前
省电40%的底层逻辑:冷热双温售货机基于SaaS的智能温控算法~YH
单片机·嵌入式硬件·百度·架构·emacs
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 01 章 · Spring Boot 4 的坐标系:版本矩阵、模块拓扑与源码阅读方法论
java·后端·开源
zyseo81 小时前
谷歌SEO 内链地图脚本:Python 画出权重分发,揪出孤岛页
java·前端·python