Spring Cloud Gateway+Spring Security OAuth2+JWT(一)

导读:本篇文章讲解 Spring Cloud Gateway+Spring Security OAuth2+JWT(一),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

Spring Cloud Gateway+Spring Security OAuth2+JWT(一)

前言

捣鼓了一段时间的Spring cloud,想把公司的的基于用户和权限体系的基础框架也融入到Spring cloud中。查阅了一些相关资料,打算采用Spring Cloud Gateway+Spring Security OAuth2+JWT的方式。基础版本的已经搭好了,也总结一下这几天的成果。

授权服务 OAuth2+JWT

首先说明一下Spring Security OAuth2的交互数据可以有3种方式。

  1. 第一钟是基于配置的即内存
  2. 第二钟是基于内置数据结构的数据库(Spring Security OAuth2 自己定义了很多表结构和封装好的DAO 我们只要按照它给的表结构创建表就可以用了)
  3. 第三种就是我们自己定义的表

我有用了2和3,很多资料都是第一种,自己也研究了半天。不多说直接上代码

pom

如果采用redis存储token的话 要注意一下

	<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.security.oauth</groupId>
			<artifactId>spring-security-oauth2</artifactId>
			<!-- 指明版本,解决redis存储出现的问题:java.lang.NoSuchMethodError: org.springframework.data.redis.connection.RedisConnection.set([B[B)V问题 -->
			<version>2.3.3.RELEASE</version>
		</dependency>

授权服务器配置

这里主要是配置了用户的相关信息,由于我有自己的用户表所以我就采用了实现UserService接口的一个实现类。

package com.ljl.auth.config;

import com.ljl.auth.service.impl.UserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.sql.DataSource;

/**
 * Created by Administrator on 2019/9/3 0003.
 * 安全配置
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserServiceImpl userService;

    @Autowired
    private DataSource dataSource;

    /**
     * 注入用户信息服务
     * @return 用户信息服务对象
     */
    @Bean
    public UserDetailsService userDetailsService() {
        return userService;
    }

    /**
     * 全局用户信息
     * @param auth 认证管理
     * @throws Exception 用户认证异常信息
     */
    @Autowired
    public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
        //auth.jdbcAuthentication().dataSource(dataSource).passwordEncoder(passwordEncoder());
        auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
    }

    /**
     * 认证管理
     * @return 认证管理对象
     * @throws Exception 认证异常信息
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * http安全配置
     * @param http http安全对象
     * @throws Exception http安全异常信息
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest().authenticated().and()
                .httpBasic().and().csrf().disable();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

授权服务器配置。这边主要是token和配置客户端详情信息。
由于我用了jwt所以就不需要将token存储下来了,所以只用了OAUTH_CLIENT_DETAILS这个表。
OAuth2集成了很多表,可以去查询相关资料。

jwt我采用的是RSA加密的方法,工具类我也贴出来了。

package com.ljl.auth.config;

import com.ljl.common.util.RSAUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.sql.DataSource;

/**
 * Created by Administrator on 2019/9/3 0003.
 *  授权服务器配置
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    // 认证管理器
    @Autowired
    private AuthenticationManager authenticationManager;
    // redis连接工厂
    /*@Autowired
    private JedisConnectionFactory JedisConnectionFactory;*/
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private DataSource dataSource;
    /**
     * 令牌存储
     * @return redis令牌存储对象
     */
    /*@Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(JedisConnectionFactory);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(this.authenticationManager);
        endpoints.tokenStore(tokenStore());
    }*/

    @Bean
    protected JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new MyJwtAccessTokenConverter();
        converter.setKeyPair(RSAUtil.GetKeyPair());
        return converter;
    }

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter()).authenticationManager(this.authenticationManager);
    }

    /**
     * OAuth 授权端点开放
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security
                // 开启/oauth/token_key验证端口无权限访问
                .tokenKeyAccess("permitAll()")
                // 开启/oauth/check_token验证端口认证权限访问
                .checkTokenAccess("isAuthenticated()")
                //主要是让/oauth/token支持client_id以及client_secret作登录认证
                .allowFormAuthenticationForClients();
    }
    /**
     * OAuth 配置客户端详情信息
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(passwordEncoder);
    }


}

我用的是oracle

-- Create table
create table OAUTH_CLIENT_DETAILS
(
  client_id               VARCHAR2(255) not null,
  resource_ids            VARCHAR2(255),
  client_secret           VARCHAR2(255),
  scope                   VARCHAR2(255),
  authorized_grant_types  VARCHAR2(255),
  web_server_redirect_uri VARCHAR2(255),
  authorities             VARCHAR2(255),
  access_token_validity   NUMBER(11),
  refresh_token_validity  NUMBER(11),
  additional_information  VARCHAR2(255),
  autoapprove             VARCHAR2(255)
)
tablespace BASE
  pctfree 10
  initrans 1
  maxtrans 255
  storage
  (
    initial 64K
    next 1M
    minextents 1
    maxextents unlimited
  );

自定义用户

自定义用户表的话主要是需要实现UserService这个接口中的loadUserByUsername方法并返回一个uesr对象。
这边的查询用户是我另一个基础服务的一个接口,就是通过用户名查询数据。

package com.ljl.auth.service.impl;



import com.alibaba.fastjson.JSON;
import com.ljl.auth.model.*;
import com.ljl.auth.service.UserService;
import com.ljl.base.api.UserFeignApi;
import com.ljl.common.base.Pages;
import com.ljl.common.base.Result;
import com.ljl.common.model.BaseOperator;
import com.ljl.common.util.PasswordUtil;
import com.ljl.common.util.StaticData;
import com.ljl.dataSource.BaseDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.*;

@Service("userService")
public class UserServiceImpl implements UserService {
    @Autowired
	private UserFeignApi userFeignApi;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		//查询用户信息
		BaseOperator userinfo = userFeignApi.getUserByUsername(username);
		//需要构造org.springframework.security.core.userdetails.User 对象包含账号密码还有用户的角色
		if (userinfo!=null){
			User user =  new User(userinfo.getLoginname(),userinfo.getPassword(), AuthorityUtils.createAuthorityList("admin"));
			return new OauthUser(userinfo,user);
		}else {
			throw  new UsernameNotFoundException("用户["+username+"]不存在");
		}
	}
}

这个是我自己定义的实现了UserDetails, CredentialsContainer这两个接口的用户对象,除了再上面要用,jwt中也要用。
这边的baseUser 是我自己的用户对象。

package com.ljl.auth.model;

import com.ljl.common.model.BaseOperator;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
 * Created by Administrator on 2019/9/6 0006.
 */
