我们经常使用的各类网站和App均会涉及注册、登录和修改密码等功能,登录系统后,有些功能会提示没有权限,甚至有些位置我们无法访问,这些都是系统权限和认证的体现。

我们从本章及后面的章节中,将学习在ASP.NET Core应用程序中使用ASP.NET Core Identity实现安全认证相关功能所需要掌握的知识。

本章主要向读者介绍如下内容。

21.1 ASP.NET Core Identity介绍

ASP.NET Core Identity是一个会员身份系统,早期它的名字是Membership,当然那是一段“古老”的历史,现在我们来了解全新的Identity。它允许我们创建、读取、更新和删除账户。支持账号验证、身份验证、授权、恢复密码和SMS双因子身份验证。它还支持微软、Facebook和Google等第三方登录提供商。它提供了一个丰富的API,并且这些API还可以进行大量的扩展。我们将在本书的后面实现这些功能。

添加ASP.NET Core Identity服务

这里采用的是EF Core,因为要让我们的系统支持Identity服务,所以需要安装它的程序包。打开NuGet管理器,安装Microsoft.AspNetCore.Identity.EntityFrameworkCore即可。

以下是添加和配置ASP.NET Core Identity服务的步骤。

使AppDbContext继承类IdentityDbContext,然后引入命名空间,代码如下。

