1、项目背景
用户账号及鉴权体系几乎是任何一个web应用都需要的底层架构,目前正在学习Django,作为练手,本文从业务逻辑到技术实现,完整走一遍用户鉴权体系搭建的流程。
2、业务逻辑层
2.1、用户账号及鉴权体系的场景梳理
在用户视角来看,有如下场景:
- 注册
- 登录
- 绑定手机、邮箱
- 账号信息维护
- 找回密码
- 通过手机找回
- 通过邮箱找回
- 权限级别的升降
- 登出
在运营人员来看,有如下场景:
- 根据不同用户级别,设定不同权限
- 功能权限
- 数据权限
2.2、用户账号需要的信息
- username
- 昵称,nickname
- password
- email,可以通过email登录、找回密码。
- phone,可以通过手机登录、找回密码。
- 可以关联微信开放平台的openid,可以通过微信扫码登录。
- verify_code,存储验证码信息
- email_is_verified,邮箱是否被绑定了
- phone_is_verified,手机号是否被绑定了
- avatar,头像,在后期的很多场景中都会用到。
- grade,用户等级,不同的等级会有不同的权限
2.3、注册的时候需要哪些信息
站在用户的角度:
- 希望尽量简单,需要的信息越少越好。
- 希望有一个主键,能够在丢失号码的时候找回。
站在运营人员的角度:
- 希望拿到的信息越多越好。
- 又不希望因为要太多信息而给用户吓跑。
- 必须需要验证码,防止用户刷。
结论:
- 注册的时候,只要username,password,验证码。
- 可以在后台绑定手机号、邮箱。
- 绑定手机和邮箱时,需要验证信息真实性。
3、技术实现层
3.1、基础准备
首先,建议一个User的App,在terminal输入如下指令:
python3 manage.py startapp user
运行完成后,会发现生成了如下文件:
user/
__init__.py
modeks.py
tests.py
view.py
接下来,在settings.py里面,配置installed_app,将user.apps.UserConfig配置进来:
INSTALLED_APPS = [
…
'user.apps.UserConfig',
…
]
第三步,将userApp加入到站点的urls.py中的urlpatterns内:
urlpatterns = [
...
path('user/', include('user.urls')),
...
]
第四步,在user项目目录下新建urls.py,设定如下内容:
app_name = 'user'
urlpatterns = [
# 初始页面
path('', views.index, name='index'),
# 注册
path('registrition', views.registrition , name = 'registrition'),
# 登录
path('login/',auth_views.LoginView.as_view(template_name = 'login.html', redirect_field_name='/') , name='login'),
# 登出
path('logout/',auth_views.LogoutView.as_view(next_page='/'),name = 'logout'),
# 找回密码
path('find_password',views.find_password,name='find_password'),
# 修改密码
path('change_password',views.change_password,name='change_password'),
# 绑定邮箱
path('bind_email_v2',views.bind_email_v2,name='bind_email_v2'),
path('bind_email_v2/<str:e>',views.bind_email_v2,name='bind_email_v2'),
path('clear_email',views.clear_email,name='clear_email'),
# 绑定手机
path('bind_phone',views.bind_phone,name='bind_phone'),
# 个人信息
path('profile',views.profile,name='profile'),
path('change_profile',views.change_profile,name='change_profile'),
]
第五步,修改user项目下的views.py,设定如下内容:
from django.shortcuts import render
from django.shortcuts import HttpResponse,HttpResponseRedirect
from django.core.mail import send_mail
# Create your views here.
def index(request):
context = {}
return render(request, 'index.html', context)
def login(request):
context = {}
return render(request, 'login.html', context)
def registrition(request):
context = {}
return render(request, 'registrition.html', context)
def send_email(request):
send_mail(
)
return HttpResponse('发送成功!')
3.2、建立自定义的用户Model
Django自带的User Model包含如下字段:
序号 | 字段 | 说明 |
---|---|---|
1 | ID | |
2 | password | 密码 |
3 | last_login | 最近一次登录时间 |
4 | is_superuser | 是否是超级管理员 |
5 | username | 用户名 |
6 | first_name | 名 |
7 | last_name | 姓 |
8 | 邮箱地址 | |
9 | is_staff | 是否是员工 |
10 | is_active | 是否是激活状态 |
11 | date_joined | 加入时间 |
但是我们希望能够有手机号、微信openID等更多信息,所以需要扩展Django自带的UserModel,通常来说,有两种办法来实现这种扩展:a. 给原来的用户model表增加一个扩展表,扩展表与原表是1对1的关系;b. 重写一个自定义的UserModel。如果我们开始了一个新项目,哪怕默认的User Model能够满足初始需求,也强烈推荐你使用方案2,一方面是方便扩展,另一方面是进行用户信息操作时,不用跨很多个表去查。我们先简单了解一下方案1,再重点看方案2。
3.2.1、使用一对一模型扩展用户表信息
如果想要在给user存储更多的信息,可以使用一个:OneToOneField,这个一对一的model通常被叫作:profile model,比如我想给用户扩展两个信息:phone和openID,那么需要修改models.py:
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
phone = models.CharField(max_length=16,blank=True)
openid = models.CharField(max_length=50,blank=True)
verify_code = models.CharField(max_length=8,blank=True)
invalid_time =models.DateTimeField(blank=True)
phone_is_verified = models.BooleanField(blank=True,null=True)
email_is_verified = models.BooleanField(blank=True,null=True)
avatar = models.ImageField(verbose_name='avatar', upload_to=user_directory_path, blank=True, null=True)
序号 | 字段 | 类型 | 说明 |
---|---|---|---|
1 | phone | CharField(max_length=16) | 用户的手机号码 |
2 | openID | CharField(max_length=50) | 用户的openid |
如果想要在管理后台增加上面说的自定义字段,可以在你的应用名/admin.py里面,增加一个InlineModelAdmin(内联模型管理后台),然后将它添加到UserAdmin类内。
admin.py:
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from stock_analysis.models import Profile
# Register your models here.
class ProfileInline(admin.StackedInline):
model = Profile
can_delete = False
verbose_name_plural = 'profile'
class UserAdmin(BaseUserAdmin):
inlines = (ProfileInline,)
# Re-register UserAdmin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
3.2.2、用自定义的用户类
Django允许重写默认的user model,如果想在应用里使用对应的用户类,你可以在settings里关联一个自定义的model:
# 账号体系配置
AUTH_USER_MODEL = 'user.myUser'
上面的常量里,myapp是指一个django应用(需要在INSTALLED_APP里面进行配置),后面的MyUser是指你希望在使用的自定义User Model。
最简单的创建一个自定义用户类的方式是从AbstractUser继承。AbstractUser继承自AbstractBaseUser,它提供了user model的核心实现,在使用它的时候,需要声明几个关键实现信息:
- 【必填】USERNAME_FIELD
- 【选填】EMAIL_FIELD
- 【选填】REQUIRED_FIELDS
models.py:
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
phone = models.CharField(max_length=16,blank=True, null=True)
openid = models.CharField(max_length=50,blank=True, null=True)
verify_code = models.CharField(max_length=8,blank=True, null=True)
invalid_time = models.DateTimeField(blank=True, null=True)
phone_is_verified = models.BooleanField(blank=True,null=True)
email_is_verified = models.BooleanField(blank=True,null=True)
avatar = models.ImageField(verbose_name='avatar', upload_to=user_directory_path, blank=True, null=True)
admin.py:
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import MyUser
# Register your models here.
admin.site.register(MyUser,UserAdmin)
都搞定之后,按顺序执行,安装对应的表:
python3 manage.py makemigrations
python3 manage.py migrate
中间有遇到过一个报错:
踩坑记录:
写完上面的自定义用户类后,先运行python3 manage.py makemigration,再运行python3 manage.py migrate,报错,如下所示:
django.db.migrations.exceptions.InconsistentMigrationHistory: Migration admin.0001_initial is applied before its dependency stock_analysis.0001_initial on database 'default'.
处理方式如下:
1. 先makemigrations
2. 打开settings.py,注释掉INSTALLED_APP里面的’django.contrib.admin’,
3. 打开urls.py,注释掉urlpatterns里面的admin,
4. 再migrate就不会报错了。
5. 最后把上面的两个注释恢复回来。
接下来,运行:
python3 manage.py createsuperuser
即可以创建一个应用的超级管理员。
3.3、注册功能
接下来我们完成注册功能,它的url在前面已经配置完成了,接下来配置view层。
在注册页面里面需要有一个表单,Django已经默认创建了一些内建表单,有一些表单能够适用于任何一个AbstractBaseUser的子类:
- AuthenticationForm
- SetPasswordForm
- PasswordChangeForm
- AdminPasswordChangeForm
下面这个表单是要在自定义的用户Model满足一定要求的时候才能被使用:
- PasswordResetForm:假设user model有一个email地址的字段,并且这个字段可以通过get_email_field_name()返回,用来验证这个用户;并且一个is_active字段用来防止给非活跃的用户复活。
还有两个表单是需要重写的:
- UserCreationForm
- UserChangeForm
在这一部分,我们需要重写用户注册表单。先将原表单的信息拆解,了解它的逻辑:

