「LeanCloud Web 应用开发实践」系列直播及文章分享持续进行中。
每周二周四晚上 8 点开始,时长预计 45 分钟。在 “leanCloud通讯” 微信公众号回复 “公开课” 即可获取直播链接。
《LeanCloud Web 应用开发实践公开课》上期回顾和本期主题介绍。
抛出疑问 00:01:10
- 在云引擎登录了,但是云函数却没有 currentUser
- 在浏览器调用 JS SDK 登录用户,页面跳转时云引擎中没有 currentUser
- 云引擎 SDK 中有些地方会有 fetchUser 属性,有什么用?
为了理清 currentUser 的状态,需要看下不同类型的 WEB 应用是如何运作的。
早期 WEB 应用——服务端渲染 00:02:40
使用云引擎 demo 来演示,可以使用 https://todo-demo.leanapp.cn 来做接下来的尝试,或者自己部署该 demo 应用尝试(代码 版本: 1efc44a )。
这个 demo 是一个典型的服务端渲染的应用。所谓的服务端渲染是指浏览器请求服务端的地址或资源时,服务端返回一个 HTML 文档(一个很大的字符串),浏览器收到 HTML 文档之后,进行渲染并呈现页面。通过云引擎的自定义路由很容易实现这样的 WEB 应用。
如果单纯看请求和响应,以登录页面为例:
$ curl -v https://todo-demo.leanapp.cn/users/login
> GET /users/login HTTP/1.1
> Host: todo-demo.leanapp.cn
>
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=utf-8
<
<!DOCTYPE html><html><head><title>用户登录</title>...<input type="submit"
value="登录" class="btn btn-default"><a href="/users/register" class="btn btn-default">注册</a></div></form></div></body></html>
提示:为了方便表达,所有页面请求都转化为 curl 请求的方式,下同。
提示:为了节省空间,删掉了很多额外的内容(下同),可以自己执行 curl 命令看完整结果。
服务端如何感知登录用户? 00:07:41
提示:请勾选浏览器控制台 Network 标签页的 Preserve log 选项,这样之前的请求在页面跳转之后还会保留,方便观察。
先配置云引擎 cookieSession中间件 (代码):
app.use(AV.Cloud.CookieSession({ secret: '05XgTktKPMkU', maxAge: 3600000, fetchUser: true }));
用户登录路由的 代码 如下:
router.post('/login', function(req, res, next) {
var username = req.body.username;
var password = req.body.password;
AV.User.logIn(username, password).then(function(user) {
res.saveCurrentUser(user);
res.redirect('/todos');
}, function(err) {
res.redirect('/users/login?errMsg=' + err.message);
}).catch(next);
});
在云引擎的自定义路由中调用了 AV.User.logIn 的 API,并且调用了 res.saveCurrentUser(user); 来将用户信息写入 cookie。
整个请求和响应的流程:
- 浏览器并提交表单的 username 和 password 信息,向服务器发起请求:
curl -v 'https://todo-demo.leanapp.cn/users/login' -H 'content-type: application/x-www-form-urlencoded' --data 'username=zhangsan&password=zhangsan'
- 请求到达云引擎登录相关的路由,根据 username 和 password 进行登录:
var username = req.body.username;
var password = req.body.password;
AV.User.logIn(username, password)
- 路由方法将用户信息写入 cookie:
res.saveCurrentUser(user);
该操作在最终请求响应时, cookieSession 中间件 会将用户的信息写入 header 的 Set-Cookie 中。
- 浏览器收到响应:
< HTTP/1.1 302 Found
< Content-Type: text/plain; charset=utf-8
< Location: /todos
< Set-Cookie: avos:sess=eyJfdWlkIjoiNTUxZDJkZTZlNGIwYjM2NzFhZWNmZWIyIiwiX3Nlc3Npb25Ub2tlbiI6ImFjajd3eTgwdDhmdGtpYzRxYzY1ZDNiZDgifQ==; path=/; expires=Tue, 08 Aug 2017 15:49:21 GMT; secure; httponly
< Set-Cookie: avos:sess.sig=TyI_sXTvNa4nUSxByoX3zxWRZ8M; path=/; expires=Tue, 08 Aug 2017 15:49:21 GMT; secure; httponly
<
在响应里多了两个 Set-Cookie
信息,收到这样的响应后,浏览器会在 cookie 里写入这些信息,其中 avos:sess
对应的值是一个 base64 字符串,具体内容是 :
{"uid":"551d2de6e4b0b3671aecfeb2","sessionToken":"acj7wy80t8ftkic4qc65d3bd8"}
所以标示用户身份的 sessionToken
信息保存在 cookie 里。
提示:avos:sess.sig
是一个校验使用字符串,可以不关心。
cookie 有个特性:每次请求服务器时,会把 cookie 自动添加到请求的 header 中。所以之后再请求该站点的其他页面:
curl 'https://todo-demo.leanapp.cn/todos' -H 'cookie: avos:sess=eyJfdWlkIjoiNTUxZDJkZTZlNGIwYjM2NzFhZWNmZWIyIiwiX3Nlc3Npb25Ub2tlbiI6ImFjajd3eTgwdDhmdGtpYzRxYzY1ZDNiZDgifQ==; avos:sess.sig=TyI_sXTvNa4nUSxByoX3zxWRZ8M'
当这些请求到达云引擎应用之后, cookieSession 中间件 会再次起作用,从请求 header
中取出相关的 cookie 并校验,从中能获取到登录用户的 sessionToken
,然后从存储服务获取该用户的信息(或称为判断 sessionToken
是否有效),并将 user 信息赋值到 request.currentUser
属性上。
之后,请求会到达具体的自定义路由,此时就可以从 request.currentUser
获取发起请求的登录用户信息了。
小结 00:20:20
对于服务端渲染的应用:
- 服务端响应整个 HTML,浏览器负责渲染并展现
- 浏览器提交账号密码,服务端进行用户登录,并把代表用户身份的标示(比如 sessionToken)保存到 cookie 中。
- 浏览器会保存服务端返回的 cookie,并在之后的请求中携带这些 cookie。
- 服务端根据每次请求的 cookie 信息中判断是否有用户身份标示,并确认本次请求是否存在一个「当前登录用户」。
前后端分离的应用 00:22:10
服务端渲染的应用在用户体验方面存在不足,比如一系列表单填写完成之后一次性提交,此时服务端判断参数是否有效再响应用户;还有服务端每次响应整个 HTML 有很大的带宽浪费。之后出现了 AJAX 技术使得光标离开某个表单项之后,浏览器单独发送请求到服务端直接判断其有效性并迅速响应;并且每次浏览器与服务端通信都是一些数据结构(JSON 或者 XML)来降低流量,浏览器根据数据结果来修改 DOM 结构进行展现。
LeanCloud 将存储服务以 REST API 的方式提供服务,让前端(浏览器,或移动设备)可以方便的操作数据,这使得基于 LeanCloud 的应用基本都是前后端分离的。
当前示例使用一些简单页面来模拟前后端分离的应用。
前后端分离应用的请求 00:24:35
请求一个前后端分离的示例(页面代码):
$ curl 'https://todo-demo.leanapp.cn/static/page1.html'
<html>
<head>
<script src="//cdn1.lncld.net/static/js/3.0.4/av-min.js"></script>
</head>
<body>
<h1>page1</h1>
<script>
...
console.log('当前登录用户:%s', AV.User.current() && AV.User.current().get('username'))
console.log('开始登录...')
AV.User.logIn('zhangsan', 'zhangsan')
.then(function(user) {
console.log('登录成功: username: %s, sessionToken: %s', user.get('username'), user._sessionToken)
})
.then(function() {
console.log('当前登录用户:%s', AV.User.current() && AV.User.current().get('username'))
...
</script>
</body>
</html>
服务端响应了一个页面,浏览器渲染页面时,会执行 script 部分的脚本,该脚本可能会做大量工作,比如生成或者修改页面 DOM,并向服务器发请求获取其他数据。比如这个示例就在页面打开之后 3 秒,通过 JS SDK 向服务器发起一个用户登录的请求,收到响应后在浏览器 console 输出一些日志。
提示:浏览器中可能会出现一些 OPTIONS 请求,具体原因见 HTTP访问控制(CORS) 。
使用浏览器请求 page1 ,整个流程如下:
- 页面被渲染完成之后,也一起完成了 AV 对象的初始化工作。
var APP_ID = 'kdrt5GNCjojUjiIujawd5A4n-gzGzoHsz';
var APP_KEY = 'Xvxjo6SVUITIqet69q3mudlF';
AV.init({
appId: APP_ID,
appKey: APP_KEY
});
- 3 秒之后,页面脚本通过 JS SDK 的 AV.User.logIn 方法向 LeanCloud 服务器发起登录请求。
setTimeout(function() {
console.log('当前登录用户:%s', AV.User.current() && AV.User.current().get('username'))
console.log('开始登录...')
AV.User.logIn('zhangsan', 'zhangsan')
}, 3000)
- 服务器响应用户信息:
{
"sessionToken": "u2xtq3dxxvonapqn5uc9snbz7",
"updatedAt": "2017-08-07T14:39:07.619Z",
"objectId": "59887b8b570c350062430143",
"username": "zhangsan",
"createdAt": "2017-08-07T14:39:07.619Z",
"emailVerified": false,
"mobilePhoneVerified": false
}
JS SDK 将该信息反序列化构造出AV.User
对象,然后将其保存在浏览器 Local Storage
中。
通过 JS SDK 的 AV.User.current()
方法获取当前登录用户,本质上就是去 Local Storage
获取用户的信息并返回调用方(比如请求 page2 ,页面代码):
...
console.log('当前登录用户:%s', AV.User.current() && AV.User.current().get('username'))
...
服务端如何感知登录用户 00:34:00
云函数 是运行在云引擎(服务端)的一个方法,通过 JS SDK 的 AV.Cloud.run 方法可以很方便的调用。
示例中定义了一个云函数(代码):
...
AV.Cloud.define('whoami', function(req, res) {
console.log('whoami:', req.currentUser);
var username = req.currentUser && req.currentUser.get('username');
res.success(username);
});
...
在浏览器中通过 JS SDK 调用云函数(请求 page3 ,页面代码):
...
AV.Cloud.run('whoami')
.then(function(username) {
console.log('whoami:', username);
})
...
浏览器请求云函数流程如下:
通过 JS SDK 调用云函数,并根据需要传递参数(示例中未涉及)。JS SDK 会根据 Local Storage 中的信息在请求的 header 中附加 X-LC-Session ,值为用户身份标示 sessionToken。
请求到达云引擎应用,云引擎中间件会判断是否存在 X-LC-Session 的信息,如果有,就使用该值通过存储服务获取用户信息,并赋值给 request.currentUser。
请求进入云函数相关代码流程,开发者就可以获取到 currentUser 了:
console.log('whoami:', req.currentUser);
var username = req.currentUser && req.currentUser.get('username');
res.success(username);
为何 LeanCloud 上的前后端分离的应用不通过 cookie 记录用户信息?00:48:40
因为使用 LeanCloud 的前后端分离应用,运行应用的域(比如云引擎的二级域名 http://abc.leanapp.cn )和提供服务的域(比如 LeanCloud 存储服务 https://api.leancloud.cn/1.1/class/Todo )不同,根据 cookie 的安全策略是不能在不同域传递 cookie 的。
所以 LeanCloud 的 SDK 会在请求的 header 中携带信息让服务端感知到当前登录用户。
小结 00:55:13
基于 LeanCloud 的前后端分离应用:
- 使用云引擎返回「初始化状态」页面。
- 浏览器通过 js 脚本决定如何渲染页面,经常是单页面应用。
- 与服务端交互通过 REST API:由 JS SDK 封装,数据操作走存储服务,云函数操作走云引擎。
- 因为 WEB 应用的域和服务端的域不同,用户状态不能通过 cookie 传递,而是通过请求 header 传递。
两种方式的对比 00:57:52
登录方式 | 云引擎自定义路由 | 浏览器 JS SDK + REST API(云函数) |
---|---|---|
保存位置 | cookie | Local Storage |
服务端感知方式 | 通过 cookieSession 中间件 从 cookie 获取 | 通过云引擎中间件从 header 获取 |
与服务端交互方式 | 页面跳转或表单提交。因为同域,cookie 自动携带 | 通过 JS SDK 操作存储服务的数据或调用云函数。因为跨域,cookie 无法携带,使用 header。 |
服务端用户登录/登出操作 | 自定义路由中用户登录/登出后可以操作相关 cookie,浏览器 cookie 更新,影响后续请求。 | 云函数中用户登录/登出没有意义,不会改变浏览器 Local Storage 的内容,不影响后续浏览器对云函数的请求。 |
疑问解释 01:10:20
相信到这里,最初提出的疑问可以解释了:
在云引擎登录了,但是云函数却没有 currentUser
云引擎自定义路由登录只改变浏览器 cookie,而后续在浏览器通过 JS SDK 调用云函数时,是否携带SessionToken
的信息在header
中,和 cookie 无关。在浏览器调用 JS SDK 登录用户,页面跳转时云引擎中没有 currentUser
浏览器调用 JS SDK 用户登录相关的 API 之后,只是Local Storage
有变化,并在之后的访问存储服务或云函数时会将sessionToken
携带在header
中,cookie 并无变化。而应用页面跳转,或者 form 表单提交访问云引擎自定义路由时, cookieSession 中间件 无法从 cookie 中获取需要的信息。
服务端客户端用户感知同步 01:12:52
登录流程
- 浏览器调用服务端登录相关的路由,路由中登录用户,并更新 cookie,且响应中携带
sessionToken
。 - 浏览器收到登录响应,解析出
sessionToken
,并调用 JS SDK 的AV.User.become
方法在浏览器登录。
在此之后,不管是请求云引擎自定义路由还是请求云函数,都能确保 currentUser 的存在。当然 cookie 还存在过期的问题,不过这里就不展开讨论了。
登出流程
- 浏览器调用服务端登出路由,该路由可能做一些用户相关的资源清理,并清空 cookie。
- 浏览器受到登出响应后,调用 JS SDK 的相关方法在浏览器登出。
fetchUser 属性的作用 01:25:10
通过控制云引擎中间件的 fetchUser 属性,可以降低一部分不必要的 _User
的查询请求。
以 AV.Cloud.define API 为例,当收到云函数请求时,云引擎中间件从请求 header
中获取 sessionToken
信息,并且确认下 fetchUser
属性的值:
- 如果为 true (默认):则使用
sessionToken
从存储服务读取用户(_User
表)的信息。之后将sessionToken
和currentUser
信息复制到request
的相关属性上。 - 如果为 false:则跳过从存储服务读取用户信息的步骤,只将
sessionToken
赋值到 request 的属性上。也就意味着云函数中```request.currentUser
为undefined
。
如何判断是否需要设置 fetchUser 的属性 01:33:00
如果云函数的相关逻辑需要
_User
的其他信息,比如username
,那就设置fetchUser
为true
,或者不设置使其保持默认值。否则,可以设置
fetchUser
为false
,但是需要在所有数据操作(和云函数调用)时将 sessionToken 加入到请求中:
var query = new AV.Query('Todo');
query.equalTo('status', 0);
query.find({sessionToken: req.sessionToken})
如果 req.sessionToken 有效,则存储服务会根据查询条件和 ACL 返回适当的信息。
如果 req.sessionToken 无效(过期或伪造),则存储服务可能因为 ACL 拒绝操作或返回空结果。