Lo llevará de la mano para agregar la función de autenticación de inicio de sesión del código de verificación de SMS en la aplicación SpringBoot integrada con SpringSecurity

prefacio

En el artículo anterior , aclaré el proceso de autenticación de inicio de sesión basado en el nombre y la contraseña en Spring Security. El autor presentó el proceso de autenticación de inicio de sesión en detalle Spring Security, y también indicó que debemos implementar la autenticación de inicio de sesión personalizada en nuestro trabajo, como el número de teléfono móvil + SMS La expansión del código de verificación, dirección de correo electrónico + código de verificación de correo electrónico y autenticación de inicio de sesión de terceros está lista. Entonces, en este artículo, el autor lo guiará a través de cómo implementar otro método de autenticación de inicio de sesión en el proyecto integrado, es decir, autenticación de inicio de sesión con número de teléfono móvil + código de verificación de SMS Spring Security.SpringBoot

Lo último para ahorrar tiempo y costos de construcción del proyecto, la realización de las funciones de este artículo blogserverse autor proporcionará la dirección del código del proyecto al final del artículo Espero que los lectores puedan dedicar unos 5 minutos a ver el final del artículo.

1 token de autenticación personalizado

Nuestra MobilePhoneAuthenticationTokenclase personalizada hereda de la AbstractAuthenticationTokenclase, principalmente proporciona un constructor con parámetros y anula , getCredentials, getPrincipaly setAuthenticatedotros métodoseraseCredentialsgetName

public class MobilePhoneAuthenticationToken extends AbstractAuthenticationToken {
    // 登录身份,这里是手机号
    private Object principal;
    
    // 登录凭证,这里是短信验证码
    private Object credentials;
    
    /**
     * 构造方法
     * @param authorities 权限集合
     * @param principal 登录身份
     * @param credentials 登录凭据
     */
    public MobilePhoneAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }
    
    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
    // 不允许通过set方法设置认证标识
    @Override
    public void setAuthenticated(boolean authenticated) {
        if (authenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }
    // 擦除登录凭据
    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        credentials = null;
    }
    
    // 获取认证token的名字
    @Override
    public String getName() {
        return "mobilePhoneAuthenticationToken";
    }
}
复制代码

2 Clase de proveedor de autenticación personalizada

Cuando personalizamos la MobilePhoneAuthenticationProviderclase, nos referimos al AbstractUserDetailsAuthenticationProvidercódigo fuente de la clase e implementamos tres interfaces como AuthenticationProvider, InitializingBeanyMessageSourceAware

Al mismo tiempo, para realizar la función de número de teléfono móvil + autenticación de inicio de sesión del código de verificación de SMS, agregamos dos atributos de clase a esta clase como UserServicedos parámetros de construcción de la claseRedisTemplateMobilePhoneAuthenticationProvider

El código fuente después de completar la codificación de esta clase es el siguiente:

public class MobilePhoneAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {

    private UserService userService;

    private RedisTemplate redisTemplate;

    private boolean forcePrincipalAsString = false;

    private static final Logger logger = LoggerFactory.getLogger(MobilePhoneAuthenticationProvider.class);

    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

    private UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
    
