2022-06-23
devops
00
请注意,本文编写于 836 天前,最后修改于 836 天前,其中某些信息可能已经过时。

目录

Session和Cookie
历史
什么是无状态和有状态
cookie和session交互过程(原理)
cookie中的数据格式
cookie的缺点
替代cookie
session的问题
扩展session
会话保持
使用session
django使用session
加入session首次请求
加入session第2次请求
测试session
请求取cookie, 响应时加cookie
sessionid关联用户进行认证 (SessionMiddleware和AuthenticationMiddleware中间件)
登出和判断是否需要登陆的装饰器

Session和Cookie

历史

发了一个论文在互联网文章上,发明html, http。

http特点:

  • 1.1前短连接,tcp连接 创建和销毁成本高。 原因是当时服务器性能差,占着连接不放,并发承受不了。
  • 1.1之后,支持keep-alive,连接打开后,会保持一段时间, 浏览器在时间内再请求就复用这个TCP连接,减少了TCP3次断开和3次握手的开销。

http是无状态的,短连接的(长连接也是有时效的).

什么是无状态和有状态

无状态,服务端无法知道这一次连接是谁? 解决: cookie+session; token

有状态,表示服务端和客户端有数据交互。服务端会为客户端存放数据。这一次请求和上一次请求有内在关系

没有价值的数据,可以不需要识别用户。有价值的数据,就需要识别用户。sessionid

cookie和session交互过程(原理)

http前一次请求和后一次请求,有没有关系, 服务器不知道。

  1. 早期不需要知道,他们之间的关系。
  2. 95年左右,服务器想知道你是谁, 或者希望客户端带一些东西过来。发明了cookie, 浏览器可以存储一个数据,这个数据跟你访问的域名有关,是kv对 叫“cookie值”,你对这个域名发起了http请求,浏览器就会自动把这个数据kv对,自动放在请求头,发给服务器端。
    • 生成cookie, 客户端 可以生成。
    • 服务端第一次请求,就生成一些header, 浏览器会自动将此header放在域名相关数据中,下一回访问服务器时, 你会带上这些cookie值
  3. 动态网页技术就需要解决状态问题,需要知道用户是谁
  4. 动态网页发展纯cookie并不能解决服务端状态问题,光有cookie服务端并不知道你是谁,还需要session。

image-20220517094153035

  1. 通过session, 这一次连接过来客户端发来cookie, 服务端在Hash表中基于sessionid查出用户。这样就解决了状态问题
  1. 往往用户名,密码登录成功(authentication ok)后才会给你发token,hash值,sessionid。
    • 登陆后,将sessionid关联的用户的数据记录到表。这些就是用户的行为
  2. session是在服务端保存的值,实现状态管理,同时返回一个token值(Sessionidsid,hash值)cookie。
  3. hash表保存
    • redis 内存中:查询快 高效,内存占用大。 数据是暂时的。
    • mysql 磁盘中:查询慢。

cookie避免重复利用:客户端: cookie过期快 1s后过期,sessionstorage存储。服务端:过期前可以对同一个sessionid,set-cookie重置过期时间。

sessionid多久过期?过期之后浏览器会删除sessionid(cookie值);

电商网站,必须定期过期。用户想方便,可以不定期过期。只要用户一直在电脑前可以一直续期。

非电商网站,可以免登陆。

  1. 用户点了登出按钮, 请求到服务器端。服务端将sessionid关联的session()删除,响应给用户时,响应首部添加set-cookie: sessionid=''
    • 真实场景是我们关浏览器就清理了cookie。并没有发logout请求。服务端hash表的key也有过期值, 过期也会清理session。 用户重新登陆后,服务器给用户一个全新的sessionid,而不是上次未过期的sessionid。

image-20220519093210400

cookie中的数据格式

cookie是浏览器中,放在内存中的数据,这个数据与域相关,每次请求这个域就会自动带上这些数据,发到服务器。

描述
sessionid首次请求为用户生成Session()对象中的一个属性
expiredate内存中,重启浏览器,cookie就没有了;不重启时,过期时间到达浏览器会自动清理cookie
domain默认当前域 blog.mykernel.cn;如果指定mykernel.cn表示blog.mykernel.cn 或 www.mykernel.cn ; 只要对满足的域请求,会自动带上cookie
path/表示匹配的域,所有路径请求时,会自动带cookie;/app表示/app/index.html会带cookie;而/app1路径的请求就不会自动携带cookie;
httponlycookie只能自动发到server或server发来数据存放到浏览器,javascript不能读cookie;
secure发送cookie必须https

