SpringSecurityと統合されたSpringBootアプリケーションにSMS検証コードログイン認証機能を追加するために手を取り合ってください

序文

前回の記事では、Spring Securityの名前とパスワードに基づいたログイン認証プロセスを明確にしました。著者はログイン認証プロセスを詳細Spring Securityに紹介し、携帯電話番号などのカスタムログイン認証を実装する必要があることも規定しました。 + SMS検証コード、電子メールアドレス+電子メール検証コード、およびサードパーティのログイン認証の拡張の準備が整いました。そのため、この記事では、統合プロジェクトに別のログイン認証方法、つまり携帯電話番号+SMS検証コードのログイン認証を実装する方法について説明しますSpring SecuritySpringBoot

プロジェクトの構築にかかる時間とコストを節約するために、この記事の機能の実現は、作成者が以前に変換したオープンソースプロジェクトblogserverに作成者は、プロジェクトコードアドレスを最後に提供します。記事。読者が記事の終わりを見るのに約5分を費やすことができることを願っています。

1カスタムAuthenticationToken

カスタムMobilePhoneAuthenticationTokenクラスはクラスを継承し、主にコンストラクターAbstractAuthenticationTokenパラメーターとオーバーライド、、、、getCredentialsおよびその他のメソッドを提供しますgetPrincipalsetAuthenticatederaseCredentialsgetName

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カスタムAuthenticationProviderクラス

クラスをカスタマイズするときは、クラスのソースコードMobilePhoneAuthenticationProviderを参照し、、、などの3つのインターフェイスを実装します。AbstractUserDetailsAuthenticationProviderAuthenticationProviderInitializingBeanMessageSourceAware

同時に、携帯電話番号+ SMS検証コードログイン認証の機能を実現するために、このクラスに2つのクラス属性をクラスのUserService2つの構築パラメータとして追加しました。RedisTemplateMobilePhoneAuthenticationProvider

このクラスのコーディングが完了した後のソースコードは次のとおりです。

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;
    }
}
复制代码

このカスタムオーセンティケータークラスでは、authenticateカスタム認証ロジックは主にメソッドで完了し、認証が成功すると新しい認証が返されます。

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中调用发送短信验证码接口

SMS検証code.pngを送信します

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

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

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

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

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

携帯電話番号SMSログインauthentication.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应用中增加手机号+短信码的方式登录认证的功能也就实现了。各位读者朋友如果觉得文章对你有帮助,欢迎给我的这篇文章点个在看并转发给身边的程序员同事和朋友,谢谢!后面有时间笔者会在前端用户登录界面调用本次实现的后台接口实现手机号+短信验证码功能。

以下は、私のgiteeリポジトリにあるこの記事のソースコードアドレスです。完全なコードを研究する必要がある友人は、ローカルでコードを複製できます。

Blogserverプロジェクトgiteeクローンアドレス:gitee.com/heshengfu12…

この記事では、最初に個人のWeChatパブリックアカウント[A Fu on Web Programming]を公開しました。私の記事があなたに役立つと思う友人は、フォローするWeChatパブリックアカウントを追加してください。公式アカウントの[連絡先]メニューに私の連絡先情報があります。また、技術的な進歩の道に私たちだけがいることのないように、技術的な問題を伝えるためにWeChatに私を追加することもできます。

おすすめ

転載: juejin.im/post/7079276233929785380