1. 为什么在Spring Boot项目里选Shiro而不是别的权限框架
先说个现实问题。做Java后端的人一旦涉及登录、权限控制,十有八九会在Shiro和Spring Security之间纠结。Spring Security背靠Spring官方生态,功能全但学习曲线陡峭;Shiro则轻巧、直观,我当年从零上手只花了一个周末。如果项目不是那种需要极强的OAuth2/OpenID Connect协议级定制的大型企业应用,Shiro完全够用,而且它的设计思路更容易说清楚。
我把Shiro的定位用一句话概括:它就是Java生态里的“瑞士军刀”权限库。认证、授权、会话管理、加密集成,四件事包圆。你不需要为了只是“让用户登录一下”这种需求去背Spring Security那套Filter链和ProviderManager的概念。Shiro的核心模型就四个字:Subject、SecurityManager、Realm。
- Subject:当前用户操作的门面,你自己不直接操作数据,你永远通过Subject去问“我能干什么”。
- SecurityManager:内部的实际控制器,相当于Shiro的大脑中枢。
- Realm:连接你的用户数据源(数据库)和Shiro判断逻辑之间的桥梁,暴露给Shiro的其实就是查询接口。
- 认证(Authentication)+ 授权(Authorization):前者证明“你是谁”,后者证明“你能做什么”。
我做过的几个模拟项目中,有几回团队内部争论要不要切到Spring Security,后来评估一下工作量,核心业务又不需要SSO,最后还是老老实实继续用Shiro。原因很简单:技术选型的评判标准从来不是谁功能多,而是谁的模型和当前业务匹配度更高。Shiro在这类“单体应用+后台管理+角色权限”的经典场景下,优势就是直接、好维护、新人上手快。
接下来这篇文章,我会按真实开发顺序,从零搭一个Spring Boot整合Shiro的示例,包含登录认证、角色授权、数据库动态权限以及实际部署中容易踩的坑。我尽量把每个配置背后的原因讲透,而不是让你抄一坨能跑的代码就走。
2. 环境和基础架构准备
2.1 版本选型
版本搭配是很多人起步时第一个坑。网上教程多,但Spring Boot版本不同,引入Shiro的方式和兼容性有细微差别。我实测比较省心的组合如下:
- Spring Boot 2.7.x(不要一上来就追3.x,很多老教程还在2.x,踩坑排查时参考材料更充足)
- Shiro Spring Boot Starter 1.13.0(注意是shiro-spring-boot-starter,不是shiro-spring)
- JDK 1.8 或 11
- Maven 3.6+
可能有人问,为什么不直接用shiro-spring-boot-starter最新的2.0?因为Shiro 2.x目前官方迭代信号并不明确,1.x分支仍然是生产环境的主流稳定版本。1.13.0支持Spring Boot 2.7没有任何兼容性问题。如果你用Spring Boot 3.x,那很多老方案要重写,新手阶段没必要给自己加戏。
2.2 项目结构
我习惯按功能分包,而不是按层分包。这里用按功能分包的方式,让每个模块的依赖关系更清楚:
com.example.shirodemo ├── config // Shiro配置类、权限配置 ├── controller // 登录、用户、管理接口 ├── service // 业务层 ├── mapper // MyBatis或JPA映射 ├── entity // 用户、角色、权限实体 └── common // 返回结果封装、异常处理2.3 依赖引入
Maven核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.13.0</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>有一点必须提醒:有时引入shiro-spring-boot-starter后,会和Spring Boot的自动配置产生路径映射冲突,尤其是静态资源目录。比如有人发现项目里的/login页面访问不了,很可能就是Shiro默认拦截了所有路径。这个我在后面配置章节专门展开。
3. 核心概念拆解:为什么Shiro要这么设计
3.1 Subject:用户与代码之间的“门面”
Shiro设计中最核心的一点是,业务代码不应该直接依赖当前用户对象是哪种类型,也不应该自己判断用户是否登录。你只需要从某个地方拿到Subject:
Subject currentUser = SecurityUtils.getSubject(); currentUser.login(usernamePasswordToken);Subject内部持有委托给SecurityManager执行的逻辑。用个生活化的类比:去政务大厅办事,你不需要知道背后审批流转的各个窗口具体怎么运作,你只需要在柜台提交材料。这个柜台就是Subject,政务大厅的管理系统就是SecurityManager。
这套设计的好处是什么?代码解耦。你在Service层、Controller层、甚至工具类里都只需要访问Subject,不要自己new用户对象。后续哪怕从传统Session换成了JWT会话方式,对业务代码的侵入极小。
3.2 SecurityManager:真正干活的调度中枢
SecurityManager本身是个接口,实际用的时候通常用它默认的DefaultWebSecurityManager。它接收Subject的请求,然后去做三件事:认证、授权、会话管理。
很多人配置完Shiro后,只记得“我写了一个ShiroConfig”,但没搞清楚SecurityManager在里面的位置。我画个逻辑链条你感受下:
Subject → SecurityManager → Authenticator / Authorizer → Realm → 数据库Authenticator负责认证逻辑,Authorizer负责授权逻辑,Realm负责把数据库里的用户信息带进来。这样一层一层剥下去,你会发现Shiro的设计其实很符合单一职责原则。
3.3 Realm:把数据库“翻译”给Shiro的适配器
Realm是新手最容易写歪的地方。有人误以为Realm是写SQL的地方,把一堆查询逻辑塞进去;还有人在Realm里直接做密码对比,这些都是错的。
Realm本质上就是个适配器,Shiro内部定义好了方法:doGetAuthenticationInfo和doGetAuthorizationInfo。你只需做两件事:
- 在
doGetAuthenticationInfo里,根据传入的用户名查询用户信息,然后把查询到的用户对象返回给Shiro。 - 在
doGetAuthorizationInfo里,根据当前用户身份查询角色和权限,然后返回给Shiro。
密码验证这件事Shiro自己会做,你只需要把数据库里存储的密码返回给Shiro做比对。这里牵扯出密码加密的问题,我先卖个关子,后面有专门小节。
3.4 AuthenticationInfo和AuthorizationInfo的区别
很多初学者分不清这两个返回对象。
AuthenticationInfo:负责描述“这个用户是谁”。最常用的实现是SimpleAuthenticationInfo,构造函数里传入用户身份标识(通常是用户名)、密码、realmName。AuthorizationInfo:负责描述“这个用户能干什么”。常用的实现是SimpleAuthorizationInfo,往里塞角色集合和权限集合。
理解这两个对象,Shiro的整个工作流程就通了:先认证把身份核验通过,再授权把角色权限信息注入,后续每次访问需要校验的接口时,Shiro在内部查缓存或调用授权方法。
4. Spring Boot整合Shiro的完整配置实现
4.1 配置类详解
这是整个集成项目里最核心的文件。我贴出自己常用的配置版本,然后每个关键点逐行拆开。
@Configuration public class ShiroConfig { @Bean public Realm customRealm() { return new CustomRealm(); } @Bean public DefaultWebSecurityManager securityManager(Realm customRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(customRealm); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); factoryBean.setLoginUrl("/login"); factoryBean.setSuccessUrl("/index"); factoryBean.setUnauthorizedUrl("/403"); Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/register", "anon"); filterChainDefinitionMap.put("/static/**", "anon"); filterChainDefinitionMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return factoryBean; } }这里有几个关键易错点:
为什么是LinkedHashMap?因为过滤链是按顺序匹配的,一旦前面的规则命中,后面的规则不会继续判断。所以你一定要把anon这种放行规则放在前面,把authc这种需要认证的规则放在最后。否则写成跳表结构,/**一旦提前,你配置的静态资源放行全部失效。
为什么需要设置loginUrl和successUrl?这两个URL决定未登录跳转和登录成功跳转的地址。如果你做前后端分离,那基本不需要这两个跳转,而是改为返回JSON提示前端做路由切换。我这边的模拟项目是服务端渲染,所以保留了这个跳转逻辑。
为什么静态资源也要用anon放行?这是新手碰到最多的问题。默认情况下/** = authc会把所有路径都拦截,包括样式表、JS、图片。你以为Shiro没有生效,其实是页面资源全被拦截了。所以把常见的/static/**、/css/**、/js/**、/img/**放行。
4.2 自定义Realm的标准写法
public class CustomRealm extends AuthorizingRealm { @Resource private UserService userService; /** * 授权:查询用户角色和权限 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); Set<String> roles = userService.findRolesByUsername(username); Set<String> permissions = userService.findPermissionsByUsername(username); info.setRoles(roles); info.setStringPermissions(permissions); return info; } /** * 认证:查询用户身份 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) { throw new UnknownAccountException("用户名不存在"); } return new SimpleAuthenticationInfo(user, user.getPassword(), getName()); } }注意这里我传入的是user这个整个对象,而不仅是username字符串。这样做的目的是在后续要拿用户ID、邮箱等更多资料时,直接(User) principalCollection.getPrimaryPrincipal()就能取到,不用再多查一次库。
授权方法什么时候触发?不是登录就触发,而是第一次访问需要角色或权限校验的接口时触发。这里有个容易误导人的点:很多人以为登录时就会把所有权限加载到内存,其实Shiro是懒加载模式。这意味着首次访问一个被@RequiresPermissions标记的接口时,会先调用doGetAuthorizationInfo从库里查权限,然后再校验。这是正常现象,不是BUG。
4.3 密码加密与匹配器
现在很多项目还在把明文密码放进数据库,这非常危险。Shiro自带密码匹配机制,我惯用方案是MD5加盐。注意,这里我说的加盐不是简单字符串拼接,而是给每个用户生成独立的随机盐值。
@Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("MD5"); matcher.setHashIterations(1024); return matcher; }然后在自定义Realm里重写一个方法:
@Override public boolean supports(AuthenticationToken token) { return token instanceof UsernamePasswordToken; }这个supports方法用来声明当前Realm只处理哪种类型的Token。如果不覆盖,默认行为是处理所有Token。我遇到过因为多个Realm时supports没写对,导致认证流程走了错误Realm的情况,所以养成写这个方法的习惯。
加密这块,我建议密码存储格式用密码 + 盐分开存储,创建用户的时候随机生成盐值。验证明文密码时,Shiro会根据存储在库里的盐值重新算一次哈希,和库里的密文比。具体工具类代码我用Apache Commons Codec实现,散列迭代次数在性能和安全之间取个平衡点,1024次是安全基线,再高会引起登录性能下降。
5. 认证流程实现:从登录接口到权限校验
5.1 标准登录Controller写法
@RestController public class LoginController { @PostMapping("/login") public Result login(String username, String password) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(username, password); try { subject.login(token); return Result.success("登录成功"); } catch (UnknownAccountException e) { return Result.error("用户名不存在"); } catch (IncorrectCredentialsException e) { return Result.error("密码错误"); } catch (AuthenticationException e) { return Result.error("认证失败:" + e.getMessage()); } } }重点来了:subject.login(token)这个调用一旦成功,当前线程的Subject就持有了认证上下文,后续所有需要认证的接口都会自动通过。不需要手动往Session里存用户对象,Shiro内部已经维护了会话与Subject的关系。
我在实际项目中遇到的问题多发生在“登录成功返回JSON”的场景。因为如果配置类里设置了successUrl,有些版本会在登录成功后自动做服务端重定向,导致前端拿不到JSON。处理方案很简单:Controller里不要依赖Shiro那个successUrl跳转,自己返回数据让前端决定下一步。
5.2 注解权限控制
授权这块Shiro提供了三种注解方式:@RequiresAuthentication、@RequiresRoles、@RequiresPermissions。
@RequiresPermissions("user:delete") @DeleteMapping("/user/{id}") public Result deleteUser(@PathVariable Long id) { userService.delete(id); return Result.success(); }但要注意,Spring Boot中默认不启用注解扫描,需要在配置类里加一个Bean启用:
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }没有这个Advisor,注解是无效的。这个坑我印象很深,之前一个模拟项目上线后才发现接口用@PreAuthorize没起作用,排查到最后就是忘了注册这个Advisor。如果后面你用拦截器或过滤器做权限控制,也可以不依赖注解,直接用Shiro的subject.hasRole()和subject.isPermitted()判断,灵活性更高,适合复杂场景。
5.3 角色权限的表结构设计与查询SQL
我通常用经典的五张表方案:
sys_user:用户表sys_role:角色表sys_permission:权限表(菜单/按钮/接口标识)sys_user_role:用户角色关联表sys_role_permission:角色权限关联表
查询权限的核心SQL如下:
SELECT DISTINCT p.permission_code FROM sys_user u LEFT JOIN sys_user_role ur ON u.id = ur.user_id LEFT JOIN sys_role r ON ur.role_id = r.id LEFT JOIN sys_role_permission rp ON r.id = rp.role_id LEFT JOIN sys_permission p ON rp.permission_id = p.id WHERE u.username = #{username}这个查询把用户的所有权限合并起来去重。实际项目里权限数据量可能增长很快,所以必须考虑缓存。Shiro默认支持权限缓存,但需要Redis或Ehcache配合。配置好后,doGetAuthorizationInfo方法不会每次请求都触发,只有第一次会查库。
权限缓存命中后,执行流程是: 请求进来 → 注解/过滤器检查权限 → 缓存里取权限集合 → 有则直接通过 → 没有再走数据库这套流程下,首次登录后访问权限接口时会慢一点点,后面全部走缓存。
6. 会话、记住我与登出机制
6.1 SessionManager与默认会话生命周期
Shiro默认会自己维护会话,不依赖HttpSession。在Web环境下,它使用Servlet容器的Session。这对单体应用影响不大,但如果做分布式部署,就要考虑Session共享问题。常见两种解法:改成无状态的JWT方案,或把Session保存到Redis。
Spring Boot整合Shiro时,如果你不做任何额外配置,默认会话超时时间是30分钟。这个时间可以在配置类里修改:
@Bean public DefaultWebSessionManager defaultWebSessionManager() { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setGlobalSessionTimeout(60 * 60 * 1000L); return sessionManager; }注意单位是毫秒,60分钟就是60 * 60 * 1000,很多粗心的同学直接写60000,最后发现会话过一分钟就断了。
6.2 记住我功能的实现误区
记住我(Remember Me)和登录认证是两个不同级别的事。登录认证通过后,Subject会在当前会话内保持认证状态。而Remember Me是浏览器关闭、会话结束后仍能恢复部分身份信息,但它不等于完全以登录身份操作。
在Shiro里启用记住我非常简单:
UsernamePasswordToken token = new UsernamePasswordToken(username, password); token.setRememberMe(true);但这里有一个容易忽略的安全问题:如果配置了authc过滤器(表示必须认证),那么Remember Me是不生效的——认证状态未建立,接口还是拒绝访问。需要使用user过滤器来区分:
/user/** = useruser过滤器会判断:已认证用户直接通过,Remember Me用户也能通过,完全匿名用户拒绝。这个机制适合那种“记住登录状态但敏感操作依然要重新认证”的场景。
6.3 登出逻辑的两种姿势
Shiro默认在过滤链里注册了logout过滤器,配置好之后,访问/logout就会清除Subject并销毁会话。但是Service端渲染模式下,这个自动行为已经把会话清了,前端还需要跳转到登录页,因此需要登录URL前后端配合。
如果是纯接口场景,我更推荐手动登出:
Subject currentUser = SecurityUtils.getSubject(); currentUser.logout();这种方式的优势是灵活,你可以在登出前做埋点日志、清理自定义缓存数据,甚至在登出前拒绝某些操作。注意调完logout()之后Session已经被销毁,所以如果你在登出后还要获取当前用户信息,那是拿不到的。
7. 实战:动态权限配置与接口拦截
7.1 基于数据库动态生成URL过滤链
很多入门项目是把URL权限写死在配置类里的,比如:
filterChainDefinitionMap.put("/admin/**", "roles[admin]");这在权限需求固定的后台系统里没问题。但真实业务中,权限往往是动态变化的,出了一个新功能模块,要新加URL,不能每次改代码重启。我的做法是把URL和权限标识的对应关系抽到数据库表里,配置类里启动时动态读取。
大致流程是:
- 定义一张表
sys_url_permission:字段包括url、permission_code。 - 项目启动时从库里加载所有URL权限配置。
- 在
shiroFilterFactoryBean里循环构建过滤链。
List<UrlPermission> urlPermissions = permissionService.findAll(); for (UrlPermission item : urlPermissions) { filterChainDefinitionMap.put(item.getUrl(), "perms[" + item.getPermissionCode() + "]"); }这里用的过滤器是perms,它表示访问该URL需要指定权限字符串。这个配置只会在启动时加载一次,如果运行时权限变化了,需要动态刷新。实现动态刷新要拿到ShiroFilterFactoryBean的实例,重设过滤链,同时调用securityManager重新加载。这个操作在开发环境经常用到,我已经封装成工具方法,调用一次就能热更新权限映射。
7.2 前后端分离场景下的无状态改造
如果是纯前后端分离项目,服务端Session方案会带来不少跨域和网关问题。这时我倾向于把Shiro的登录验证改成无状态JWT模式。思路如下:
- 登录成功后生成JWT,返回前端。
- 前端每次请求带上
Authorization: Bearer <token>。 - 自定义一个
JwtFilter,继承AuthenticatingFilter。 - 在
JwtFilter里解析Token,构建一个JwtToken交给Subject。
这个方案的优点是后端无状态化,网关层可以直接校验Token。缺点是Shiro的会话机制彻底不启用,无法利用Shiro自带的Session管理,角色权限缓存等要自己再设计。对于追求微服务化的项目,这一步基本绕不过去。如果你的项目是单体后台,我建议还是优先用标准Session方案,简单可靠。
8. 常见问题与排查技巧实录
8.1 静态资源全部404
如果页面加载时CSS和JS全部报404,先检查Shiro过滤链是否把/static/**放行。另一个常见原因是Spring Boot的静态资源路径配置和Shiro拦截路径不一致。通常情况下/static/**是默认静态前缀;如果你把静态资源放在/resources下,过滤链里的路径也要同步改。
8.2 登录成功但无法访问任意接口
这个现象一般是authc过滤器和认证上下文之间的状态不一致。我遇到过的原因是:Controller里的登录方法没有走SecurityUtils.getSubject(),而是自己new了一个Subject,导致认证成功但Shiro上下文里没有持有真正的Subject实例。记住,SecurityUtils.getSubject()在Web环境下会从线程Session中取得实际Subject,千万不要手动new。
8.3 注解权限不生效
出现这个问题的原因基本是漏了AuthorizationAttributeSourceAdvisor。再次强调,只有把这个Bean注册进Spring容器,基于AOP的注解拦截才会生效。
还有一种情况是注解方法在Controller内部通过this调用,导致AOP代理没拦截到。比如在Controller里写了个私有方法标注@RequiresPermissions,然后同一个Controller里的另一个方法调用它,注解不会生效。原因是AOP代理拦截的是外部入口,内部方法调用不走代理。
8.4 Department的密码一直报错
密码校验失败大多是加密方式不一致。比如用户注册时用的是MD5 + 随机盐,但Realm里配置的HashedCredentialsMatcher没指定盐值来源。标准做法是SimpleAuthenticationInfo构造时传入盐值参数,Shiro会自动从库里读取的盐参与二次哈希验证,否则它拿不到盐当然校验失败。
盐值由数据库字段保存,我这里的设计是sys_user表加一个salt字段。注册用户时生成随机字符串存进去,登录验证时Shiro根据用户名查出用户信息后再取出这个盐。这个流程实现了同一密码不同用户密文完全不同的效果,就算数据库泄露,撞库难度直线上升。
9. 我踩过的一些坑总结
第一个坑是Shiro配置类里的Bean方法参数注入顺序问题。早年我用Spring Boot 2.1的时候,SecurityManager那个Bean里直接传Realm对象没问题,但升级到某个版本后,Bean注入顺序变了,导致Realm还没创建好就注入SecurityManager。最后改成@Qualifier指定Bean名才稳定。
第二个坑是Shiro的过滤链对URL通配符的处理。比如/user/*只会匹配一级路径,不会匹配/user/1/2这种多级路径。如果你需要匹配多级,要写/user/**。在配置动态权限时尤其注意这点,否则会出现“单独访问一级接口正常,带子路径就全部拦截”的诡异现象。
第三个坑是多个Realm时的认证顺序。系统里如果同时存在登录密码认证Realm和第三方认证Realm,一定要实现shiro.authenticator的ModularRealmAuthenticator并指定自定义策略,否则默认行为是所有Realm都要执行,这会直接导致密码Realm和第三方Realm互相同步报错。
第四个坑更隐蔽,Shiro和Spring事务的加载顺序问题。如果Realm里查询数据库的方法没有事务,而权限缓存又配置了过期时间,高并发下可能出现权限数据不一致。建议在权限查询方法上加@Transactional(readOnly = true),强制走数据库快照,避免缓存击穿。
10. 权限粒度设计上的实战建议
权限标识的命名规范我强烈建议统一。比如:
- 用户管理:
user:list、user:add、user:update、user:delete - 订单管理:
order:view、order:export、order:modify - 角色管理:
role:assign
这种资源:操作的命名方式,不仅清晰,而且后续做按钮级权限时可以直接映射到前端。前端拿到用户权限集合后,可以动态控制按钮显示。例如管理员页面中“删除用户”按钮的显示条件就是user:delete在权限集合里存在。
如果业务复杂到需要数据权限(比如只能看本部门数据),Shiro注解就搞不定了。这部分属于业务层权限过滤,我会在Service层用subject.hasPermission结合自定义参数做数据范围判断,或者在SQL里拼接权限条件。Shiro只能解决“能不能访问这个接口”的问题,解决不了“访问哪些数据”的问题。
11. 后续扩展方向
如果这个模拟项目做完了,下一步可以往这些方向扩展:
- 接入Redis做权限缓存与会话共享,扩展分布式部署场景。
- 引入JWT实现无状态登录,让团队理解Shiro会话机制如何升级。
- 做配套的前端按钮级权限管理,把后端权限数据映射为路由和按钮显示逻辑。
- 加一个操作审计日志模块,记录每次敏感操作的当前用户、IP、时间、操作内容。
我个人在实际操作中的体会是,Shiro这套东西没有那么玄乎,但它给你的设计思路非常有价值。它逼着你把用户身份、角色、权限这三件本来就容易混在一起的事情拆开梳理清楚。即使后面迁到框架,迁移时也能带着一套清晰的权限模型走。
最后分享一个小技巧:开发阶段建议在Shiro配置类里临时加一个/*.do = anon规则,这样既能保证接口可访问,又不会因为过滤器干扰Debug断点。等联调时再改成严格模式。没有这个习惯之前,我每次调接口都要先登录,排查问题效率低了不少。希望这份入门教程能让你少走几天弯路。