    public MobilePhoneAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) {
        this.userService = userService;
        this.redisTemplate = redisTemplate;
    }
    
   /**
     * 认证方法
     * @param authentication 认证token
     * @return successAuthenticationToken
     * @throws AuthenticationException 认证异常
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 首先判断authentication参数必须是一个MobilePhoneAuthenticationToken类型对象
        Assert.isInstanceOf(MobilePhoneAuthenticationToken.class, authentication,
                ()-> this.messages.getMessage("MobilePhoneAuthenticationProvider.onlySupports", "Only MobilePhoneAuthenticationToken is supported"));
        // 获取authentication参数的principal属性作为手机号
        String phoneNo = authentication.getPrincipal().toString();
        if (StringUtils.isEmpty(phoneNo)) {
            logger.error("phoneNo cannot be null");
            throw new BadCredentialsException("phoneNo cannot be null");
        }
        // 获取authentication参数的credentials属性作为短信验证码
        String phoneCode = authentication.getCredentials().toString();
        if (StringUtils.isEmpty(phoneCode)) {
            logger.error("phoneCode cannot be null");
            throw new BadCredentialsException("phoneCode cannot be null");
        }
        try {
            // 调用userService服务根据手机号查询用户信息
            CustomUser user = (CustomUser) userService.loadUserByPhoneNum(Long.parseLong(phoneNo));
            // 校验用户账号是否过期、是否被锁住、是否有效等属性
            userDetailsChecker.check(user);
            // 根据手机号组成的key值去redis缓存中查询发送短信验证码时存储的验证码
            String storedPhoneCode = (String) redisTemplate.opsForValue().get("loginVerifyCode:"+phoneNo);
            if (storedPhoneCode==null) {
                logger.error("phoneCode is expired");
                throw new BadCredentialsException("phoneCode is expired");
            }
            // 用户登录携带的短信验证码与redis中根据手机号查询出来的登录认证短信验证码不一致则抛出验证码错误异常
            if (!phoneCode.equals(storedPhoneCode)) {
                logger.error("the phoneCode is not correct");
                throw new BadCredentialsException("the phoneCode is not correct");
            }
            // 把完成的用户信息赋值给组成返回认证token中的principal属性值
            Object principalToReturn = user;
            // 如果强制把用户信息转成字符串,则只返回用户的手机号码
            if(isForcePrincipalAsString()) {
                principalToReturn = user.getPhoneNum();
            }
            // 认证成功则返回一个MobilePhoneAuthenticationToken实例对象,principal属性为较为完整的用户信息
            MobilePhoneAuthenticationToken successAuthenticationToken = new MobilePhoneAuthenticationToken(user.getAuthorities(), principalToReturn, phoneCode);
            return successAuthenticationToken;
        } catch (UsernameNotFoundException e) {
            // 用户手机号不存在,如果用户已注册提示用户先去个人信息页面添加手机号码信息,否则提示用户使用手机号注册成为用户后再登录
            logger.error("user " + phoneNo + "not found, if you have been register as a user, please goto the page of edit user information to  add you phone number, " +
                    "else you must register as a user use you phone number");
            throw new BadCredentialsException("user " + phoneNo + "not found, if you have been register as a user, please goto the page of edit user information to  add you phone number, " +
                    "else you must register as a user use you phone number");
        } catch (NumberFormatException e) {
            logger.error("invalid phoneNo, due it is not a number");
            throw new BadCredentialsException("invalid phoneNo, due do phoneNo is not a number");
        }
    }
    
    /**
    * 只支持自定义的MobilePhoneAuthenticationToken类的认证
    */
    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.isAssignableFrom(MobilePhoneAuthenticationToken.class);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(this.messages, "A message source must be set");
        Assert.notNull(this.redisTemplate, "A RedisTemplate must be set");
        Assert.notNull(this.userService, "A UserDetailsService must be set");
    }

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

    public void setForcePrincipalAsString(boolean forcePrincipalAsString) {
        this.forcePrincipalAsString = forcePrincipalAsString;
    }

    public boolean isForcePrincipalAsString() {
        return forcePrincipalAsString;
    }
}
复制代码

En esta clase de autenticador personalizado, la authenticatelógica de autenticación personalizada se completa principalmente en el método y se devuelve una nueva autenticación después de que la autenticación sea exitosa.

MobilePhoneAuthenticationToken对象,principal属性为认证通过后的用户详细信息。

3 自定义AuthenticationFilter类

我们自定义的MobilePhoneAuthenticationFilter参照UsernamePasswordAuthenticationFilter类的源码实现一个专门用于手机号+验证码登录认证的认证过滤器,它的源码如下,我们主要在attemptAuthentication方法中完成从HttpServletRequest类型请求参数中提取手机号和短信验证码等请求参数。然后组装成一个MobilePhoneAuthenticationToken对象,用于调用this.getAuthenticationManager().authenticate方法时作为参数传入。

实现重写attemptAuthentication方法后的MobilePhoneAuthenticationFilter类的源码如下:

/**
 * 自定义手机登录认证过滤器
 */