public class AppDbContext:IdentityDbContext { //其余代码 }

配置ASP.NET Core Identity服务。在Startup类的ConfigureServices()方法中,添加以下代码行。

services.AddIdentity<IdentityUser,IdentityRole>() .AddEntityFrameworkStores<AppDbContext>();

接下来,将Authentication()中间件添加到请求管道,代码如下。

public void Configure(IApplicationBuilder app,IWebHostEnvironment env) { //如果环境是Development serve Developer Exception Page if(env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } //否则显示用户友好的错误页面 else if(env.IsStaging() || env.IsProduction() || env.IsEnvironment("UAT")) { app.UseExceptionHandler("/Error"); app.UseStatusCodePagesWithReExecute("/Error/{0}"); } //使用纯静态文件支持的中间件,而不使用带有终端的中间件 app.UseStaticFiles(); //添加验证中间件 app.UseAuthentication(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name:"default", pattern:"{controller=Home}/{action=Index}/{id?}"); }); }

在Startup类的Configure()方法中,调用UseAuthentication()方法将Authentication()中间件添加到应用程序的请求处理管道中。我们希望能够在请求到达MVC中间件之前对用户进行身份验证。因此,在请求处理管道的UseRouting()中间件之前添加认证中间件。这很重要,因为我们之前讲过中间件的添加顺序不能乱。

现在开始添加身份迁移。在Visual Studio中的程序包控制台窗口执行以下命令以添加新迁移。

Add-Migration AddingIdentity

此迁移包含用于创建ASP.NET Core Identity系统所需的表的代码。

如果运行,则会出现以下错误。

The entity type'IdentityUserLogin'requires a primary key to be defined.

之前因为要封装Seed()方法,所以重写OnModelCreating()方法。出现这个错误是因为我们在DbContext类中重写了OnModelCreating()方法,但未调用基本IdentityDbContext类OnModelCreating()方法。

Identity表的键映射在IdentityDbContext类的OnModelCreating()方法中。因此,要解决这个错误,需要做的是,调用基类OnModelCreating()使用该方法的基础关键字,代码如下。

public class AppDbContext:IdentityDbContext { public AppDbContext(DbContextOptions<AppDbContext> options):base(options) { } public DbSet<Student> Students{get;set;} protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Seed(); } }

执行Update-Database命令以应用迁移记录并创建所需的身份表,如图21.1所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(1)

图21.1

21.2 使用ASP.NET Core Identity注册新用户

现在已经创建好了表的信息,接下来我们增加一个注册功能,让用户能够注册到系统中。

新用户注册视图应如图21.2所示。为了能够注册为新用户,需要邮箱地址和密码两个字段。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(2)

图21.2

21.2.1 RegisterViewModel视图模型

我们将使用RegisterViewModel类作为Register视图的模型,它负责将视图中的信息传递给控制器。为了验证信息是否正确,我们使用了几个ASP.NET Core验证属性。在之前的章节中详细说明过这些属性和模型验证。

using System.ComponentModel.DataAnnotations; namespace MockSchoolManagement.ViewModels { public class RegisterViewModel { [Required] [EmailAddress] [Display(Name = "邮箱地址")] public string Email{get;set;} [Required] [DataType(DataType.Password)] [Display(Name = "密码")] public string Password{get;set;} [DataType(DataType.Password)] [Display(Name = "确认密码")] [Compare("Password", ErrorMessage = "密码与确认密码不一致,请重新输入.")] public string ConfirmPassword{get;set;} } }

在这里我们添加了DataType特性,它的主要作用是指定比数据库内部类型更具体的数据类型。DataType枚举提供了多种数据类型,比如日期、时间、电话号码、货币和邮箱地址等。但是请注意,DataType特性不提供任何验证,它主要服务于我们的视图文件,比如,DataType.EmailAddress可以在视图中创建mailto:链接,DataType.Date则会在支持HTML5的浏览器中提供日期选择器。

21.2.2 账户控制器

账户控制器(accountController)是指所有与账户相关的CRUD(增加、读取、更新和删除)操作都将在此控制器中。目前我们只有Register()操作方法,可以通过向/account/register发出GET请求来实现此操作方法。

using Microsoft.AspNetCore.Mvc; namespace MockSchoolManagement.Controllers { public class AccountController:Controller { [HttpGet] public IActionResult Register() { return View(); } } }

21.2.3 注册视图中的代码

将此视图放在Views/Account文件夹中,此视图的模型是我们在前面创建的Register ViewModel。

@model RegisterViewModel @{ViewBag.Title = "用户注册";} <h1>用户注册</h1> <div class="row"> <div class="col-md-12"> <form method="post"> <div asp-validation-summary="All" class="text-danger"> </div> <div class="form-group"> <label asp-for="Email"> </label> <input asp-for="Email" class="form-control" /> <span asp-validation-for="Email" class="text-danger"> </span> </div> <div class="form-group"> <label asp-for="Password"> </label> <input asp-for="Password" class="form-control" /> <span asp-validation-for="Password" class="text-danger"> </span> </div> <div class="form-group"> <label asp-for="ConfirmPassword"> </label> <input asp-for="ConfirmPassword" class="form-control" /> <span asp-validation-for="ConfirmPassword" class="text-danger"> </span> </div> <button type="submit" class="btn btn-primary">注册</button> </form> </div> </div>

21.2.4 添加注册按钮

在布局视图中添加注册按钮,我们需要在_Layout.cshtml文件中找到ID为collapsibleNavbar的导航菜单栏,在下方添加注册按钮,导航到对应的视图,代码如下。

<div id="collapsibleNavbar" class="collapse navbar-collapse"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" asp-controller="home" asp-action="Index">学生列表</a> </li> <li class="nav-item"> <a class="nav-link" asp-controller="home" asp-action="Create">添加学生</a> </li> </ul> <ul class="navbar-nav ml-auto"> <li class="nav-item"> <a class="nav-link" asp-controller="account" asp-action="register"> 注册 </a> </li> </ul> </div>

运行项目后,单击注册按钮即可看到图21.2所示的效果图,接下来我们实现处理HttpPOST请求到/account/register的Register()操作方法。然后通过表单Taghelpers将数据发布到ASP.NET Core Identity中创建账户。

21.3 UserManager和SignInManager服务

在本节我们学习使用ASP.NET Core Identity提供的UserManager服务创建新用户,然后使用其提供的SignInManager服务来登录用户。

UserManager <IdentityUser>类包含管理基础数据存储中的用户所需的方法。比如,此类具有CreateAsync()、DeleteAsync()和UpdateAsync()等方法来创建、删除和更新用户,如图21.3所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(3)

图21.3

SignInManager <IdentityUser>类包含用户登录所需的方法。比如,SignInManager类具有SignInAsync()、SignOutAsync()等方法来登录和注销用户,如图21.4所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(4)

图21.4

以下是AccountController的完整代码。

using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using MockSchoolManagement.ViewModels; using System.Threading.Tasks; namespace MockSchoolManagement.Controllers { public class AccountController:Controller { private UserManager<IdentityUser> _userManager; private SignInManager<IdentityUser> _signInManager; public AccountController(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager) { this._userManager = userManager; this._signInManager = signInManager; } [HttpGet] public IActionResult Register() { return View(); } [HttpPost] public async Task<IActionResult> Register(RegisterViewModel model) { if(ModelState.IsValid) { //将数据从RegisterViewModel复制到IdentityUser var user = new IdentityUser { UserName = model.Email, Email = model.Email }; //将用户数据存储在AspNetUsers数据库表中 var result = await _userManager.CreateAsync(user,model.Password); //如果成功创建用户,则使用登录服务登录用户信息 //并重定向到HomeController的索引操作 if(result.Succeeded) { await _signInManager.SignInAsync(user,isPersistent:false); return RedirectToAction("index","home"); } //如果有任何错误,则将它们添加到ModelState对象中 //将由验证摘要标记助手显示到视图中 foreach(var error in result.Errors) { ModelState.AddModelError(string.Empty,error.Description); } } return View(model); } } }

此时,如果读者运行项目并提供有效的邮箱地址和密码,则它会在SQL Server数据库的AspNetUsers表中创建账户。读者可以从Visual Studio的SQL Server对象资源管理器中查看此数据,如图21.5所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(5)

图21.5

21.3.1 ASP.NET Core Identity中对密码复杂度的处理

在刚刚注册的时候,我们发现有两个问题。

这是因为ASP.NET Core IdentityOptions类在ASP.NET Core中用于配置密码复杂性规则。默认情况下,ASP.NET Core身份不允许创建简单的密码来保护我们的应用程序免受自动暴力攻击。

当我们尝试使用像abc这样的简单密码注册新账户时,会显示创建失败,读者将看到如图21.6所示的验证错误。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(6)

图21.6

我们在图21.6中看到中文提示,后面的章节会告诉读者如何配置。

21.3.2 ASP.NET Core Identity密码默认设置

在ASP.NET Core Identity中,密码默认设置在PasswordOptions类中。读者可以在ASP.NET Core GitHub仓库中找到此类的源代码。只需在仓库中搜索PasswordOptions类。

代码如下。

public class PasswordOptions { public int RequiredLength{get;set;} = 6; public int RequiredUniqueChars{get;set;} = 1; public bool RequireNonAlphanumeric{get;set;} = true; public bool RequireLowercase{get;set;} = true; public bool RequireUppercase{get;set;} = true; public bool RequireDigit{get;set;} = true; }

相关参数的说明如表21.1(略)所示。

21.3.3 覆盖ASP.NET Core身份中的密码默认设置

我们可以通过在Startup类的ConfigureServices()方法中使用IServiceCollection接口的Configure()方法来实现这一点。

services.Configure<IdentityOptions>(options => { options.Password.RequiredLength = 6; options.Password.RequiredUniqueChars = 3; options.Password.RequireNonAlphanumeric = false; options.Password.RequireLowercase = false; options.Password.RequireUppercase = false; });

也可以在添加身份服务时执行此操作,代码如下。

services.AddIdentity<IdentityUser,IdentityRole>(options => { options.Password.RequiredLength = 6; options.Password.RequiredUniqueChars = 3; options.Password.RequireNonAlphanumeric = false; }) .AddEntityFrameworkStores<AppDbContext>();

当然,在这里推荐使用IdentityOptions的形式进行配置,因为它可以作为一个独立服务,而不是嵌套在AddIdentity()方法中。

IdentityOptions对象中除了Password的配置信息,还有用户、登录、策略等配置信息,我们可以根据不同的场景进行灵活的配置。

21.3.4 修改中文提示的错误信息

Identity提供了AddErrorDescriber()方法,可方便我们进行错误内容的配置和处理。

ASP.NET Core默认提供的都是英文提示,我们可以将它们修改为中文。现在我们创建一个CustomIdentityErrorDescriber的类文件,路径为根目录下创建的CustomerMiddlewares文件夹,然后继承IdentityErrorDescriber服务,添加以下代码。

public class CustomIdentityErrorDescriber:IdentityErrorDescriber { public override IdentityError DefaultError() { return new IdentityError{Code = nameof(DefaultError),Description = $"发生了未知的故障。" }; } public override IdentityError ConcurrencyFailure() { return new IdentityError{Code = nameof(ConcurrencyFailure),Description = "乐观并发失败,对象已被修改。" }; } public override IdentityError PasswordMismatch() { return new IdentityError{Code = nameof(PasswordMismatch),Description = "密码错误" }; } public override IdentityError InvalidToken() { return new IdentityError{Code = nameof(InvalidToken),Description = "无效的令牌." }; } public override IdentityError LoginAlreadyAssociated() { return new IdentityError{Code = nameof(LoginAlreadyAssociated),Description = "具有此登录的用户已经存在." }; } public override IdentityError InvalidUserName(string userName) { return new IdentityError{Code = nameof(InvalidUserName),Description = $"用户名'{userName}'无效,只能包含字母或数字." }; } public override IdentityError InvalidEmail(string email) { return new IdentityError{Code = nameof(InvalidEmail),Description = $"邮箱'{email}'无效." }; } public override IdentityError DuplicateUserName(string userName) { return new IdentityError{Code = nameof(DuplicateUserName),Description = $"用户名'{userName}'已被使用." }; } public override IdentityError DuplicateEmail(string email) { return new IdentityError{Code = nameof(DuplicateEmail),Description = $"邮箱'{email}'已被使用." }; } public override IdentityError InvalidRoleName(string role) { return new IdentityError{Code = nameof(InvalidRoleName),Description = $"角色名'{role}'无效." }; } public override IdentityError DuplicateRoleName(string role) { return new IdentityError{Code = nameof(DuplicateRoleName),Description = $"角色名'{role}'已被使用." }; } public override IdentityError UserAlreadyHasPassword() { return new IdentityError{Code = nameof(UserAlreadyHasPassword),Description = "该用户已设置了密码." }; } public override IdentityError UserLockoutNotEnabled() { return new IdentityError{Code = nameof(UserLockoutNotEnabled),Description = "此用户未启用锁定." }; } public override IdentityError UserAlreadyInRole(string role) { return new IdentityError{Code = nameof(UserAlreadyInRole),Description = $"用户已关联角色'{role}'." }; } public override IdentityError UserNotInRole(string role) { return new IdentityError{Code = nameof(UserNotInRole),Description = $"用户未关联角色'{role}'." }; } public override IdentityError PasswordTooShort(int length) { return new IdentityError{Code = nameof(PasswordTooShort),Description = $"密码必须至少是{length}字符." }; } public override IdentityError PasswordRequiresNonAlphanumeric() { return new IdentityError { Code = nameof(PasswordRequiresNonAlphanumeric), Description = "密码必须至少有一个非字母数字字符." }; } public override IdentityError PasswordRequiresDigit() { return new IdentityError{Code = nameof(PasswordRequiresDigit),Description = $"密码必须至少有一个数字('0'-'9')." }; } public override IdentityError PasswordRequiresUniqueChars(int uniqueChars) { return new IdentityError{Code = nameof(PasswordRequiresUniqueChars),Description = $"密码必须使用至少不同的{uniqueChars}字符。" }; } public override IdentityError PasswordRequiresLower() { return new IdentityError{Code = nameof(PasswordRequiresLower),Description = "密码必须至少有一个小写字母('a'-'z')." }; } public override IdentityError PasswordRequiresUpper() { return new IdentityError{Code = nameof(PasswordRequiresUpper),Description = "密码必须至少有一个大写字母('A'-'Z')." }; } }

回到Startup类的ConfigureServices()方法中,在AddIdentity()服务中使用AddErrorDescriber()方法覆盖默认的错误提示内容,代码如下。

services.AddIdentity<IdentityUser,IdentityRole>().AddErrorDescriber<CustomIdentityErrorDescriber>().AddEntityFrameworkStores<AppDbContext>();

配置完成之后,提示变为中文,注册时密码长度达到6位即可。

21.4 登录状态及注销功能的实现

在本节中我们学习如何判断用户是否登录,以及注册、登录和注销等功能是否可实现。

首先来看一看如何在ASP.NET Core中实现注销功能。如果用户未登录,则显示登录和注册按钮,如图21.7所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(7)

图21.7

如果用户已登录,请隐藏登录和注册按钮并显示注销按钮,如图21.8所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(8)

图21.8

我们需要在_Layout.cshtml文件中找到ID为collapsibleNavbar的导航菜单栏,修改代码如下。

在下方代码中注入了SignInManager,以便我们检查用户是否已登录,来决定显示和隐藏的内容。

@using Microsoft.AspNetCore.Identity @inject SignInManager<IdentityUser> _signInManager <div class="collapse navbar-collapse" id="collapsibleNavbar"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" asp-controller="home" asp-action="index">学生列表</a> </li> <li class="nav-item"> <a class="nav-link" asp-controller="home" asp-action="create">添加学生</a> </li> </ul> <ul class="navbar-nav ml-auto"> @*如果用户已登录,则显示注销链接*@ @if(_signInManager.IsSignedIn(User)) { <li class="nav-item"> <form method="post" asp-controller="account" asp-action="logout"> <button type="submit" style="width:auto" class="nav-link btn btn-link py-0"> 注销 @User.Identity.Name </button> </form> </li> }else{ <li class="nav-item"> <a class="nav-link" asp-controller="account" asp-action="register"> 注册 </a> </li> <li class="nav-item"> <a class="nav-link" asp-controller="account" asp-action="login"> 登录 </a> </li> } </ul> </div> </IdentityUser>

然后在AccountController中添加以下Logout()方法。

[HttpPost] public async Task<IActionResult> Logout() { await _signInManager.SignOutAsync(); return RedirectToAction("index","home"); }

请注意,我们使用POST请求将用户注销,而不使用GET请求,因为该方法可能会被滥用。恶意者可能会诱骗用户单击某张图片,将图片的src属性设置为应用程序注销URL,这样会造成用户在不知不觉中退出了账户。

21.5 ASP.NET Core Identity中的登录功能实现

在本节中,我们将讨论使用ASP.NET Core Identity的API在ASP.NET Core应用程序中实现登录功能。要在ASP.NET Core应用程序中实现登录功能,我们需要实现以下功能。

21.5.1 LoginViewModel登录视图模型

要在系统中登录用户,则需要其邮箱、用户名、密码以及使其选择是否需要持久性Cookie或会话Cookie。

public class LoginViewModel { [Required] [EmailAddress] public string Email{get;set;} [Required] [DataType(DataType.Password)] public string Password{get;set;} [Display(Name = "记住我")] public bool RememberMe{get;set;} }

21.5.2 登录视图的代码

登录视图的代码如下。

@model LoginViewModel @{ViewBag.Title = "用户登录";} <h1>用户登录</h1> <div class="row"> <div class="col-md-12"> <form method="post"> <div asp-validation-summary="All" class="text-danger"> </div> <div class="form-group"> <label asp-for="Email"> </label> <input asp-for="Email" class="form-control" /> <span asp-validation-for="Email" class="text-danger"> </span> </div> <div class="form-group"> <label asp-for="Password"> </label> <input asp-for="Password" class="form-control" /> <span asp-validation-for="Password" class="text-danger"> </span> </div> <div class="form-group"> <div class="checkbox"> <label asp-for="RememberMe"> <input asp-for="RememberMe" /> @Html.DisplayNameFor(m => m.RememberMe) </label> </div> </div> <button type="submit" class="btn btn-primary">登录</button> </form> </div> </div>

21.5.3 AccountController中的Login()操作方法

using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using MockSchoolManagement.ViewModels; using System.Threading.Tasks; namespace MockSchoolManagement.Controllers { public class AccountController:Controller { private UserManager<IdentityUser> _userManager; private SignInManager<IdentityUser> _signInManager; public AccountController(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager) { this._userManager = userManager; this._signInManager = signInManager; } [HttpGet] public IActionResult Login() { return View(); } [HttpPost] public async Task<IActionResult> Login(LoginViewModel model) { if(ModelState.IsValid) { var result = await _signInManager.PasswordSignInAsync( model.Email,model.Password,model.RememberMe,false); if(result.Succeeded) { return RedirectToAction("index","home"); } ModelState.AddModelError(string.Empty,"登录失败,请重试"); } return View(model); } } }

21.5.4 会话Cookie与持久性Cookie

维基百科解释:Cookie并不是它的原意“甜饼”的意思,而是一个保存在客户机中的简单的文本文件,这个文件与特定的Web文档关联在一起,保存了该客户机访问这个Web文档时的信息,当客户机再次访问这个Web文档时这些信息可供该文档使用。由于“Cookie”具有可以保存在客户机上的神奇特性,因此它可以帮助我们实现记录用户个人信息的功能,而这一切都不必使用复杂的CGI等程序。

简单来说,我们把Cookie理解为一个大小不超过4kB,便于我们在客户端保存一些用户个人信息的功能。

在ASP.NET Core Identity中,用户成功登录后,将发出Cookie,并将此Cookie随每个请求一起发送到服务器,服务器会解析此Cookie信息来了解用户是否已经通过身份验证和登录。此Cookie可以是会话Cookie或持久Cookie。

会话Cookie是指用户登录成功后,Cookie会被创建并存储在浏览器会话实例中。会话Cookie不包含过期时间,它会在浏览器窗口关闭时被永久删除。

持久Cookie是指用户登录成功后,Cookie会被创建并存储在浏览器中,因为是持久Cookie,所以在关闭浏览器窗口后,它不会被删除。但是,它通常有一个到期时间,会在到期后被删除。

在LoginViewModel.cs视图模型中,我们已经添加了一个bool类型的RememberMe属性。用户可在登录时选择记住我,选中即使用持久性Cookie,而未选中则为会话Cookie。

现在运行项目,我们可以在登录的时候选择记住我,登录成功后如图21.9所示。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(9)

图21.9

打开开发者工具(按F12键),观察图21.9框中的内容,可以发现过期时间是很长的。现在关闭浏览器,并将其再次打开,用户也依然是登录状态。这便是持久性Cookie的作用,只有在到期时间到了之后才会删除。

至于会话Cookie验证,我们在登录的时候取消选择记住我,然后看到如图21.10所示的内容。

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(10)

图21.10

这里已经是一个会话了,它不包含过期时间,在关闭浏览器后,再次将其打开,系统会自动注销用户。

以上就是持久性Cookie与会话Cookie的区别了。

在本章中我们学习了Identity的基本功能,创建一个系统用户并完成了登录注册及状态检查。在后面的章节中,内容会逐步深入,可配合源代码学习。

21.6 小结

本章介绍了ASP.NET Core Identity框架的定位及作用,并利用它提供的API完成了用户的登录与注销等基本功能。在后面的章节中我们会使用更多的API将系统趋于完善。

本文摘自《深入浅出 ASP.NET Core》

asp.net core 6框架揭秘(从零开始学ASP.NETCoreIdentity框架)(11)

这本书原本的计划是描述EF Core中的知识点,带领读者完整地做一个管理系统。但是个人觉得这样写与市场上的其他图书没有什么区别,它就是一本概述知识点的图书,无非多了一个较为完整的功能系统而已。对于我而言这是有落差的。有一天和朋友吃饭,他建议把ABP中那些有效的、目前市场上流行的设计理念整合进图书,不用讲解得太明白,只是告诉读者如何用以及这么用的好处即可。

本书的结构

本书分为以下5个部分。

,