创建forms.py,里面新建MyUserCreationForm,内容如下:
# 创建用户的表单
class MyUserCreationForm(UserCreationForm):
# 错误信息字典
error_messages = {
"password_mismatch": _("两次输入的密码不一致."),
"same_username" : _("用户名已经被注册"),
}
# 定义表单展示名
captcha = CaptchaField(label='验证码')
# 进行用户名的验证
def clean_username(self):
username = self.cleaned_data['username']
same_username = myUser.objects.filter(username = username)
if same_username:
raise ValidationError(
self.error_messages["same_username"],
code="same_username"
)
return username
在view层进行引用:
def registrition(request):
# 展示页
if request.user.is_authenticated:
return HttpResponseRedirect(reverse("stock_analysis:index"))
if request.method == 'GET':
register_form = CustomUserCreationForm()
context = {
'form' : register_form,
'u' : request.user.is_authenticated
}
# 提交后的验证
if request.method == 'POST':
register_form = CustomUserCreationForm(request.POST)
if register_form.is_valid():
register_form.save()
return HttpResponse("注册成功!")
context = {
'form' : register_form,
'email' : register_form.fields
}
return render(request, 'registrition.html', context)
最后,修改template,就可以啦:
<form method="post" action="{% url 'user:registrition' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password1.label_tag }}</td>
<td>{{ form.password1 }}</td>
</tr>
<tr>
<td>{{ form.password2.label_tag }}</td>
<td>{{ form.password2 }}</td>
</tr>
{{ form.captcha.errors }}
<tr>
<td>{{ form.captcha.label_tag }}</td>
<td>{{ form.captcha }}</td>
</tr>
</table>
<input type="submit" value="注册">
<input type="hidden" name="next" value="{{ next }}">
</form>
3.4、图片验证码逻辑
为了防止有人刷,那么增加验证码的验证功能:
使用django-simple-captcha组件
Step1.
在settings.py里面,将captcha放到installed-app里面。
Step2.
captcha需要在数据库内建自己的表,所以:python3 manage.py migrate
Step3.
在根目录的urls.py里面增加配置:path('captcha/',include('captcha.urls'))
Step4.
在自定义的表单内增加:
# 定义表单展示名
captcha = CaptchaField(label='验证码')
Step5、
在前端页面加入验证码的显示:
{{ form.captcha.errors }}
<tr>
<td>{{ form.captcha.label_tag }}</td>
<td>{{ form.captcha }}</td>
</tr>
Step6、
实现验证码的点击自动刷新,在前端页面增加:
<script>
$('.captcha').click(function(){
$.getJSON('/captcha/refresh/',function(result){
$('.captcha').attr('src',result['image_url']);
$('#id_captcha_0').val(result['key']);
})
})
</script>
3.5、管理后台显示自定义Model的字段
等到以上都配置完后,登录后台,发现显示的字段不全,所以需要自己写一下UserAdmin:
from django.contrib import admin
from .models import User
class UserAdmin(admin.ModelAdmin):
fieldsets = [
('基本信息', {'fields':['username','first_name','last_name']}),
('联系信息',{'fields':['email','phone','openid']}),
('辅助信息',{'fields':['last_login','is_superuser','is_staff','is_active','date_joined','verify_code','invalid_time']})
]
list_display = ('username', 'email', 'phone','last_login','date_joined')
# Register your models here.
admin.site.register(User,UserAdmin)
3.6、登录和登出
目前可以直接用自带的视图:
# 登录
path('login/',auth_views.LoginView.as_view(template_name = 'login.html', redirect_field_name='/') , name='login'),
# 登出
path('logout/',auth_views.LogoutView.as_view(next_page='/'),name = 'logout'),
3.7、绑定邮箱
绑定邮箱有两种方式:
- 给用户邮箱发送一串激活码,之后用户回到网页回填发送的激活码。
- 给用户邮箱发送一个URL地址,用户点击此地址后,自动激活。
先采用第一种方式,此流程分为两步:
首先,要求用户填写一个邮箱,填写完成后,将邮箱保存到数据库内,并向此邮箱发送一串激活码;在此步骤中,为防止用户刷,需要增加一个数字验证码的校验功能。
之后,页面内要展示需要用户填写激活码的表单,此时,用户可以选择:更换邮箱、再次发送验证码,或者是输入激活码激活。
3.7.1、发送邮件能力
发送邮件使用django自带的send_email()方法,需要在settings.py里面进行配置:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = True # 是否使用TLS安全传输协议(用于在两个通信应用程序之间提供保密性和数据完整性。)
EMAIL_USE_SSL = False # 是否使用SSL加密,qq企业邮箱要求使用
EMAIL_HOST = 'smtp.163.com' # 发送邮件的邮箱 的 SMTP服务器,这里用了163邮箱
EMAIL_PORT = 25 # 发件箱的SMTP服务器端口# 上面配置可以不动,下面配置修改为自己的
# 上面的内容不用动,下面的内容设置为自己的
EMAIL_HOST_USER = 'tin@163.com' # 发送邮件的邮箱地址
EMAIL_HOST_PASSWORD = 'xxxxxx' # 发送邮件的邮箱密码(这里使用的是授权码)
EMAIL_TO_USER_LIST = ['xxxx@foxmail.com', 'xxx@qq.com'] # 此字段是可选的,用来配置收件人列表
3.7.2、设计流程中两个步骤用到的表单
新建forms.py,填写如下代码:
# 验证邮箱的表单
class BindEmailFormStep1(forms.Form):
email = forms.EmailField(label="邮箱")
captcha = CaptchaField(label='验证码')
status = forms.widgets.HiddenInput()
class BindEmailFormStep2(forms.Form):
email = forms.EmailField(label="邮箱" ,disabled="disabled", required=False)
bind_msg = forms.CharField(label="激活码",required=False)
status = forms.widgets.HiddenInput()
3.7.4、View层设计
在view层,根据request.method是get还是post的不同,分别对页面进行处理,get部分处理未填写表单时的展示,其中设置了一个status变量,用来判断用户是进行到了第一步还是第二步:
def bind_email_v2(request,e=''):
u = myUser.objects.get(id=request.user.id)
# 对表现层进行处理
if request.method == 'GET':
# 1、此用户没有email记录
if u.email == "":
status = 0
form = BindEmailFormStep1()
# 2、此用户已有email记录,且已经经过了验证
elif u.email_is_verified:
status = 1
return HttpResponseRedirect(reverse("user:profile"))
# 3、此用户已有email记录,没有经过验证
else:
status = 2
form = BindEmailFormStep2(initial={'email':u.email})
# 将status值赋值给表单内的隐藏变量
form.hidden_status = form.status.render(value=status,name="status")
context = {'form': form, 'status':status, 'e':e}
return render(request, 'bind_email_v2.html', context)
post部分处理回收过来的结果:
# 对提交请求进行处理
if request.method == 'POST' :
p = request.POST
# 对验证码进行验证
if p['status'] == '0':
form = BindEmailFormStep1(request.POST)
# 如果通过了验证,进行后续流程
if form.is_valid():
try:
# 获取系统内是否已经有此邮箱的记录了
ue = myUser.objects.get(email=form.cleaned_data['email'])
# 如果系统内没有此邮箱记录,那么生成token,发送给用户的邮箱,并将这个邮箱存到数据库内。
except ObjectDoesNotExist:
# 根据request里面的id查询用户信息
u = myUser.objects.get(id=request.user.id)
# 发送验证码并将邮箱保存给这个用户
return send_email_base(u,form)
# 如果出现其他错误了,那么报错
except:
return HttpResponse("出错")
# 用户的邮箱为空,但是数据库里有这个邮箱的记录
else:
# 这个邮箱已经被认证了
if ue.email_is_verified :
return HttpResponseRedirect(reverse("user:bind_email_v2", kwargs={'e':'此邮箱已被其他用户占用!'}))
# 这个邮箱没有被认证
else :
ue.email = ""
ue.save()
return send_email_base(u,form)
# 如果没通过验证,报错
else:
t = ""
for e in form.errors:
t = t + e + ':' + form.errors[e][0]
t = t+';'
return HttpResponseRedirect(reverse("user:bind_email_v2", kwargs={'e':t}))
# 对激活码进行验证
else:
form = BindEmailFormStep2(request.POST)
# 如果通过了验证,进行后续流程
if form.is_valid():
if u.verify_code == form.cleaned_data['bind_msg']:
u.email_is_verified = True
u.save()
return HttpResponseRedirect(reverse("user:profile"))
else:
return HttpResponseRedirect(reverse("user:bind_email_v2", kwargs={'e':'激活码不对,请重新填写'}))
else :
t = ""
for e in form.errors:
t = t + e + ':' + form.errors[e][0]
t = t+';'
return HttpResponseRedirect(reverse("user:bind_email_v2", kwargs={'e':t}))
3.7.5、Template层
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>绑定邮箱</title>
</head>
<body>
{{user.id}}
:
{{user}}
{% if status == 0 %}
<!-- 此用户没有email记录 -->
<form method="post" action="{% url 'user:bind_email_v2' %}" id="form1">
{% csrf_token %}
<table>
<tr>
<td>{{ form.email.label_tag }}</td>
<td>{{ form.email }}</td>
</tr>
<tr id="cccc">
<td>{{ form.captcha.label_tag }}</td>
<td>{{ form.captcha }}</td>
</tr>
</table>
{{ form.hidden_status }}
<input type="submit" id="send" value="发送激活码">
{% if form.errors %}
<p>{{form.errors}}</p>
{% endif %}
<p id="msg1">{{e}}</p>
</form>
{% else %}
<!-- 此用户有email记录 -->
<form method="post" action="{% url 'user:bind_email_v2' %}" id="form2">
{% csrf_token %}
<table>
<tr>
<td>{{ form.email.label_tag }}</td>
<td>{{ form.email }}</td>
</tr>
<tr id="bind_code" class="h">
<td>{{ form.bind_msg.label_tag }}</td>
<td>{{ form.bind_msg }}</td>
</tr>
</table>
<td>{{ form.hidden_status }}</td>
<input type="submit" value="激活" id="bind_submit" class="h">
<input type="button" value="修改邮箱" id="return" class="h">
<input type="button" value="再次发送" id="refresh" class="h">
<p id="msg2">{{e}}</p>
<p>{{form.errors}}</p>
{% if form.errors %}
<p>{{form.errors}}</p>
{% endif %}
</form>
<form method="post" action="{% url 'user:clear_email' %}" id="form3">
{% csrf_token %}
</form>
{% endif %}
<script src="https://cdn.staticfile.org/jquery/3.2.1/jquery.min.js"></script>
<script>
$('#return').click(function(){
$("#msg2").text("")
$.post({
url: "{% url 'user:clear_email' %}",
data:($("#form3").serialize()),
success:function(data){
if(data === 'done'){
location.href = "{% url 'user:bind_email_v2' %}"
}
else{
alert(data)
}
}
})
})
</script>
</body>
</html>
3.8、个人资料页&修改资料
3.8.1、URL层
完成注册且登录后,需要有一个个人资料页,在里面会展示用户的基本信息及头像;还会有个修改资料页,可以修改头像、姓名
新建url规则:
# 个人信息
path('profile',views.profile,name='profile'),
path('change_profile',views.change_profile,name='change_profile'),
3.8.2、定义表单,修改Model层
在forms.py内,新建修改资料的表单:
# 修改个人资料
class ChangeProfileForm(forms.ModelForm):
# username = forms.CharField(label="用户名",required=True,disabled=True)
class Meta:
model = myUser
fields = ['avatar','first_name','last_name']
这个表单关联的是myUser的model内的avatar、first_name、last_name字段,其中,avatar是一个ImageField字段,在model内是这么定义的:
avatar = models.ImageField(verbose_name='avatar', upload_to=user_directory_path, blank=True, null=True)
上述定义内,user_directory_path是一个方法,用来对文件进行命名
def user_directory_path(instance, filename):
ext = filename.split('.').pop()
filename = '{0}/{1}-{2}-{3}.{4}'.format(datetime.datetime.now().strftime('%Y%m%d'),datetime.datetime.now().strftime('%I%M%S'),instance.username, instance.id,ext)
return os.path.join( filename) # 系统路径分隔符差异,增强代码重用性
3.8.3、View层
view层内,定义资料页及修改资料页:
# 个人信息页
def profile(request):
context = {}
return render(request, 'profile.html', context)
def change_profile(request):
u = myUser.objects.get(id=request.user.id)
my_profile = ChangeProfileForm(initial={
'avatar': u.avatar,
'first_name': u.first_name,
'last_name': u.last_name
})
context = {'profile': my_profile}
if request.method == 'POST':
my_profile = ChangeProfileForm(request.POST,request.FILES,instance=u)
if my_profile.is_valid():
u.save()
return HttpResponseRedirect(reverse("user:profile"))
return render(request, 'change_profile.html', context)
3.8.4、Template层
资料页:
{% if user.avatar %}
<img width="150" height="150" src="/media/{{user.avatar}}" alt="">
{% else %}
<img width="150" height="150" src="{% static 'default/avatar.png' %}" alt="">
{% endif %}
<table>
<tr>
<td>用户ID:</td>
<td>{{user.id}}</td>
</tr>
<tr>
<td>用户名:</td>
<td>{{user}}</td>
</tr>
<tr>
<td>姓:</td>
<td>{{user.last_name}}</td>
</tr>
<tr>
<td>名:</td>
<td>{{user.first_name}}</td>
</tr>
<tr>
<td>邮箱:</td>
{% if user.email == "" %}
<td></td>
<td><a href="{% url 'user:bind_email_v2' %}">绑定邮箱</a></td>
{% else %}
<td>
{{user.email}}
</td>
<td>
{% if user.email_is_verified %}
<span>已通过验证</span>
{% else %}
<a href="{% url 'user:bind_email_v2' %}">现在激活</a>
{%endif%}
</td>
{% endif %}
</tr>
<tr>
<td>手机号码:</td>
{% if not user.phone %}
<td></td>
<td><a href="{% url 'user:bind_phone' %}">绑定手机</a></td>
{% else %}
<td>
{{user.phone}}
</td>
<td>
{% if user.phone_is_verified %}
<span>已通过验证</span>
{% else %}
<a href="{% url 'user:bind_phone' %}">现在激活</a>
{%endif%}
</td>
{% endif %}
</tr>
<tr>
<td>加入时间:</td>
<td>{{user.date_joined}}</td>
</tr>
<tr>
<td>最近登录:</td>
<td>{{user.last_login}}</td>
</tr>
</table>
<p><a href="{% url 'user:change_profile' %}">修改资料</a></p>
<p><a href="{% url 'stock_analysis:index' %}">站点主页</a></p>
<p><a href="{% url 'user:logout' %}">登出</a></p>
修改资料页:
<form method="post" enctype="multipart/form-data" action="{% url 'user:change_profile' %}" id="form1">
{% csrf_token %}
<table>
{{profile}}
</table>
{% if form.errors %}
<p>{{form.errors}}</p>
{% endif %}
<input type="submit" value="保存资料">
</form>
4、尾声
通过以上流程,我们完整地搭建了一个基于django的用户账号体系,包含注册、修改资料、上传头像、邮箱验证、手机号验证等,里面非常多的知识点,是一个很好的练手项目。