public class MobilePhoneAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_PHONE_NO_KEY = "phoneNo";

    public static final String SPRING_SECURITY_PHONE_CODE_KEY = "phoneCode";

    private String phoneNoParameter = SPRING_SECURITY_PHONE_NO_KEY;

    private String phoneCodeParameter = SPRING_SECURITY_PHONE_CODE_KEY;

    private boolean postOnly = true;

    public MobilePhoneAuthenticationFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    public MobilePhoneAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
        super(requiresAuthenticationRequestMatcher);
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
        if (postOnly && !httpServletRequest.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + httpServletRequest.getMethod());
        }
        String phoneNo = obtainPhoneNo(httpServletRequest);
        if (phoneNo==null) {
            phoneNo = "";
        } else {
            phoneNo = phoneNo.trim();
        }
        String phoneCode = obtainPhoneCode(httpServletRequest);
        if (phoneCode==null) {
            phoneCode = "";
        } else {
            phoneCode = phoneCode.trim();
        }
        MobilePhoneAuthenticationToken authRequest = new MobilePhoneAuthenticationToken(new ArrayList<>(), phoneNo, phoneCode);
        this.setDetails(httpServletRequest, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    @Nullable
    protected String obtainPhoneNo(HttpServletRequest request) {
        return request.getParameter(phoneNoParameter);
    }

    @Nullable
    protected String obtainPhoneCode(HttpServletRequest request) {
        return request.getParameter(phoneCodeParameter);
    }

    protected void setDetails(HttpServletRequest request, MobilePhoneAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}
复制代码

4 修改UserService类

UserService类主要在用来查询用户自定义信息,我们在该类中添加根据手机号查询用户信息方法。注意如果用户表中没有手机号码字段,需要给表新增一个存储手机号码的字段,列类型为bigint, 实体类中该字段为Long类型

UserService类中实现根据用户手机号查询用户信息的实现代码如下:

@Service
@Transactional
public class UserService implements CustomUserDetailsService {
    @Resource
    UserMapper userMapper;
    
    @Resource
    RolesMapper rolesMapper;
    
    @Resource
    PasswordEncoder passwordEncoder;
   
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
    
    /**
     * 根据用户手机号查询用户详细信息
     * @param phoneNum 手机号
     * @return customUser
     * @throws UsernameNotFoundException
     */
     @Override
    public UserDetails loadUserByPhoneNum(Long phoneNum) throws UsernameNotFoundException {
        logger.info("用户登录认证, phoneNum={}", phoneNum);
        UserDTO userDTO = userMapper.loadUserByPhoneNum(phoneNum);
        if (userDTO == null) {
            // 抛UsernameNotFoundException异常
            throw  new UsernameNotFoundException("user " + phoneNum + " not exist!");
        }
        CustomUser customUser = convertUserDTO2CustomUser(userDTO);
        return customUser;
    }
    
    /**
     * UserDTO转CustomUser对象
     * @param userDTO
     * @return user
     */
    private CustomUser convertUserDTO2CustomUser(UserDTO userDTO) {
        //查询用户的角色信息,并返回存入user中
        List<Role> roles = rolesMapper.getRolesByUid(userDTO.getId());
        // 权限大的角色排在前面
        roles.sort(Comparator.comparing(Role::getId));
        CustomUser user = new CustomUser(userDTO.getUsername(), userDTO.getPassword(),
                userDTO.getEnabled()==1, true, true,
                true, new ArrayList<>());
        user.setId(userDTO.getId());
        user.setNickname(userDTO.getNickname());
        user.setPhoneNum(userDTO.getPhoneNum());
        user.setEmail(userDTO.getEmail());
        user.setUserface(userDTO.getUserface());
        user.setRegTime(userDTO.getRegTime());
        user.setUpdateTime(userDTO.getUpdateTime());
        user.setRoles(roles);
        user.setCurrentRole(roles.get(0));
        return user;
    }
   
}
复制代码

UserDTOCustomUser两个实体类源码如下:

public class UserDTO implements Serializable {

    private Long id;

    private String username;

    private String password;

    private String nickname;

    private Long phoneNum;

    // 有效标识:0-无效;1-有效
    private int enabled;

    private String email;

    private String userface;

    private Timestamp regTime;

    private Timestamp updateTime;
    // ......省略各个属性的set和get方法
}
复制代码
public class CustomUser extends User {
    private Long id;
    private String nickname;
    private Long phoneNum;
    private List<Role> roles;
    // 当前角色
    private Role currentRole;
    private String email;
    private String userface;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
    private Date regTime;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
    private Date updateTime;

    public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }

    public CustomUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }
    
    @Override
    @JsonIgnore
    public List<GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleCode()));
        }
        return authorities;
    }
    // ......省略其他属性的set和get方法
    
}
复制代码

Mapper层实现根据手机号码查询用户详细信息代码如下:

UserMapper.java

@Repository
public interface UserMapper {

