6 React项目架构 - web server网络请求转发
Mon, Sep 24, 2018
开发环境工程架构示意图
accessToken
问题:
- 部分接口需要带accessToken才有权限请求
- accesstoken是用户登录后,cnodejs服务器返回的
- accesstoken不能存在浏览器里,有安全风险
解决方案:
- 获取accessToken后,存在web服务器里,通过session机制,在web服务端检测有木有token,没就给用户弹登录,有就转发请求
实现
安装工具
$ npm i body-parser express-session query-string -S |
说明:
- body-parser是用来转化request的body的,转化成json格式的数据
- espress-sisson是express插件,用来存放服务端session
- query-string是用来把url「?」后面的内容转化为一个json的
配置web服务器
server.js
... const bodyParser = require('body-parser') app.use(bodyParser.json()) // #1-1 app.use(bodyParser.urlencoded({ extended: false })) // #1-2 const session = require('express-session') app.use(session({ // #2 maxAge: 10 * 60 * 1000, // #2-1 name: 'sid', // #2-2 resave: false, // #2-3 saveUninitialized: false, secret: '(‘◇’)?' // #2-4 })) ... |
说明:
- body-parser用于解析request的body中的参数
- 把application json的数据,转化成req.body的数据
- 对应formdate的数据 - 把这些数据也转换到req.body中,之后通过req.body就能拿到请求里的参数了
- 配置服务器session相关功能的
- 设置session的过期时长
- 10分钟
- 做测试用的,所以不需要太长
- 线上的session是要存在数据库中的,如redis
- 现在是存在内存中,服务关掉了,相关数据就没了,所有人都需要重新登录一遍
- 指定sessionID对应的cookieID,浏览器端可以通过cookieID拿到sessionID的值
- 是否每次请求都申请一个cookieID
- secret是一个密钥串,用于对session加密,保证cookie在浏览器端无法解密
- 设置session的过期时长
验证accessToken
接口:
post /accesstoken 验证 accessToken 的正确性
接收 post 参数
- accesstoken
String
用户的 accessToken如果成功匹配上用户,返回成功信息。否则 403。
返回值示例
{success: true, loginname: req.user.loginname, id: req.user.id, avatar_url: req.user.avatar_url}
逻辑:
- 客户端调用web服务器的/login接口,发post请求,传accessToken到web服务里来
- web服务器把请求转发给cnode服务器
- 如果验证通过(accessToken有效):
- 构建一个user对象作为value,赋给sessionID为key的map里面 - 信息存在web服务器的内存中(业务逻辑中不用处理,express的session模块已经做好相关逻辑了)
- 把服务端消息返回给客户端
server/util/handler_login.js
const router = require('express').Router() const axios = require('axios') const baseUrl = 'https://cnodejs.org/api/v1' router.post('/login', function(req, res, next) { axios.post(`${baseUrl}/accesstoken`, { accesstoken: req.body.accessToken }) .then((resp) => { if (resp.status === 200 && resp.data.success) { req.session.user = { accessToken: req.body.accessToken, loginName: resp.data.loginname, id: resp.data.id, avatarUrl: resp.data.avatar_url, } res.json({ success: true, data: resp.data }) } }) .catch((err) => { if (err.response) { // #1 res.json({ success: false, data: err.response.data // #3 }) } else { // #2 next(err) } }) }) module.exports = router |
说明:
- 如果cnode服务端返回了报错信息,表明是业务逻辑报错,直接返回err.response给用户
- 如果cnode没报错(发生了非业务逻辑错误),则把错误交给「全局错误处理器」处理
- 如果直接写err.response的话,res.json会把err.response这串东东整个转成字符串,但是response对象太大了,无法转成字符串,会导致响应一致无法返回客户端
- 报错信息:(node:9519) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 2): TypeError: Converting circular structure to JSON
其他接口的代理转发
server/util/proxy.js
const axios = require('axios') const querystring = require('query-string') // #7 const baseUrl = 'https://cnodejs.org/api/v1' module.exports = function(req, res, next) { const path = req.path const user = req.session.user || { } // #2 防止undfine产生报错 const needAccessToken = req.query.needAccessToken // #1 if (needAccessToken && !user.accessToken) { res.status(401).send({ success: false, msg: 'need login' }) } const query = Object.assign({}, req.query, { accesstoken: (needAccessToken && req.method === 'GET') ? user.accessToken : '' // #8 }) if (query.needAccessToken) delete query.needAccessToken // #3 axios(`${baseUrl}${path}`, { method: req.method, params: query, data: querystring.stringify(Object.assign({}, req.body, { (needAccessToken && req.method === 'POST') ? user.accessToken : '' // #4 所有POST请求都加accessToken })), headers: { 'Content-Type': 'application/x-www-form-urlencoded' // #5 指定使用form-data格式发送请求-由于服务端设定导致的 } }).then((resp) => { if (resp.status === 200) { res.send(resp.data) } else { res.status(resp.status).send(resp.data) // #6 链式调用,给client返回错误信息 } }).catch((err) => { if (err.response) { res.status(500).send(err.response.data) } else { res.status(500).send({ success: false, msg: '未知错误' }) } }) } |
说明:
通过客户端传过来的url中,是否有needAccessToken=true判断是否需要给服务端传accessToken
下面需要通过user.xxx调用相关方法,需要防止user为undefine时导致报错,故在此处做初始化
干掉web服务器和client之间的私自约定的query参数
- 所有发向cnode的POST请求,在body里拼上accessToken,即使cnode接口不需要,加上也OK
- 虽然表面上看上去,是从session.user中拿accessToken的,但是其实,user信息是存在web服务器中的,session.user这步操作,会通过sessionID,在web服务器中找到对应的user,然后拿accessToken
- cnode的api有些无法接收json,所以需要指定下Content-Type,用form-data的格式去发请求
链式调用,给client返回错误信息和状态码
如果不用querystring.stringify转码,会报500错误 - accesstoken不正确
插件:`npm i query-string -S`
如果不使用格式转换工具的话,格式会类似:{"accesstoken": "xxx"}
使用后,格式类似我们在浏览器端使用「表单」请求数据的格式一样
- GET方法,把accessToken拼在query后面...Object.assign语法蛮好玩的...
server.js调用相关转发方法
... app.use('/api/user', require('./util/handler-login')) app.use('/api', require('./util/proxy')) if (!isDev) { ... |
说明:
- 转发相关方法,需要在服务起来之前调用
测试
普通接口:http://localhost:2333/api/topics
accessToken接口:
实现测试页面 client/views/test/api.test.js
import React from 'react' import axios from 'axios' /* eslint-disable */ // #1 export default class TestApi extends React.Component { getTopics() { axios.get('/api/topics') .then((resp) => { console.log(resp) }).catch((err) => { console.log(err) }) } login() { axios.post('/api/user/login', { accessToken: 'xxxxxx-xxx-xxxx-xx' }).then(resp => { console.log(resp) }) } markAll() { axios.post('/api/message/mark_all?needAccessToken=true') .then(resp => { console.log(resp) }) } render() { return ( <div> <button onClick={this.getTopics}>topics</button> <button onClick={this.login}>login</button> <button onClick={this.markAll}>markAll</button> </div> ) } } /* eslint-enable */ |
说明:
- 禁止eslint检测区块
router.jsx添加测试页面入口
... import TestApi from '../views/test/api.test' export default () => [ ... <Route path='/test' component={TestApi} key="test" />, ] |
注意:
- dev:web:server还不支持router和mobx,通过dev:web:server访问index.html的话,访问不到东西...
- 依然需要通过dev:client访问我们的测试页面 http://localhost:8787/test
- 又因为,默认情况下,通过dev:client走的请求,默认都是走8787端口的,点击「login」按钮,会发的请求是类似:「https://localhost:8787/api/user/login」
- 这样的请求dev:client下的server无法处理
- 需要让我们的web:server处理才行,即:「https://localhost:2333/api/user/login」
- 需要配置webpack的dev服务器的时候,指定下/api相关的请求,转发给2333端口
webpack.config.client.js
if (isDev) { ... config.devServer = { host: '0.0.0.0', port: '8787', ... proxy: { '/api': 'http://localhost:2333' } }; ... } |
测试方式:
- 起服务:dev:client dev:web:start
- 访问http://localhost:8787/test
- 点击login
- 观察Network