(一)前言

(1.1)什么是单点登录?

单点登录(Single Sign On,简称SSO),它的用途在于,不管多么复杂的应用群,只要在用户权限范围内,那么就可以做到,用户只需要登录一次就可以访问权限范围内的所有应用子系统。对于用户而言,访问多个应用子系统只需要登录一次,同样在需要注销的时候也只需要注销一次。

(1.2)OAuth2.0第三方授权登录与单点登录的区别

①各应用间的关系:OAuth2.0授权服务端和第三方客户端不属于一个互相信任的应用群(通常都不是同一个公司提供的服务),第三方客户端的用户不属于OAuth2.0授权服务端的官方用户;而单点登录的服务端和接入的客户端都在一个互相信任的应用群(通常是同一个公司提供的服务),各个子系统的用户属于单点登录服务端的官方用户。
②从资源角度来看。OAuth2.0授权主要是让用户自行决定——用户在OAuth2.0服务提供商的个人资源是否允许第三方应用访问,而具体能获取何种信息由服务提供商决定;而单点登录实现的做法有很多,可以将用户资源放在一个单点登录服务器中,做统一认证,由于单点登录服务器属于同一公司,因此可定制。

(二)授权登录原理

(2.1)第三方应用授权流程

(2.2)Spring Social在Spring Security过滤器链中的位置情况

(2.3)Spring Social 对授权流程的具体实现图

(2.4)向第三方应用发起授权登录的请求后

(三)代码实现

(3.1)QQ第三方登录

采取的是由里向外的开发顺序。

(1)请求服务提供商的具体实现

请求服务提供商(基于OAuth2.0授权码模式):申请授权码 ==》 携授权码申请AccessToken ==》 用AccessToken请求openId ==》 用openId和appId请求QQ用户信息

(1.1)获取openId 和 用户信息:

(1.1.1)用户信息类
//参照QQ返回的用户信息封装的用户信息类
public class QQUserInfo {//返回码private int ret;//如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。private String msg;//唯一标识private String openId;private int is_lost;//省(直辖市)private String province;//市(直辖市区)private String city;//出生年月private String year;private String constellation;//用户在QQ空间的昵称。private String nickname;//大小为30×30像素的QQ空间头像URL。private String figureurl;//大小为50×50像素的QQ空间头像URL。private String figureurl_1;//大小为100×100像素的QQ空间头像URL。private String figureurl_2;//大小为40×40像素的QQ头像URL。private String figureurl_qq_1;//大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100×100的头像,但40×40像素则是一定会有。private String figureurl_qq_2;private String figureurl_qq;private String figureurl_type;//性别。 如果获取不到则默认返回”男”private String gender;private int gender_type;//标识用户是否为黄钻用户(0:不是;1:是)。private String is_yellow_vip;//标识用户是否为黄钻用户(0:不是;1:是)private String vip;//黄钻等级private String yellow_vip_level;//等级private String level;//标识是否为年费黄钻用户(0:不是; 1:是)private String is_yellow_year_vip;/**Getters and Setters ...**/
}
(1.1.2)接口实现
//获取用户信息
public interface QQ {QQUserInfo getUserInfo();
}/*** QQ接口调用* 因为每一个用户的accessToken,openId是不同的,不能用@Component,这样就变成单例的对象了*/
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {ObjectMapper objectMapper = new ObjectMapper();//获取openId的请求串private static final String GET_OPENID_URL = "https://graph.qq.com/oauth2.0/me?access_token=%s";//获取用户信息的请求串private static final String GET_USERINFO_URL = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";private final Logger logger = LoggerFactory.getLogger(this.getClass());private String appId;//appId是去QQ互联上注册的应用idprivate String openId;//用户在QQ里的唯一标识(每个QQ对应一个)//accessToken,appId需要外边传入,openId在该类中获取public QQImpl(String accessToken, String appId){super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);//作为查询参数(TokenStrategy.AUTHORIZATION_HEADER则是放在请求头)this.appId = appId;String getOpenIdUrl = String.format(GET_OPENID_URL, accessToken);//用accessToken替换%sString result = getRestTemplate().getForObject(getOpenIdUrl, String.class);logger.info("openId result:"+result);this.openId = StringUtils.substringBetween(result,"\"openid\":\"", "\"}");}/*** 用openId(通过accessToken获取)和appId去获取用户信息* @return*/@Overridepublic QQUserInfo getUserInfo() {String getUserInfoUrl = String.format(GET_USERINFO_URL, this.appId, this.openId);String result = getRestTemplate().getForObject(getUserInfoUrl, String.class);logger.info("user info result:"+result);QQUserInfo userInfo;try {userInfo = objectMapper.readValue(result, QQUserInfo.class);userInfo.setOpenId(openId);return userInfo;} catch (IOException e) {throw new RuntimeException("获取用户信息失败!");}}
}