    UserDTO loadUserByPhoneNum(@Param("phoneNum") Long phoneNum);
    // ......省略其他抽象方法
}
复制代码

UserMapper.xml

<select id="loadUserByPhoneNum" resultType="org.sang.pojo.dto.UserDTO">
        SELECT id, username, nickname,password, phoneNum, enabled, email, userface, regTime, updateTime
        FROM `user`
        WHERE phoneNum = #{phoneNum,jdbcType=BIGINT}
</select>
复制代码

5 修改短信服务sendLoginVeryCodeMessage方法

关于在SpringBoot项目中如何集成腾讯云短信服务实现发送短信验证码功能,可以参考我之前发表在公众号的文章SpringBoot项目中快速集成腾讯云短信SDK实现手机验证码功能

只是需要稍作修改,因为发短信验证码时要求国内手机号前缀为+86,后面接的是用户的11位手机号码。而我们的数据库中存储的是11位手机号码,使用手机号+短信验证码登录时使用的也是11位手机号码。因此将短信验证码存入redis缓存时需要将这里手机号的+86前缀去掉。如果这里不改,那么数据库中用户的手机号码字段就要设计成一个字符串类型,前端用户登录时传入的手机号参数也应该加上+86前缀。为了避免更多地方修改,我们就在这里修改好了。

SmsService.java