cookie的缺点

  • 浏览器中的cookie是明文存放,安全性极差, 不要将密码放在cookie中
  • cookie大小限制4KB
  • http1中每个请求都发cookie,增加了流量;http2开始,cookie不需要每次发;

替代cookie

  • localstorage
    • 相同点:属于域的数据。k/v对
    • 不同点:请求时不会自动携带。自己写代码存储或发。浏览器重启还在。键不会有过期机制(得自己删除); 存储大小更大;
  • sessionstorage
    • 相同点:属于域的数据。k/v对。浏览器替你存储。内存存放。
    • 不同点:
    • 应用:敏感信息临时存储。
  • indexdb
    • 支持db查询

session的问题

  • session定期过期清除
  • session占用内存
  • session不持久化,服务程序崩溃,session丢失
  • session持久化数据库,服务程序崩溃,从数据库恢复

扩展session

image-20220519101540292

会话保持

  • 负载均衡基于用户粘在某个后端主机上。不合适服务器弹性扩展

    • ip_hash, 源地址hash,粒度太大
    • session hash,一致性hash。
    • 目标地址hash, 目标服务器挂了,就会出现问题
  • tomcat session复制。

    • deltasession, 增量session类,session增删时,会通知其他服务器进行增删。

    • 优点:每台session是冗余的

    • 缺点:网络开销,每台服务器全量session,占用大量内存 如果来100万个用户在使用,内存耗不够。小场景可以使用

    为什么要100台tomcat同步,为什么要这么大的网络?可以把服务子模块化,不同业务不同tomcat服务器。

  • session server。memcached redis

    • 旧部署memcached,完全够了。

    • 新部署redis,数据类型丰富,但是存储session,redis用不了这么多数据类型。

    带来的sessionid先在内存访问,没有的话,再从memcached/redis访问。以主机内存为主,以外部存储为辅助。

    万一memcached挂了,所以2个memcached/redis.

    https://blog.51cto.com/scholar/1670343

使用以上架构:日活多少?峰值多少? 不需要考虑这些架构。

使用session

django使用session

