springsecurity sessionregistry session共享_不用 Spring Security 可否?试试这个小而美的安全框架...
写在前面
在一款应用的整个生命周期,我们都会谈及该应用的数据安全问题。用户的合法性与数据的可见性是数据安全中非常重要的一部分。但是,一方面,不同的应用对于数据的合法性和可见性要求的维度与粒度都有所区别;另一方面,以当前微服务、多服务的架构方式,如何共享Session,如何缓存认证和授权数据应对高并发访问都迫切需要我们解决。Shiro的出现让我们可以快速和简单的应对我们应用的数据安全问题
Shiro介绍
Shiro简介
这个官网解释不抽象,所以直接用官网解释:Apache Shiro™是一个强大且易用的 Java 安全框架,可以执行身份验证、授权、加密和会话管理等。基于 Shiro 的易于理解的API,您可以快速、轻松地使任何应用程序变得安全(从最小的移动应用到最大的网络和企业应用)。
谈及安全,多数 Java 开发人员都离不开 Spring 框架的支持,自然也就会先想到 Spring Security,那我们先来看二者的差别
Shiro | Spring Security |
---|---|
简单、灵活 | 复杂、笨重 |
可脱离Spring | 不可脱离Spring |
粒度较粗 | 粒度较细 |
虽然 Spring Security 属于名震中外 Spring 家族的一部分,但是了解 Shiro 之后,你不会想 “嫁入豪门”,而是选择追求「诗和远方」冲动。
横看成岭侧成峰,远近高低各不同 (依旧是先了解概念就好)
远看 Shiro 看轮廓
Subject
它是一个主体,代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给SecurityManager;可以把 Subject 认为是一个门面;SecurityManager 才是实际的执行者
SecurityManager
安全管理器;即所有与安全有关的操作都会与 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,如果学习过 SpringMVC,你可以把它看成 DispatcherServlet前端控制器
Realm
域,Shiro 从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。
近看 Shiro 看细节
看图瞬间懵逼?别慌,会为你拆解来看,结合着图看下面的解释,这不是啥大问题,且看:
Subject
主体,可以看到主体可以是任何可以与应用交互的 “用户”
SecurityManager
相当于 SpringMVC 中的 DispatcherServlet;是 Shiro 的心脏;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理
Authenticator
认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;需要自定义认证策略(Authentication Strategy),即什么情况下算用户认证通过了
Authrizer
授权器,或者访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能
Realm
可以有 1 个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是JDBC实现,也可以是LDAP实现,或者内存实现等等;由用户提供;注意:Shiro 不知道你的用户/权限存储在哪及以何种格式存储;所以我们一般在应用中都需要实现自己的Realm
SessionManager
如果写过 Servlet 就应该知道 Session 的概念,Session 需要有人去管理它的生命周期,这个组件就是 SessionManager;而Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB等环境;所以,Shiro 就抽象了一个自己的Session 来管理主体与应用之间交互的数据;这样的话,比如我们在 Web 环境用,刚开始是一台Web服务器;接着又上了台EJB 服务器;这时又想把两台服务器的会话数据放到一个地方,我们就可以实现自己的分布式会话(如把数据放到Memcached 服务器)
SessionDAO
DAO大家都用过,数据访问对象,用于会话的 CRUD,比如我们想把 Session 保存到数据库,那么可以实现自己的SessionDAO,通过如JDBC写到数据库;比如想把 Session 放到 Memcached 中,可以实现自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;
CacheManager
缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能
Cryptography
密码模块,Shiro提高了一些常见的加密组件用于如密码「加密/解密」的
注意上图的结构,我们会根据这张图来逐步拆分讲解,记住这张图也更有助于我们理解 Shiro 的工作原理,所以依旧是打开两个网页一起看就好喽
搭建概览
多数小伙伴都在使用 Spring Boot, Shiro 也很应景的定义了 starter,做了更好的封装,对于我们来说使用起来也就更加方便,来看选型概览
序号 | 名称 | 版本 |
---|---|---|
1 | Springboot | 2.0.4 |
2 | JPA | 2.0.4 |
3 | Mysql | 8.0.12 |
4 | Redis | 2.0.4 |
5 | Lombok | 1.16.22 |
6 | Guava | 26.0-jre |
7 | Shiro | 1.4.0 |
使用 Spring Boot,大多都是通过添加 starter 依赖,会自动解决依赖包版本,所以自己尝试的时候用最新版本不会有什么问题,比如 Shiro 现在的版本是 1.5.0 了,整体问题不大,大家自行尝试就好
添加 Gradle 依赖管理
大体目录结构
application.yml 配置
基本配置
你就让我看这?这只是一个概览,先做到心中有数,我们来看具体配置,逐步完成搭建
其中 shiroFilter bean 部分指定了拦截路径和相应的过滤器,”/user/login”, ”/user”, ”/user/loginout” 可以匿名访问,其他路径都需要授权访问,shiro 提供和多个默认的过滤器,我们可以用这些过滤器来配置控制指定url的权限(先了解个大概即可):
配置缩写 | 对应的过滤器 | 功能 |
---|---|---|
anon | AnonymousFilter | 指定url可以匿名访问 |
authc | FormAuthenticationFilter | 指定url需要form表单登录,默认会从请求中获取username、password,rememberMe等参数并尝试登录,如果登录不了就会跳转到loginUrl配置的路径。我们也可以用这个过滤器做默认的登录逻辑,但是一般都是我们自己在控制器写登录逻辑的,自己写的话出错返回的信息都可以定制嘛。 |
authcBasic | BasicHttpAuthenticationFilter | 指定url需要basic登录 |
Logout | LogoutFilter | 登出过滤器,配置指定url就可以实现退出功能,非常方便 |
noSessionCreation | NoSessionCreationFilter | 禁止创建会话 |
perms | PermissionsAuthorizationFilter | 需要指定权限才能访问 |
port | PortFilter | 需要指定端口才能访问 |
rest | HttpMethodPermissionFilter | 将http请求方法转化成相应的动词来构造一个权限字符串,这个感觉意义不大,有兴趣自己看源码的注释 |
roles | RolesAuthorizationFilter | 需要指定角色才能访问 |
ssl | SslFilter | 需要https请求才能访问 |
user | UserFilter | 需要已登录或“记住我”的用户才能访问 |
数据库表设计
数据库表设计请参考 entity package下的 bean,通过@Entity 注解与 JPA 的设置自动生成表结构 (你需要简单的了解一下 JPA 的功能)。
我们要说重点啦~~~
身份认证
身份认证是一个证明 “李雷是李雷,韩梅梅是韩梅梅” 的过程,回看上图,Realm 模块就是用来做这件事的,Shiro 提供了 IniRealm,JdbcReaml,LDAPReam等认证方式,但自定义的 Realm 通常是最适合我们业务需要的,认证通常是校验登录用户是否合法。
新建用户 User
@Data@Entitypublic class User implements Serializable {@Id@GeneratedValue(strategy=GenerationType.AUTO)private Long id;@Column(unique =true)private String username;private String password;private String salt;}
定义 Repository
@Repositorypublic interface UserRepository extends JpaRepository<User, Long> {public User findUserByUsername(String username);}
编写UserController:
@GetMapping("/login")public void login(String username, String password) {UsernamePasswordToken token = new UsernamePasswordToken(username, password);token.setRememberMe(true);Subject currentUser = SecurityUtils.getSubject();currentUser.login(token);}
自定义 Realm
自定义 Realm,主要是为了重写 doGetAuthenticationInfo(…)方法
@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;String username = token.getUsername();User user = userRepository.findUserByUsername(username);SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, user.getPassword(), getName());simpleAuthenticationInfo.setCredentialsSalt(ByteSource.Util.bytes(user.getSalt()));return simpleAuthenticationInfo;}
这些代码我需要做一个说明,你可能也满肚子疑惑:
这段代码怎么应用了 shiro?
controller 是怎么调用到 custom realm 的?
重写的 doGetAuthenticationInfo(…) 方法目的是什么?
认证流程说明
用户访问/user/login
路径,生成 UsernamePasswordToken, 通过SecurityUtils.getSubject()获取Subject(currentUser),调用 login 方法进行验证,让我们跟踪一下代码,瞧一瞧就知道自定义的CustomRealm怎样起作用的,一起来看源码:
到这里我们要停一停了,请回看 Shiro 近景图,将源码追踪路径与其对比,是完全一致的
授权
身份认证是验证你是谁的问题,而授权是你能干什么的问题,
产品经理:申购模块只能科室看程序员:好的产品经理:科长权限大一些,他也能看申购模块程序员:好的(黑脸)产品经理:科长不但能看,还能修改数据程序员:关公提大刀,拿命来
…
作为程序员,我们的宗旨是:「能动手就不吵吵」; 硝烟怒火拔地起,耳边响起驼铃声(Shiro):「放下屠刀,立地成佛」授权没有那么麻烦,大家好商量…
整个过程和身份认证基本是一毛一样,你对比看看
角色实体创建
涉及到授权,自然要和角色相关,所以我们创建 Role 实体:
@Data@Entitypublic class Role {@Id@GeneratedValue(strategy=GenerationType.AUTO)private Long id;@Column(unique =true)private String roleCode;private String roleName;}
新建 Role Repository
@Repositorypublic interface RoleRepository extends JpaRepository<Role, Long> {@Query(value = "select roleId from UserRoleRel ur where ur.userId = ?1")List<Long> findUserRole(Long userId);List<Role> findByIdIn(List<Long> ids);}
定义权限实体 Permission
@Data@Entitypublic class Permission {@Id@GeneratedValue(strategy=GenerationType.AUTO)private Long id;@Column(unique =true)private String permCode;private String permName;}
定义 Permission Repository
@Repositorypublic interface PermissionRepository extends JpaRepository<Permission, Long> {@Query(value = "select permId from RolePermRel pr where pr.roleId in ?1")List<Long> findRolePerm(List<Long> roleIds);List<Permission> findByIdIn(List<Long> ids);}
建立用户与角色关系
其实可以通过 JPA 注解来制定关系的,这里为了说明问题,以单独外键形式说明
@Data@Entitypublic class UserRoleRel {@Id@GeneratedValue(strategy=GenerationType.AUTO)private Long id;private Long userId;private Long roleId;}
建立角色与权限关系
@Data@Entitypublic class RolePermRel {@Id@GeneratedValue(strategy=GenerationType.AUTO)private Long id;private Long permId;private Long roleId;}
编写 UserController
@RequiresPermissions("user:list:view")@GetMapping()public void getAllUsers(){List<User> users = userRepository.findAll();}
@RequiresPermissions("user:list:view")
注解说明具有用户:列表:查看权限的才可以访问),官网明确给出权限定义格式,包括通配符等,我希望你自行去查看
自定义 CustomRealm (主要重写 doGetAuthorizationInfo) 方法:
与认证流程如出一辙,只不过多了用户,角色,权限的关系罢了
授权流程说明
这里通过过滤器(见Shiro配置)和注解二者结合的方式来进行授权,和认证流程一样,最终会走到我们自定义的 CustomRealm 中,同样 Shiro 默认提供了许多注解用来处理不同的授权情况
注解 | 功能 |
---|---|
@RequiresGuest | 只有游客可以访问 |
@RequiresAuthentication | 需要登录才能访问 |
@RequiresUser | 已登录的用户或“记住我”的用户能访问 |
@RequiresRoles | 已登录的用户需具有指定的角色才能访问 |
@RequiresPermissions | 已登录的用户需具有指定的权限才能访问(如果不想和产品经理华山论剑,推荐用这个注解) |
授权官网给出明确的授权策略与案例,请查看:http://shiro.apache.org/permissions.html
上面的例子我们通过一直在通过访问 Mysql 获取用户认证和授权信息,这中方式明显不符合生产环境的需求
Session会话管理
做过 Web 开发的同学都知道 Session 的概念,最常用的是 Session 过期时间,数据在 Session 的 CRUD,同样看上图,我们需要关注 SessionManager 和 SessionDAO 模块,Shiro starter 已经提供了基本的 Session配置信息,我们按需在YAML中配置就好(官网https://shiro.apache.org/spring-boot.html 已经明确给出Session的配置信息)
Key | Default Value | Description |
---|---|---|
shiro.enabled | true | Enables Shiro’s Spring module |
shiro.web.enabled | true | Enables Shiro’s Spring web module |
shiro.annotations.enabled | true | Enables Spring support for Shiro’s annotations |
shiro.sessionManager.deleteInvalidSessions | true | Remove invalid session from session storage |
shiro.sessionManager.sessionIdCookieEnabled | true | Enable session ID to cookie, for session tracking |
shiro.sessionManager.sessionIdUrlRewritingEnabled | true | Enable session URL rewriting support |
shiro.userNativeSessionManager | false | If enabled Shiro will manage the HTTP sessions instead of the container |
shiro.sessionManager.cookie.name | JSESSIONID | Session cookie name |
shiro.sessionManager.cookie.maxAge | -1 | Session cookie max age |
shiro.sessionManager.cookie.domain | null | Session cookie domain |
shiro.sessionManager.cookie.path | null | Session cookie path |
shiro.sessionManager.cookie.secure | false | Session cookie secure flag |
shiro.rememberMeManager.cookie.name | rememberMe | RememberMe cookie name |
shiro.rememberMeManager.cookie.maxAge | one year | RememberMe cookie max age |
shiro.rememberMeManager.cookie.domain | null | RememberMe cookie domain |
shiro.rememberMeManager.cookie.path | null | RememberMe cookie path |
shiro.rememberMeManager.cookie.secure | false | RememberMe cookie secure flag |
shiro.loginUrl | /login.jsp | Login URL used when unauthenticated users are redirected to login page |
shiro.successUrl | / | Default landing page after a user logs in (if alternative cannot be found in the current session) |
shiro.unauthorizedUrl | null | Page to redirect user to if they are unauthorized (403 page) |
分布式服务中,我们通常需要将Session信息放入Redis中来管理,来应对高并发的访问需求,这时只需重写SessionDAO即可完成自定义的Session管理
整合Redis
@Configurationpublic class RedisConfig {@Autowiredprivate RedisConnectionFactory redisConnectionFactory;@Beanpublic RedisTemplate<String, Object> stringObjectRedisTemplate() {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(redisConnectionFactory);template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(new GenericJackson2JsonRedisSerializer());return template;}}
重写SessionDao
查看源码,可以看到调用默认SessionManager的retriveSession方法,我们重写该方法,将Session放入HttpRequest中,进一步提高session访问效率
向ShiroConfig中添加配置
其实在概览模块已经给出代码展示,这里单独列出来做说明:
/** * 自定义RedisSessionDao用来管理Session在Redis中的CRUD * @return */@Bean(name = "redisSessionDao")public RedisSessionDao redisSessionDao(){return new RedisSessionDao();}/** * 自定义SessionManager,应用自定义SessionDao * @return */@Bean(name = "customerSessionManager")public CustomerWebSessionManager customerWebSessionManager(){CustomerWebSessionManager customerWebSessionManager = new CustomerWebSessionManager();customerWebSessionManager.setSessionDAO(redisSessionDao());return customerWebSessionManager;}/** * 定义Security manager * @param customRealm * @return */@Bean(name = "securityManager")public DefaultWebSecurityManager defaultWebSecurityManager(CustomRealm customRealm) {DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager ();securityManager.setRealm(customRealm);securityManager.setSessionManager(customerWebSessionManager()); // 可不指定,Shiro会用默认Session managersecurityManager.setCacheManager(redisCacheManagers()); //可不指定,Shiro会用默认CacheManager// securityManager.setSessionManager(defaultWebSessionManager());return securityManager;}/** * 定义session管理器 * @return */@Bean(name = "sessionManager")public DefaultWebSessionManager defaultWebSessionManager(){DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();defaultWebSessionManager.setSessionDAO(redisSessionDao());return defaultWebSessionManager;}
至此,将 session 信息由 redis 管理功能就这样完成了
缓存管理
应对分布式服务,对于高并发访问数据库权限内容是非常低效的方式,同样我们可以利用Redis来解决这一问题,将授权数据缓存到Redis中
新建 RedisCache
@Slf4j@Componentpublic class RedisCache<K, V> implements Cache<K, V> {public static final String SHIRO_PREFIX = "shiro-cache:";@Resourceprivate RedisTemplate<String, Object> stringObjectRedisTemplate;private String getKey(K key){if (key instanceof String){return (SHIRO_PREFIX + key);}return key.toString();}@Overridepublic V get(K k) throws CacheException {log.info("read from redis...");V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k));if (v != null){return v;}return null;}@Overridepublic V put(K k, V v) throws CacheException {stringObjectRedisTemplate.opsForValue().set(getKey(k), v);stringObjectRedisTemplate.expire(getKey(k), 100, TimeUnit.SECONDS);return v;}@Overridepublic V remove(K k) throws CacheException {V v = (V) stringObjectRedisTemplate.opsForValue().get(getKey(k));stringObjectRedisTemplate.delete((String) get(k));if (v != null){return v;}return null;}@Overridepublic void clear() throws CacheException {//不要重写,如果只保存shiro数据无所谓}@Overridepublic int size() {return 0;}@Overridepublic Set<K> keys() {return null;}@Overridepublic Collection<V> values() {return null;}}
新建 RedisCacheManager
public class RedisCacheManager implements CacheManager {@Resourceprivate RedisCache redisCache;@Overridepublic <K, V> Cache<K, V> getCache(String s) throws CacheException {return redisCache;}}
至此,我们不用每次访问 Mysql DB 来获取认证和授权信息,而是通过 Redis 来缓存这些信息,大大提升了效率,也满足分布式系统的设计需求
总结
落霞与孤鹜齐飞 秋水共长天一色,产品经理和程序员一片祥和…
灵魂追问
都说 Redis 是单线程,但是很快,你知道为什么吗?
你们项目中是怎样控制认证授权的呢?当授权有变化,对于程序员来说,这个修改是灾难吗?
提高效率工具
MarkDown 表格生成器
本文的好多表格是从官网粘贴的,如何将其直接转换成 MD table 呢?那么 https://www.tablesgenerator.com/markdown_tables 就可以帮到你了,无论是生成 MD table,还是粘贴内容生成 table 和内容都是极好的,当然了不止 MD table,自己发现吧,更多工具,公众号回复 「工具」获得
推荐阅读
只会用 git pull ?有时候你可以尝试更优雅的处理方式
双亲委派模型:大厂高频面试题,轻松搞定
面试还不知道BeanFactory和ApplicationContext的区别?
如何设计好的RESTful API
程序猿为什么要看源码?
欢迎持续关注公众号:「日拱一兵」
前沿 Java 技术干货分享
高效工具汇总 回复「工具」
面试问题分析与解答
技术资料领取 回复「资料」
以读侦探小说思维轻松趣味学习 Java 技术栈相关知识,本着将复杂问题简单化,抽象问题具体化和图形化原则逐步分解技术问题,技术持续更新,请持续关注......
欢迎思想碰撞,点我留言交流
❤️「转发」和「在看」,是对我最大的支持❤️
springsecurity sessionregistry session共享_不用 Spring Security 可否?试试这个小而美的安全框架...相关推荐
- springsecurity sessionregistry session共享_要学就学透彻!Spring Security 中 CSRF 防御源码解析...
今日干货 刚刚发表查看:66666回复:666 公众号后台回复 ssm,免费获取松哥纯手敲的 SSM 框架学习干货. 上篇文章松哥和大家聊了什么是 CSRF 攻击,以及 CSRF 攻击要如何防御.主要 ...
- 不用 Spring Security 可否?试试这个小而美的安全框架
点击蓝色"程序猿DD"关注我 回复"资源"获取独家整理的学习资料! 写在前面 在一款应用的整个生命周期,我们都会谈及该应用的数据安全问题.用户的合法性与数据的可 ...
- gwt格式_使用Spring Security保护GWT应用程序的安全
gwt格式 在本教程中,我们将看到如何将GWT与Spring的安全模块(即Spring Security)集成. 我们将看到如何保护GWT入口点,如何检索用户的凭据以及如何记录各种身份验证事件. 此外 ...
- springsecurity中session失效后怎样处理_结合Spring Security进行web应用会话安全管理
结合Spring Security进行web应用会话安全管理 在本文中,将为大家说明如何结合Spring Security 管理web应用的会话.如果您阅读后觉得本文对您有帮助,期待您能关注.转发!您 ...
- spring 多租户_使用Spring Security的多租户应用程序的无状态会话
spring 多租户 从前, 我发表了一篇文章,解释了构建无状态会话的原理 . 巧合的是,我们再次为多租户应用程序执行同一任务. 这次,我们将解决方案集成到Spring Security框架中,而不是 ...
- java spring 登录验证_浅析Spring Security登录验证流程源码
一.登录认证基于过滤器链 Spring Security的登录验证流程核心就是过滤器链.当一个请求到达时按照过滤器链的顺序依次进行处理,通过所有过滤器链的验证,就可以访问API接口了. SpringS ...
- 登录用友显示java已被阻止_解决Spring Security 用户帐号已被锁定问题
1.问题描述 主要就是org.springframework.security.authentication.LockedException: 用户帐号已被锁定这个异常,完整异常如下: [2020-0 ...
- SpringSecurity系列文章 (一)Spring Security 认知
在文章开篇先说说为何使用Spring Security,在新的公司中现在认证授权服务是通过Spring Security auth2进行认证的,原本没用过这个啊,只知道apche shiro 现在还有 ...
- java oauth sso 源码_基于Spring Security Oauth2的SSO单点登录+JWT权限控制实践
概 述 在前文<基于Spring Security和 JWT的权限系统设计>之中已经讨论过基于 Spring Security和 JWT的权限系统用法和实践,本文则进一步实践一下基于 Sp ...
最新文章
- 【8】青龙面板系列教程之nolanjdc的一键安装获取cookie【作者删库了,不用尝试了】
- 串行总线---差分互连(差分线)之共模、奇模、偶模
- Lync日常维护之四:部分管理操作
- 自制一个 简易jQuery 的 API
- LevelDB源码解读
- JAVA复习5(集合——ArrayList)
- 如何使用vs将asp.net core项目添加容器支持并发布docker镜像到私有dockerhub和添加k8s/helm管理...
- [转载]文本特征TFIDF权重计算及文本向量空间VSM表示
- ansible解决python版本依赖
- 代码审计之CVE-2017-6920 Drupal远程代码执行漏洞学习
- leedcode刷题——整数反转
- 计算机入门认识图标,初学UI设计如何了解ICON图标的用处
- besiege机器人_《围攻》双脚机器人制作图文教程 双脚机器人怎么制作
- 一个新手RHCE的酸甜苦辣
- python 机器人运动仿真_基于ros平台的移动机器人的设计与运动仿真-创新创业训练计划.pdf...
- 替换=(等于号)正则表达式
- 单因素方差分析和多因素方差分析的差异是什么?
- 一套键盘鼠标跨电脑切换使用
- 学报格式和论文格式一样吗_学报论文格式
- android+噪音测试,Android技术开发之:噪音测试