public SendSmsResponse sendLoginVeryCodeMessage(String phoneNum) {
        SendSmsRequest req = new SendSmsRequest();
        req.setSenderId(null);
        req.setSessionContext(null);
        req.setSign("阿福谈Java技术栈");
        req.setSmsSdkAppid(smsProperty.getAppid());
        req.setTemplateID(SmsEnum.PHONE_CODE_LOGIN.getTemplateId());
        req.setPhoneNumberSet(new String[]{phoneNum});
        String verifyCode = getCode();
        String[] params = new String[]{verifyCode, "10"};
        req.setTemplateParamSet(params);
        logger.info("req={}", JSON.toJSONString(req));
        try {
            SendSmsResponse res = smsClient.SendSms(req);
            if ("Ok".equals(res.getSendStatusSet()[0].getCode())) {
                // 截掉+86字段,发送短信验证码成功则将验证码保存到redis缓存中(目前只针对国内短息业务)
                phoneNum = phoneNum.substring(3);
                redisTemplate.opsForValue().set("loginVerifyCode:"+phoneNum, verifyCode, 10, TimeUnit.MINUTES);
            }
            return res;
        } catch (TencentCloudSDKException e) {
            logger.error("send message failed", e);
            throw new RuntimeException("send message failed, caused by " + e.getMessage());
        }
    
    // 其他代码省略

复制代码

6 修改WebSecurityConfig配置类

最后我们需要修改WebSecurityConfig配置类,定义MobilePhoneAuthenticationProviderAuthenticationManager两个类的bean方法,同时在两个configure方法中增加新的逻辑处理。

最后WebSecurityConfig配置类的完整代码如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserService userService;
    @Resource
    RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
        MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider = this.mobilePhoneAuthenticationProvider();
        auth.authenticationProvider(mobilePhoneAuthenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 添加手机登录认证过滤器,在构造函数中设置拦截认证请求路径
        MobilePhoneAuthenticationFilter mobilePhoneAuthenticationFilter = new MobilePhoneAuthenticationFilter("/mobile/login");
        mobilePhoneAuthenticationFilter.setAuthenticationSuccessHandler(new FormLoginSuccessHandler());
        mobilePhoneAuthenticationFilter.setAuthenticationFailureHandler(new FormLoginFailedHandler());
        // 下面这个authenticationManager必须设置,否则在MobilePhoneAuthenticationFilter#attemptAuthentication
        // 方法中调用this.getAuthenticationManager().authenticate(authRequest)方法时会报NullPointException
        mobilePhoneAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        mobilePhoneAuthenticationFilter.setAllowSessionCreation(true);
        http.addFilterAfter(mobilePhoneAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        // 配置跨域
        http.cors().configurationSource(corsConfigurationSource());
        // 禁用spring security框架的退出登录,使用自定义退出登录
        http.logout().disable();
        http.authorizeRequests()
                .antMatchers("/user/reg").anonymous()
                .antMatchers("/sendLoginVerifyCode").anonymous()
                .antMatchers("/doc.html").hasAnyRole("user", "admin")
                .antMatchers("/admin/**").hasRole("admin")
                ///admin/**的URL都需要有超级管理员角色,如果使用.hasAuthority()方法来配置,需要在参数中加上ROLE_,如下:hasAuthority("ROLE_超级管理员")
                .anyRequest().authenticated()//其他的路径都是登录后即可访问
                .and().formLogin().loginPage("http://localhost:3000/#/login")
                .successHandler(new FormLoginSuccessHandler())
                .failureHandler(new FormLoginFailedHandler()).loginProcessingUrl("/user/login")
                .usernameParameter("username").passwordParameter("password").permitAll()
                .and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(getAccessDeniedHandler());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/blogimg/**","/index.html","/static/**");
    }

    @Bean
    AccessDeniedHandler getAccessDeniedHandler() {
        return new AuthenticationAccessDeniedHandler();
    }

    //配置跨域访问资源
    private CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source =   new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");	//同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
        corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
        corsConfiguration.addAllowedMethod("*");	//允许的请求方法,PSOT、GET等
        corsConfiguration.setAllowCredentials(true);
        // 注册跨域配置
        source.registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
        return source;
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
    @Bean
    public MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider() {
        MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider = new MobilePhoneAuthenticationProvider(userService, redisTemplate);
        return mobilePhoneAuthenticationProvider;
    }
}
复制代码

7 效果体验

编码完成后,我们在启动Mysql服务器和Redis服务器后启动我们的SpringBoot项目

首先在Postman中调用发送短信验证码接口

Enviar código de verificación por SMS.png

注意:以上phoneNmber参数可填写任意用户合法的国内手机号码, 并以+86开头,然后点击postman中右上角的蓝色send按钮即可发送请求。

验证码发送成功后返回如下响应信息:

{
    "status": 200,
    "msg": "success",
    "data": {
        "code": "Ok",
        "phoneNumber": "+8618682244076",
        "fee": 1,
        "message": "send success"
    }
}
复制代码

同时手机上也会受到6位短信验证码,有效期10分钟

然后我们使用自己的手机号+收到的6位短信验证码调用登录接口

Número de teléfono móvil Autenticación de inicio de sesión de SMS.png

登录成功后返回如下响应信息:

{
    "msg": "login success",
    "userInfo": {
        "accountNonExpired": true,
        "accountNonLocked": true,
        "authorities": [
            {
                "authority": "ROLE_admin"
            },
            {
                "authority": "ROLE_user"
            },
            {
                "authority": "ROLE_test1"
            }
        ],
        "credentialsNonExpired": true,
        "currentRole": {
            "id": 1,
            "roleCode": "admin",
            "roleName": "管理员"
        },
        "email": "[email protected]",
        "enabled": true,
        "id": 3,
        "nickname": "程序员阿福",
        "phoneNum": 18682244076,
        "regTime": 1624204813000,
        "roles": [
            {
                "$ref": "$.userInfo.currentRole"
            },
            {
                "id": 2,
                "roleCode": "user",
                "roleName": "普通用户"
            },
            {
                "id": 3,
                "roleCode": "test1",
                "roleName": "测试角色1"
            }
        ],
        "username": "heshengfu"
    },
    "status": "success"
}
复制代码

写在最后

到这里,实现在集成SpringSecurity的SpringBoot应用中增加手机号+短信码的方式登录认证的功能也就实现了。各位读者朋友如果觉得文章对你有帮助,欢迎给我的这篇文章点个在看并转发给身边的程序员同事和朋友,谢谢!后面有时间笔者会在前端用户登录界面调用本次实现的后台接口实现手机号+短信验证码功能。

La siguiente es la dirección del código fuente de este artículo en mi repositorio de gitee. Los amigos que necesiten estudiar el código completo pueden clonarlo localmente.

Blogserver project gitee clon dirección: gitee.com/heshengfu12…

Este artículo publicó por primera vez la cuenta pública personal de WeChat [A Fu on Web Programming]. Los amigos que piensen que mi artículo es útil para usted pueden agregar una cuenta pública de WeChat para seguir. Hay mi información de contacto en el menú [Contactar con el autor] de la cuenta oficial. También puede agregarme en WeChat para comunicar problemas técnicos, ¡para que no estemos solos en el camino del avance tecnológico!

Supongo que te gusta

Origin juejin.im/post/7079276233929785380
Recomendado
Clasificación