django支持sesssion

  • 检测sessionid,带了sessionid检测是否有效? 用户是否有效,由 django.contrib.auth.middleware.AuthenticationMiddleware 决定

    MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware',

    2次shift字符串

    python
    def process_request(self, request): # session是否有效 session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) # 获取sessionid request.session = self.SessionStore(session_key) # 与django_session表交互, 并在request上添加属性session
    python
    def process_response(self, request, response): # session过期,续期,删除session. try: accessed = request.session.accessed modified = request.session.modified empty = request.session.is_empty() except AttributeError: return response # First check if we need to delete this cookie. # The session should be deleted only if the session is entirely empty. if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: response.delete_cookie( settings.SESSION_COOKIE_NAME, path=settings.SESSION_COOKIE_PATH, domain=settings.SESSION_COOKIE_DOMAIN, samesite=settings.SESSION_COOKIE_SAMESITE, ) patch_vary_headers(response, ('Cookie',))
  • app,用来颁发sessionid,管理session是基于backends 数据库存储session?还是内存?

    INSTALLED_APPS = [ 'django.contrib.sessions',
    • 有索引相对快,但是比内存慢。
    • 基于数据库后,存储时间过长,被盗用安全问题。
  • session不使用可以关中间件和app

  • 数据库表使用django_session表,记录session。可以使用mysql或redis存储session。

  • mysql表django_session 记录了session,优点是服务崩溃session在mysql不丢失。

    sql
    mysql> SHOW TABLES; +----------------------------+ | Tables_in_t39 | +----------------------------+ | auth_group | | auth_group_permissions | | auth_permission | | auth_user | | auth_user_groups | | auth_user_user_permissions | | django_admin_log | | django_content_type | | django_migrations | | django_session | +----------------------------+ 10 rows in set (0.00 sec) mysql> SELECT * FROM django_session; Empty set (0.00 sec) mysql> DESC django_session; +--------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +--------------+-------------+------+-----+---------+-------+ | session_key | varchar(40) | NO | PRI | NULL | | # sessionid | session_data | longtext | NO | | NULL | | # sessionid关联的字典,内存中的数据序列化之后存储的。 | expire_date | datetime(6) | NO | MUL | NULL | | # sessionid过期时间 +--------------+-------------+------+-----+---------+-------+ 3 rows in set (0.00 sec)

示例跑通wsgi

url

urlpatterns = [ # 暴露handler函数,直接是函数 # path('', test), # 列表页, /emps/ test = require_GET(test) => inner # path('<int:id>', test_detail), # 详情页, restful # 暴露handler函数,装饰出来的handler函数 path('', require_GET(test)), # /emps/ 到 test 函数 require_GET(test) = inner == inner(request, *args, **kwargs) path('ts3/',TestView.as_view()), ]

view

def test(request: HttpRequest, **kwargs): print('~'*30) print(request) # <WSGIRequest: GET '/'> print(kwargs) print(request.session, '-'*30)# session print('~'*30) return JsonResponse([ (1, 'python', 3), (2, 'c++', 3) ], safe=False) # json的数据类型, 字符串; json.dumps() -> str python 列表 => js 数组; python 字典 => js 对象

响应

python
[19/May/2022 11:10:07] "GET /emps/ HTTP/1.1" 200 33 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <WSGIRequest: GET '/emps/'> {} <django.contrib.sessions.backends.db.SessionStore object at 0x000001695D2F3910> ------------------------------ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

加入session首次请求

view

python
def test(request: HttpRequest, **kwargs): print('~' * 30) print(request) # <WSGIRequest: GET '/'> print(kwargs) print(request.session.get('userdata'), '-' * 30) # session request.session['userdata'] = random.randint(100, 200) # 对当前这个请求, 分配session, 响应sessionid;将session数据保存到mysql。 print(request.session) # 保存了一个sessionid print('~' * 30) return JsonResponse([ (1, 'python', 3), (2, 'c++', 3) ], safe=False) # json的数据类型, 字符串; json.dumps() -> str python 列表 => js 数组; python 字典 => js 对象

现在请求 http://127.0.0.1:8000/emps/

image-20220519130138494

注意sessionid

sessionid=4u6mxvt03z12vwx4aqmp2u7i142xgbqf; expires=Thu, 02 Jun 2022 05:01:14 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax

同时header中,会自动携带这个cookie

image-20220519130302937

这一次python请求

python
Quit the server with CTRL-BREAK. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <WSGIRequest: GET '/emps/'> {} None ------------------------------ <django.contrib.sessions.backends.db.SessionStore object at 0x000001FCE6CA76D0> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ (0.000) SELECT VERSION(), @@sql_mode, @@default_storage_engine, @@sql_auto_is_null, @@lower_case_table_names, CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL ; args=None (0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None (0.000) SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = '4u6mxvt03z12vwx4aqmp2u7i142xgbqf' LIMIT 1; args=('4u6mxvt03z12vwx4aqmp2u7i142xgbqf',) (0.000) INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('4u6mxvt03z12vwx4aqmp2u7i142xgbqf', 'eyJ1c2VyZGF0YSI6MTAyfQ:1nrYHi:uzUbfMebFTi1j6PrjoXdcIqEE5ZwPpOeiJ_wN9KkGg8', '2022-06-02 05:01:14.428641'); args=('4u6mxvt03z12vwx4aqmp2u7i142xgbqf', 'eyJ1c2VyZGF0YSI6MTAyfQ:1nrYHi:uzUbfMebFTi1j6PrjoXdcIqEE5ZwPpOeiJ_wN9KkGg8', '2022-06-02 05:01:14.428641') [19/May/2022 13:01:14] "GET /emps/ HTTP/1.1" 200 33

查看mysql

sql
mysql> SELECT * FROM django_session\G; *************************** 1. row *************************** session_key: 4u6mxvt03z12vwx4aqmp2u7i142xgbqf session_data: eyJ1c2VyZGF0YSI6MTAyfQ:1nrYHi:uzUbfMebFTi1j6PrjoXdcIqEE5ZwPpOeiJ_wN9KkGg8 expire_date: 2022-06-02 05:01:14.428641 1 row in set (0.00 sec) ERROR: No query specified

注意:session_data就是我们上面保存的数据,进行序列化的结果。他有过期时间。

加入session第2次请求

view 现在不用更新此值了,查看现在的值。

diff
def test(request: HttpRequest, **kwargs): print('~' * 30) print(request) # <WSGIRequest: GET '/'> print(kwargs) print(request.session.get('userdata'), '-' * 30) # session + # request.session['userdata'] = random.randint(100, 200) # 对当前这个请求, 分配session, 响应sessionid;将session数据保存到mysql。 print(request.session) # 保存了一个sessionid + # request.session对象 是 django.contrib.sessions.backends.db.SessionStore 类的实例,此类的父类有 __getitem__, __setitem__, __delitem__ 方法,说明是字典。 + print(*request.session.items(),sep='\n') # ('userdata', 102) + print(request.COOKIES) # {'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} print('~' * 30) return JsonResponse([ (1, 'python', 3), (2, 'c++', 3) ], safe=False) # json的数据类型, 字符串; json.dumps() -> str python 列表 => js 数组; python 字典 => js 对象

image-20220519130757806

相同cookie请求时,会基于sessionid查询mysql,且未过期。有记录拿到sessionid相关值。

python
Quit the server with CTRL-BREAK. (0.000) SELECT VERSION(), @@sql_mode, @@default_storage_engine, @@sql_auto_is_null, @@lower_case_table_names, CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL ; args=None (0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None (0.000) SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-05-19 05:07:41.062909' AND `django_session`.`session_key` = '4u6mxvt03z12vwx4aqmp2u7i142xgbqf') LIMIT 21; args=('2022-05-19 05:07:41.062909', '4u6mxvt03z12vwx4aqmp2u7i142xgbqf') [19/May/2022 13:07:41] "GET /emps/ HTTP/1.1" 200 33 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <WSGIRequest: GET '/emps/'> {} 102 ------------------------------ <django.contrib.sessions.backends.db.SessionStore object at 0x000001F893871490> ('userdata', 102) {'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

测试session

python
import os, django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'salary.settings') django.setup(set_prefix=False) ######################### 测试 ######################### from django.contrib.sessions.models import Session # 对应django_session表 smgr = Session.objects.all() s: Session = smgr.get(pk='4u6mxvt03z12vwx4aqmp2u7i142xgbqf') print(s) print(s.session_data) # 中间字段的含义 print(s.get_decoded()) # {'userdata': 102} 这个数据可以非常大;如果在内存中,数据多,对内存占用就大;

请求取cookie, 响应时加cookie

diff
def test(request: HttpRequest, **kwargs): print('~' * 30) print(request) # <WSGIRequest: GET '/'> print(kwargs) print(request.session.get('userdata'), '-' * 30) # session # request.session['userdata'] = random.randint(100, 200) # 对当前这个请求, 分配session, 响应sessionid;将session数据保存到mysql。 print(request.session) # 保存了一个sessionid # request.session对象 是 django.contrib.sessions.backends.db.SessionStore 类的实例,此类的父类有 __getitem__, __setitem__, __delitem__ 方法,说明是字典。 print(*request.session.items(),sep='\n') # ('userdata', 102) + print(request.COOKIES) # {'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} print('~' * 30) res = JsonResponse([ (1, 'python', 3), (2, 'c++', request.method) ], safe=False) # json的数据类型, 字符串; json.dumps() -> str python 列表 => js 数组; python 字典 => js 对象 + res.cookies['mycookie'] = 'testcookie' return res

浏览器会自动将加的cookie追加到已有的cookie;

image-20220519132435520

响应体为

[ [ 1, "python", 3 ], [ 2, "c++", "GET" ] ]

pycharm打印

python
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <WSGIRequest: GET '/emps/'> # request {} # kwargs 102 ------------------------------ # 获取之前用户的数据 <django.contrib.sessions.backends.db.SessionStore object at 0x0000027D016EC610> # session对象 ('userdata', 102) # session 字典中的数据 {'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} # 请求的cookie ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

当postman第二次请求时

python
[19/May/2022 13:24:16] "GET /emps/ HTTP/1.1" 200 37 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <WSGIRequest: GET '/emps/'> {} (0.000) SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; args=None (0.000) SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-05-19 05:26:25.140215' AND `django_session`.`session_key` = '4u6mxvt03z12vwx4aqmp2u7i142xgbqf') LIMIT 21; args=('2022-05-19 05:26:25.140215', '4u6mxvt03z12vwx4aqmp2u7i142xgbqf') [19/May/2022 13:26:25] "GET /emps/ HTTP/1.1" 200 37 102 ------------------------------ <django.contrib.sessions.backends.db.SessionStore object at 0x0000027D015C6EE0> ('userdata', 102) # 自己添加的session在mysql/redis/内存中。 {'mycookie': 'testcookie', 'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} # 由于我们添加了cookie, 所以请求的cookie就一个数据。cookie在浏览器中保存,服务端不会保存。 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

sessionid关联用户进行认证 (SessionMiddleware和AuthenticationMiddleware中间件)

'django.contrib.sessions.middleware.SessionMiddleware', session中间件只解决验证session: sessionid获取,发sessionid,关联sessionid与session,SessionMiddleware并不管认证,认证由 'django.contrib.auth.middleware.AuthenticationMiddleware', 此中间件完成。

image-20220519153146235

  1. 指定用户登陆后,给用户发一个sessionid, 这个sessionid关联的session_data中存放了Userid
  2. 只要sessionid不过期,下一次请求SessionMiddleware对sessionid关联Session,对应一个字典,内存/mysql/redis。
  3. AuthenticationMiddleware将session_data中的userid关联到user实例。用户存在且激活状态 ,就有user is_authenticated=True,否则就是匿名用户实例 is_authenticated=False。

view

diff
def test(request: HttpRequest, **kwargs): print('~' * 30) print(request) # <WSGIRequest: GET '/'> print(kwargs) print(request.session.get('userdata'), '-' * 30) # session # request.session['userdata'] = random.randint(100, 200) # 对当前这个请求, 分配session, 响应sessionid;将session数据保存到mysql。 print(request.session) # 保存了一个sessionid # request.session对象 是 django.contrib.sessions.backends.db.SessionStore 类的实例,此类的父类有 __getitem__, __setitem__, __delitem__ 方法,说明是字典。 print(*request.session.items(),sep='\n') # ('userdata', 102) print(request.COOKIES) # {'sessionid': '4u6mxvt03z12vwx4aqmp2u7i142xgbqf'} + print(request.user) # AnonymousUser print('~' * 30) res = JsonResponse([ (1, 'python', 3), (2, 'c++', request.method) ], safe=False) # json的数据类型, 字符串; json.dumps() -> str python 列表 => js 数组; python 字典 => js 对象 res.cookies['mycookie'] = 'testcookie' return res

Django内建函数

  1. authenticated(用户,密码) 认证用户和密码是否正常,成功返回用户实例,失败返回None

    python
    def login_view(request): # request.user = 匿名用户 username = request.POST['username'] password = request.POST['password'] user = authenticated(username,password) if user is not None: login(request,user) # request.user = 覆盖; 生成sessionid和小字典;将小字典保存到数据库/redis/内存; # 返回response报文时,headers中有set-cookie: sessionid=xxxxxxx; 浏览器下次请求,经过session request.session; 经过 auth request.user;
  2. login(request,user) request.user = user 并且响应cookie, cookie中保存sessionid, sessionid关联的字典为 sessionid: {userid: user.id}

登出和判断是否需要登陆的装饰器

  1. logout 将sessionid关联的字典清空 request.session.flush(),响应set-cookie: sessionid: ''

    python
    def logout_view(request): logout(request) # request.user = None; sessionid 关联的小字典清空,即数据库表中记录删除。
  2. 如果用户不登出,大量session在mysql中,不会定期删除。django提供了命令, cleanersessions,我们只需要在晚上cron定时删除即可。

登出时,需要登陆的用户才可以登出

path('/logout', logout_view)

现在已经到view函数,现在已经通过session, auth中间件,就算没有sessionid或user为匿名用户。这2个中间件并不会拒绝访问。

python
#@检查 request.user 。登陆过 user不是匿名用户。request.session 有sessionid 并不代表user激活。 from django.contrib.auth.decorators import login_required @login_required def logout_view(request): logout(request)

未登陆,默认跳转 settings.LOGIN_URL

url中引用 handler函数,需要认证

diff
urlpatterns = [ # 暴露handler函数,直接是函数 # path('', test), # 列表页, /emps/ test = require_GET(test) => inner # path('<int:id>', test_detail), # 详情页, restful # 暴露handler函数,装饰出来的handler函数 path('', require_GET(test)), # /emps/ 到 test 函数 require_GET(test) = inner == inner(request, *args, **kwargs) + path('ts3/',login_required(TestView.as_view())), + path('ts4/',login_required(login_url='/login_in')(TestView.as_view())), ]
  1. ts3 默认跳转配置的settings.LOGIN_URL
  2. ts4跳转到/login_in

类上需要认证,同 4.2.3.1 4.2.3.2 4.2.3.3 4.2.3.4 节相同方法使用。这里传递的装饰器返回必须符合 view(request,*args,**kwargs) handler函数格式

diff
from django.utils.decorators import method_decorator from django.views.decorators.http import require_GET, require_http_methods +from django.contrib.auth.decorators import login_required +@method_decorator(login_required, name='dispatch') # require_http_methods(['GET','PUT'] 或 require_GET class TestView(View): def get(self, request: HttpRequest, **kwargs): # TestView 实例相关; 视图函数必须有request print(request.content_type) print(kwargs) return HttpResponse('这是视图类TestView的get') def post(self, request: HttpRequest, **kwargs): # TestView 实例相关; 视图函数必须有request print(request.content_type) print(kwargs) return HttpResponse('这是视图类TestView的post')

使用mixin类完成需要先认证

python
from django.contrib.auth.mixins import LoginRequiredMixin class MyView(LoginRequiredMixin, View): login_url = '/login/' redirect_field_name = 'redirect_to'

本文作者:mykernel

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!