(1.2)申请授权码 以及 携授权码申请AccessToken:

public class QQOAuth2Template extends OAuth2Template {private Logger logger = LoggerFactory.getLogger(getClass());public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {super(clientId, clientSecret, authorizeUrl, accessTokenUrl);setUseParametersForClientAuthentication(true);//会设置clientId和clientSecret入参}//默认是转换成Map类型,而QQ返回的是url连接串,因此重写@Overrideprotected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);logger.info("获取accessToken的响应:"+responseStr);//成功返回串为:access_token=***&expires_in=7776000&refresh_token=***String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");String accessToken = StringUtils.substringAfterLast(items[0], "=");Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));//过期时间String refreshToken = StringUtils.substringAfterLast(items[2], "=");//刷新Tokenreturn new AccessGrant(accessToken, null, refreshToken, expiresIn);}@Overrideprotected RestTemplate createRestTemplate() {RestTemplate restTemplate = super.createRestTemplate();//处理text/html格式的responserestTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));return restTemplate;}
}
/*** QQ服务提供(主要进行两步-1.获取授权码 2.通过授权码申请票据(AccessToken),可用票据去调用api获取用户信息)* 主要的授权逻辑*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {private String appId;//请求认证服务器的地址private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";//请求获取令牌的地址private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";public QQServiceProvider(String appId, String appSecret) {//因为AbstractOAuth2ServiceProvider没有默认的构造方法实现(只有public AbstractOAuth2ServiceProvider(OAuth2Operations oauth2Operations))//因此若想继承该类必须实现该类的构造方法super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));//Auth2Template是OAuth2Operations的子类this.appId = appId;}/*** 获取api接口实例(重写父类方法)* @param accessToken* @return*/@Overridepublic QQ getApi(String accessToken) {return new QQImpl(accessToken, this.appId);}
}

(2)与数据库交互

Spring Social会建立关于第三方应用登录的表UserConnection.

(2.1)QQ用户数据与Spring Social表规范字段适配

//QQ接口适配器
public class QQAdapter implements ApiAdapter<QQ> {@Overridepublic boolean test(QQ qq) {return true;//默认qq这个api是通的}@Overridepublic void setConnectionValues(QQ qq, ConnectionValues connectionValues) {QQUserInfo qqUserInfo = qq.getUserInfo();connectionValues.setDisplayName(qqUserInfo.getNickname());//显示的名称 - QQ昵称connectionValues.setImageUrl(qqUserInfo.getFigureurl_qq_1());//头像路径 - QQ空间小头像connectionValues.setProfileUrl("");//个人主页 - 空connectionValues.setProviderUserId(qqUserInfo.getOpenId());//用户ID - QQ openId}@Overridepublic UserProfile fetchUserProfile(QQ qq) {return null;}@Overridepublic void updateStatus(QQ qq, String s) {//更新个人状态,此处不做更新}
}
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {public QQConnectionFactory(String providerId, String appId, String appSecret) {super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());}
}
@Configuration
@ConditionalOnProperty(prefix = "funnee.security.social.qq", name = "app-id")//存在该配置才将该类加入spring容器
public class QQAutoConfig extends SocialAutoConfigurerAdapter {@Autowiredprivate SecurityProperties securityProperties;@Overrideprotected ConnectionFactory<?> createConnectionFactory() {QQProperties qqConfig = securityProperties.getSocial().getQq();return new QQConnectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(), qqConfig.getAppSecret());}
}