public class OauthUser implements UserDetails, CredentialsContainer {


    private final BaseOperator baseUser;
    private final User user;

    public OauthUser(BaseOperator baseUser, User user) {
        this.baseUser = baseUser;
        this.user = user;
    }


    @Override
    public void eraseCredentials() {
        user.eraseCredentials();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getAuthorities();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return user.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return user.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return user.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return user.isEnabled();
    }

    public BaseOperator getBaseOperator() {
        return baseUser;
    }
}

jwt

jwt生成token 我们可以自己定义jwt里面的内容

package com.ljl.auth.config;

import com.ljl.auth.model.OauthUser;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

import java.util.HashMap;

/**
 * Created by Administrator on 2019/9/6 0006.
 * 自定义的token
 */
public class MyJwtAccessTokenConverter extends JwtAccessTokenConverter {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        if (accessToken instanceof DefaultOAuth2AccessToken) {
            //((DefaultOAuth2AccessToken) accessToken).setRefreshToken();
            Object principal = authentication.getPrincipal();
            if (principal instanceof OauthUser) {
                OauthUser user = (OauthUser) principal;
                HashMap<String, Object> map = new HashMap<>();
                map.put("user_id", user.getBaseOperator().getUserid());
                map.put("phone", user.getBaseOperator().getTelphonenum());
                ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
            }
        }
        return super.enhance(accessToken, authentication);
    }


}

RSA工具类

package com.ljl.common.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.Base64Utils;

import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Properties;

/**
 * 实现RSA加解密
 * @author : lijialun
 * @description:
 */
