「LeanCloud Web 应用开发实践」系列直播及文章分享持续进行中。
每周二周四晚上 8 点开始,时长预计 45 分钟。在 “leanCloud通讯” 微信公众号回复 “公开课” 即可获取直播链接。
《LeanCloud Web 应用开发实践公开课》上期回顾和本期主题介绍。
抛出疑问 00:01:10
- 在云引擎登录了,但是云函数却没有 currentUser
- 在浏览器调用 JS SDK 登录用户,页面跳转时云引擎中没有 currentUser
- 云引擎 SDK 中有些地方会有 fetchUser 属性,有什么用?
为了理清 currentUser 的状态,需要看下不同类型的 WEB 应用是如何运作的。
早期 WEB 应用——服务端渲染 00:02:40
使用云引擎 demo 来演示,可以使用 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) - 服务器响应用户信息:
JS SDK 将该信息反序列化构造出{ "sessionToken": "u2xtq3dxxvonapqn5uc9snbz7", "updatedAt": "2017-08-07T14:39:07.619Z", "objectId": "59887b8b570c350062430143", "username": "zhangsan", "createdAt": "2017-08-07T14:39:07.619Z", "emailVerified": false, "mobilePhoneVerified": false }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 的前后端分离应用,运行应用的域(比如云引擎的二级域名 abc.leanapp.cn )和提供服务的域(比如 LeanCloud 存储服务 api.leancloud.cn/1.1/class/T… )不同,根据 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 拒绝操作或返回空结果。