由于本地数据库是MySql8.0的rank是关键字不能命名表字段,因此需要重写与该字段相关的类。

//复制一份JdbcConnectionRepository,将其所有用到rank字段的地方都改为自己定义的字段
public class FunneeJdbcConnectionRepository implements ConnectionRepository  {//......
}
//由于最后JdbcUsersConnectionRepository中会创建JdbcConnectionRepository的实例
//这里复制一份JdbcUsersConnectionRepository,将其用到JdbcConnectionRepository的地方改为自己新建的类
public class FunneeJdbcUsersConnectionRepository implements UsersConnectionRepository {//......public ConnectionRepository createConnectionRepository(String userId) {if (userId == null) {throw new IllegalArgumentException("userId cannot be null");}return new FunneeJdbcConnectionRepository(userId, jdbcTemplate, connectionFactoryLocator, textEncryptor, tablePrefix);}
}

(3)配置

(3.1)Spring Social相关配置
回调地址 = 域名 + filterProcessesUrl + providerId.

SpringSocialConfigurer源码将SocialAuthenticationFilter加入过滤器链。

/*** 自定义的SpringSocialConfigure* SpringSocialConfigurer会将SocialAuthenticationFilter加入过滤器链中*/
public class FunneeSpringSocialConfigure extends SpringSocialConfigurer {@AutowiredSecurityProperties securityProperties;//回调地址的前半部分,参照SocialAuthenticationFilter的部分源码/*private String getRequestedProviderId(HttpServletRequest request) {//...if (!uri.startsWith(this.filterProcessesUrl)) {return null;} else {uri = uri.substring(this.filterProcessesUrl.length());return uri.startsWith("/") ? uri.substring(1) : null;}}*/private String filterProcessesUrl;public FunneeSpringSocialConfigure(String filterProcessesUrl){this.filterProcessesUrl = filterProcessesUrl;}@Overrideprotected <T> T postProcess(T object) {SocialAuthenticationFilter filter = (SocialAuthenticationFilter)super.postProcess(object);filter.setFilterProcessesUrl(filterProcessesUrl);//默认是"/auth",这里修改成配置化的地址filter.setSignupUrl(securityProperties.getBrowser().getSignUpUrl());//默认是"/signup",这里修改成配置化的地址return (T)filter;}
}
(3.2)配置Spring Social的ConnectionRepository
@Configuration
@EnableSocial//开启spring security social相关支持
@Order(1)//意为加载顺序,数字越小,优先级越高
//若不加这行,SocialAuthenticationProvider中的usersConnectionRepository为默认的InMemoryUsersConnectionRepository,
//不是自定义的FunneeJdbcUsersConnectionRepository
public class SocialConfig extends SocialConfigurerAdapter {@AutowiredDataSource dataSource;@AutowiredSecurityProperties securityProperties;@Autowired(required = false)//若不存在则不注入private ConnectionSignUp connectionSignUp;//链接自动注册@Overridepublic UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {UsersConnectionRepository usersConnectionRepository = new FunneeJdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());if(connectionSignUp != null){((FunneeJdbcUsersConnectionRepository) usersConnectionRepository).setConnectionSignUp(connectionSignUp);}return usersConnectionRepository;}@Beanpublic SpringSocialConfigurer funneeSpringSocialConfig(){return new FunneeSpringSocialConfigure(securityProperties.getSocial().getFilterProcessesUrl());}@Beanpublic ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator)){};}
}
(3.3)配置到Spring Security的拦截机制中
@Configuration
@EnableWebSecurity
public class BrowserSecurityConfig  extends AbstractChannelSecurityConfig {@Autowiredprivate SecurityProperties securityProperties;@Autowiredprivate DataSource dataSource;@Autowiredprivate SpringSocialConfigurer springSocialConfigurer;//将spring social相关过滤器加到过滤器链中@Beanpublic PersistentTokenRepository persistentTokenRepository(){JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();jdbcTokenRepository.setDataSource(dataSource);//没有添加jdbc依赖导致该方法不存在//jdbcTokenRepository.setCreateTableOnStartup(true);return jdbcTokenRepository;}//配置了该bean之后,spring security校验用户密码时会用该加密算法进行校验(而不是明文)@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();//可用自定义的加密方式(实现PasswordEncoder即可)}@Overrideprotected void configure(HttpSecurity http) throws Exception {//http.httpBasic()//spring默认校验方式http.formLogin().loginPage("/authentication/login").apply(springSocialConfigurer).and().authorizeRequests()//对请求授权.antMatchers(SecurityConstants.DEFAULT_UNAUTHENTICATED_URL,securityProperties.getBrowser().getLoginUrl(),SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*",securityProperties.getBrowser().getSignUpUrl(),"/user/register").permitAll().anyRequest().authenticated()//进行身份认证.and().csrf().disable();}
}