public class RSAUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(RSAUtil.class);
    /** 算法名称 */
    private static final String ALGORITHM =  "RSA";
    /** RSA签名算法 */
    private static final String RSA_SIGNATURE_ALGORITHM = "SHA256WithRSA";
    /** 默认密钥大小 */
    private static final int KEY_SIZE = 2048;
    /** 最大解密长度 */
    private static final int MAX_DECRYPT_BLOCK = 256;
    /** 用来指定保存密钥对的文件名和存储的名称 */
    private static final String PUBLIC_KEY_NAME = "publicKey";
    private static final String PRIVATE_KEY_NAME = "privateKey";
    private static final String PUBLIC_FILENAME = "publicKey.properties";
    private static final String PRIVATE_FILENAME = "privateKey.properties";
    private static Properties pubProperties;
    private static Properties PriProperties;
    /** 密钥对生成器 */
    private static KeyPairGenerator keyPairGenerator = null;

    private static KeyFactory keyFactory = null;
    /** 缓存的密钥对 */
    private static KeyPair keyPair = null;
    /** Base64 编码/解码器 JDK1.8 */
    private static Base64.Decoder decoder = Base64.getDecoder();
    private static Base64.Encoder encoder = Base64.getEncoder();

    /** 初始化密钥工厂 */
    static{
        try {
            keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM);
            keyFactory = KeyFactory.getInstance(ALGORITHM);
            //generateKeyPair();
            getInstanceForPub();
            getInstanceForPri();
        } catch (NoSuchAlgorithmException e) {
            LOGGER.error(e.getMessage(),e);
        }
    }
    /*初始化公钥config*/
    private static Properties getInstanceForPub(){
        if (pubProperties == null) {
            Resource res  =new ClassPathResource(PUBLIC_FILENAME);
            pubProperties = new Properties();
            try {
                pubProperties.load(res.getInputStream());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return pubProperties;
    }
    /*初始钥私钥config*/
    private static Properties getInstanceForPri(){
        if (PriProperties == null) {
            Resource res  =new ClassPathResource(PRIVATE_FILENAME);
            PriProperties = new Properties();
            try {
                PriProperties.load(res.getInputStream());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return PriProperties;
    }
    /** 私有构造器 */
    private RSAUtil(){}

    /**
     * 生成密钥对
     * 将密钥分别用Base64编码保存到#publicKey.properties#和#privateKey.properties#文件中
     * 保存的默认名称分别为publicKey和privateKey
     *
     *
     */
    public static synchronized void generateKeyPair(){
        try {
            keyPairGenerator.initialize(KEY_SIZE,new SecureRandom());
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (InvalidParameterException e){
            LOGGER.error("KeyPairGenerator does not support a key length of " + KEY_SIZE + ".",e);
        } catch (NullPointerException e){
            LOGGER.error("RSAUtils#key_pair_gen is null,can not generate KeyPairGenerator instance.",e);
        }
        RSAPublicKey rsaPublicKey = (RSAPublicKey)keyPair.getPublic();
        RSAPrivateKey rsaPrivateKey = (RSAPrivateKey)keyPair.getPrivate();
        String publicKeyString = encoder.encodeToString(rsaPublicKey.getEncoded());
        String privateKeyString = encoder.encodeToString(rsaPrivateKey.getEncoded());
        System.out.println("公钥:"+publicKeyString);
        System.out.println("私钥:"+privateKeyString);
        storeKey(publicKeyString,PUBLIC_KEY_NAME,pubProperties,PUBLIC_FILENAME);
        storeKey(privateKeyString,PRIVATE_KEY_NAME,PriProperties,PRIVATE_FILENAME);
    }

    /**
     * 将指定的密钥字符串保存到文件中,如果找不到文件,就创建
     * @param keyString 密钥的Base64编码字符串(值)
     * @param keyName  保存在文件中的名称(键)
     * @param properties 目标文件
     */
    private static void storeKey(String keyString,String keyName,Properties properties,String fileName){
        try {
            Resource res  =new ClassPathResource(fileName);
            FileOutputStream oFile = new FileOutputStream(res.getFile(), false);
            properties.setProperty(keyName,keyString);
            properties.store(oFile, keyName);
            oFile.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }


    }

    /**
     * 获取密钥字符串
     * @param keyName 需要获取的密钥名
     * @param properties 密钥文件
     * @return Base64编码的密钥字符串
     */
    private static String getKeyString(String keyName,Properties properties){
        return properties.getProperty(keyName);
    }

    /**
     * 从文件获取RSA公钥
     * @return RSA公钥
     * @throws InvalidKeySpecException
     */
    public static RSAPublicKey getPublicKey(){
        try {
            byte[] keyBytes = decoder.decode(getKeyString(PUBLIC_KEY_NAME,pubProperties));
            X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(keyBytes);
            RSAPublicKey rsa = (RSAPublicKey)keyFactory.generatePublic(x509EncodedKeySpec);
            return rsa;
        }catch (InvalidKeySpecException e) {
            LOGGER.error("getPublicKey()#" + e.getMessage(),e);
        }
        return null;
    }

    /**
     * 从文件获取RSA私钥
     * @return RSA私钥
     * @throws InvalidKeySpecException
     */
    public static RSAPrivateKey getPrivateKey(){
        try {
            byte[] keyBytes = decoder.decode(getKeyString(PRIVATE_KEY_NAME,PriProperties));
            PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(keyBytes);
            return (RSAPrivateKey)keyFactory.generatePrivate(pkcs8EncodedKeySpec);
        } catch (InvalidKeySpecException e) {
            LOGGER.error("getPrivateKey()#" + e.getMessage(),e);
        }
        return null;
    }
    /**
     * RSA公钥加密
     */
    public static byte[] encryptByPublicKey(byte[] data) throws Exception {
        // 对数据加密
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, getPublicKey());
        int inputLen = data.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 对数据分段加密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > 117) {
                cache = cipher.doFinal(data, offSet, 117);
            } else {
                cache = cipher.doFinal(data, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * 117;
        }
        byte[] encryptedData = out.toByteArray();
        out.close();
        return encryptedData;
    }

    //私钥解密
    public static byte[] decryptByPrivateKey(byte[] encryptedData) throws Exception {
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(getPrivateKey().getEncoded());
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.DECRYPT_MODE, privateK);
        int inputLen = encryptedData.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 对数据分段解密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > MAX_DECRYPT_BLOCK) {
                cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK);
            } else {
                cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * MAX_DECRYPT_BLOCK;
        }
        byte[] decryptedData = out.toByteArray();
        out.close();
        return decryptedData;
    }

    /**
     * RSA公钥验签
     *
     * @param data
     *            待签名字符串
     *            公钥(Base64编码)
     * @return 验签结果
     * @throws Exception
     */
    public static boolean verify(byte[] data, String sign) throws Exception {
        Signature signature = Signature.getInstance(RSA_SIGNATURE_ALGORITHM);
        signature.initVerify(getPublicKey());
        signature.update(data);
        return signature.verify(Base64Utils.decodeFromUrlSafeString(sign));
    }

    /**
     * RSA私钥签名:签名方式SHA1withRSA
     *
     * @param data
     *            待签名byte[]
     *            私钥(Base64编码)
     * @return 签名byte[]
     * @throws Exception
     */
    public static byte[] sign(byte[] data) throws Exception {
        // Sign
        Signature signature = Signature.getInstance(RSA_SIGNATURE_ALGORITHM);
        signature.initSign(getPrivateKey());
        signature.update(data);
        return signature.sign();
    }



    /**
     * 将char转换为byte
     * @param c char
     * @return byte
     */
    private static byte toByte(char c){
        return (byte)"0123456789ABCDEF".indexOf(c);
    }


    public static int getValidLength(byte[] bytes){
        int i = 0;
        if (null == bytes || 0 == bytes.length)
            return i ;
        for (; i < bytes.length; i++) {
            if (bytes[i] == '\0')
                break;
        }
        return i + 1;
    }

    public static KeyPair GetKeyPair(){
        return new KeyPair(getPublicKey(),getPrivateKey());
    }

}

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/15310.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!