(4)注册登录流程

SocialAuthenticationProvider 和 SocialAuthenticationFilter部分源码解析:

/**SocialAuthenticationProvider**/
public Authentication authenticate(Authentication authentication) throws AuthenticationException {Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type");Assert.isTrue(!authentication.isAuthenticated(), "already authenticated");SocialAuthenticationToken authToken = (SocialAuthenticationToken)authentication;String providerId = authToken.getProviderId();Connection<?> connection = authToken.getConnection();String userId = this.toUserId(connection);if (userId == null) {//未找到用户//顶层SocialAuthenticationFilter会将其捕获并处理throw new BadCredentialsException("Unknown access token");} else {UserDetails userDetails = this.userDetailsService.loadUserByUserId(userId);if (userDetails == null) {throw new UsernameNotFoundException("Unknown connected account id");} else {return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), this.getAuthorities(providerId, userDetails));}}
}
protected String toUserId(Connection<?> connection) {//根据关联用户的connection获取userIdList<String> userIds = this.usersConnectionRepository.findUserIdsWithConnection(connection);return userIds.size() == 1 ? (String)userIds.iterator().next() : null;
}/**SocialAuthenticationFilter**/
private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {try {if (!authService.getConnectionCardinality().isAuthenticatePossible()) {return null;} else {token.setDetails(this.authenticationDetailsSource.buildDetails(request));Authentication success = this.getAuthenticationManager().authenticate(token);//会跳至provider执行Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principle type");this.updateConnections(authService, token, success);return success;}} catch (BadCredentialsException var5) {if (this.signupUrl != null) {//跳转到注册页this.sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));throw new SocialAuthenticationRedirectException(this.buildSignupUrl(request));} else {throw var5;}}
}

注册页:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body><h2>Demo注册页</h2><form action="/user/register" method="post"><table><tr><td>用户名:</td> <td><input type="text" name="name"></td></tr><tr><td>密码:</td><td><input type="password" name="password"></td></tr><tr><td colspan="2"><button type="submit" name="type" value="register">注册</button><button type="submit" name="type" value="binding">绑定</button></td></tr></table></form>
</body>
</html>

注册控制器实现: ProviderSignInUtils是基于session的注册工具类。

@RestController
@RequestMapping("/user")
@Api(value = "用户查询接口", description = "用户查询接口")
public class UserController {@Autowiredprivate ProviderSignInUtils providerSignInUtils;@PostMapping("/register")public void register(User user, HttpServletRequest request) {//不管是注册用户还是绑定用户,都会拿到一个用户唯一标识。String userId = user.getName();providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));}
}

安全跳转的相关配置:

@RestController
public class BrowserSecurityController {//重定向private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();//请求缓存private RequestCache requestCache = new HttpSessionRequestCache();@AutowiredSecurityProperties securityProperties;@Autowiredprivate ProviderSignInUtils providerSignInUtils;Logger logger = LoggerFactory.getLogger(BrowserSecurityController.class);/*** 认证跳转页面* @param request* @param response* @return*/@RequestMapping("/authentication/login")@ResponseStatus(code = HttpStatus.UNAUTHORIZED)public SimpleResponse loginPage(HttpServletRequest request, HttpServletResponse response) throws IOException {SavedRequest savedRequest = requestCache.getRequest(request, response);if(savedRequest != null){String targetUrl = savedRequest.getRedirectUrl();logger.info("目标url:"+targetUrl);if(StringUtils.endsWithIgnoreCase(targetUrl, ".html")){redirectStrategy.sendRedirect(request,response,securityProperties.getBrowser().getLoginUrl());}}return new SimpleResponse("当前请求需要授权认证,请登录!");}//获取当前登录的用户信息@GetMapping("/social/user")public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {SocialUserInfo userInfo = new SocialUserInfo();Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));userInfo.setProviderId(connection.getKey().getProviderId());userInfo.setProviderUserId(connection.getKey().getProviderUserId());userInfo.setNickname(connection.getDisplayName());userInfo.setHeadimg(connection.getImageUrl());return userInfo;}
}

(四)测试

输入: 域名 + filterProcessesUrl + providerId.
成功跳转至QQ授权登录页面。
授权后成功跳转回调地址。

微信单点

五、开发问题记录

问题1: Maven拥有同一级parent无法成功导入同项目别的依赖。(报错信息:Failed to read artifact descriptor for com.funnee…)
解决: 通过C:\用户.m2\repository发现父项依赖并没有成功安装在本地仓库,重新安装父项依赖(在父项pom的同级目录执行命令mvn -U install(将包安装在本地仓库),然后再去子项目刷新maven即可)

问题2: 被引入项目的websecurity配置类(adapter)不生效
解决: ①在springboot启动类添加注解
@ComponentScan(basePackages = {“com.funnee.browser”}),能够扫描到该类。
②被引入项目的Maven配置需要添加下面代码

<build><!--将本地代码封入包内--><sourceDirectory>src/main/java</sourceDirectory>
</build>

问题3: 配置自定义页面时,遇到循环重定向的问题。

解决: 因为配置了自定义登录页loginPage,然而又对所有请求进行授权要求,因此loginPage就又发向loginPage请求授权,如此循环下去。将匹配loginPage的URL放行就不会出现这种现象。

@Configuration
@EnableWebSecurity
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http.formLogin().loginPage("/login/login.html").and().authorizeRequests().antMatchers("/login/login.html").permitAll()//添加这句得以解决.anyRequest().authenticated();}
}

问题4: browserProperty get set 对象名不一致导致

1spring MVC方式
2.spring boot方式
3.整合CAS

Spring Security渐入佳境(四) -- 第三方应用授权登录相关推荐

  1. Spring Security+Oauth2四种授权模式

    上一篇文章:Spring Security + OAuth2.0项目搭建:https://blog.csdn.net/qq_42402854/article/details/123057625 接着认 ...

  2. 从零开始超详细的Spring Security OAuth2.0实现分布式系统授权(注册中心+网关+认证授权服务(JWT令牌验证)+资源调用服务)

    文章目录 一.OAuth2.0 1.介绍 2.例子 3.执行流程 二.Spring Cloud Security OAuth2 1.环境介绍 2.认证流程 三.整合分布式项目 1.技术方案 2.项目结 ...

  3. Spring Security整合JWT,实现单点登录,So Easy~!

    前面整理过一篇 SpringBoot Security前后端分离,登录退出等返回json数据,也就是用Spring Security,基于SpringBoot2.1.4 RELEASE前后端分离的情况 ...

  4. Spring Security 实战干货:OAuth2授权回调的核心认证流程

    1. 前言 我们在上一篇 Spring Security 实战干货:OAuth2 授权回调的处理机制 对 OAuth2 服务端调用客户端回调的流程进行了图解, 今天我们来深入了解 OAuth2 在回调 ...

  5. Spring Security | 轻松搞定认证授权~

    文章目录 一.Spring Security 简介 二.整合 Spring Security 1.创建项目,导入依赖 2.创建数据库,搭建环境 3.配置数据库 4.创建实体类 5.实现 UserMap ...

  6. 小程序第三方微信授权登录的实现

    在微信小程序下,小程序登录功能一般会通过 OpenID 或 UnionID 作为唯一标识,与小程序服务的账号体系进行关联打通,完成用户账户体系的构建与设计. 如果在像支付宝小程序.百度小程序第三方平台 ...

  7. 自有App小程序第三方微信授权登录的实现

    在微信小程序下,小程序登录功能一般会通过 OpenID 或 UnionID 作为唯一标识,与小程序服务的账号体系进行关联打通,完成用户账户体系的构建与设计. 如果在像支付宝小程序.百度小程序第三方平台 ...

  8. 第三方平台授权登录— —QQ登录

    第三方平台授权登录- -QQ登录 本来前两天开开心心!心情乐乐!居然因为自己没有仔细看代码.居然被埋在这里两天!呜呜!怒火中烧!不行,我要去消灭 五碗大米饭 来平复我的心情! <程序运行图> ...

  9. Spring Security 4 整合Hibernate 实现持久化登录验证(带源码)

    上一篇文章:Spring Security 4 整合Hibernate Bcrypt密码加密(带源码) 原文地址:http://websystique.com/spring-security/spri ...

最新文章

  1. 理论优美的深度信念网络,Hinton老爷子北大最新演讲
  2. Python3.6.4 安装
  3. 给你汇报Struts2 S2-016漏洞修复的总结
  4. 开源是如何让Android成为移动市场大佬的?
  5. Matlab程序仿真与调试入门
  6. cordova 支付_Cordova打包教程(H5项目打包成ipa)
  7. 明了 | MongoDB 外键的基本使用
  8. 介绍一下ajax后最好的返回结果的方式#34;Json#34;
  9. 胡侃学习(理论)计算机-From 南大小百合
  10. ntdll.dll处引发的异常: 0xC0000005: 写入位置 0x00000004 时发生访问冲突
  11. Oracle Data Guard官方说明
  12. 七年级计算机会考重点知识点总结,七年级语文期中考试复习知识点总结
  13. 十进制转换为二进制、原码、反码、补码、移码
  14. 成为大数据工程师需要具备什么能力?发展前景好么
  15. python爬虫-Handler处理器的基本使用
  16. 你想要的宏基因组-微生物组知识全在这(2022.12)
  17. ProximitySensor校准
  18. nohup 执行mysql命令_Linux nohup命令:后台命令脱离终端运行
  19. 肿瘤细胞膜纳米载体|HL-7702正常肝细胞膜复合纳米脂质体|Hep3B肝癌细胞膜包覆纳米载体
  20. 五款WordPress图片压缩插件推荐

热门文章

  1. java 快递查询接口
  2. python输入框_python 输入框
  3. WIN32学习——鼠标消息
  4. ESD9B5VLD-2/TR 1线 双向 低电容 瞬态电压抑制器WILLSEM
  5. c语言norm函数的作用,带有示例的C ++中的norm()函数
  6. 加密无政府主义者宣言-源文
  7. power oj 2840: 伯陵防线 思维+树状数组
  8. centos7修改系统时间
  9. 技校学计算机当兵,技校毕业证可以当兵吗 有用吗
  10. matlab simulink有什么用,MATLAB Simulink从入门到放弃