[2017年8月8日 更]
这个项目的来由是一个好基友想出来的,加之之前有个高中的同学曾经找我提过这个需求,虽然最后不了了之,但是在听到好友的提议之后,而且想找个项目实践一下自己的 php ,顺便接触一下框架,于是便拿了这个题目来当做毕业设计选题,刚开始对这个系统的设定就是起码能达到上线运行的,所以需要考虑到很多很多的问题,一开始定下采用前后端分离的开发方案。
既然选了 php ,还是使用符合 RESTful 标准的无状态 API 实现数据交互,所以最初敲定了技术栈是:用户端 ( vue.js + axios + webpack 等) + 服务端 ( Lumen 框架+ MySQL + Nginx ) ,此外还有些开发工具: VSCode 、 PhpStorm 、 SublimeText 、 SourceTree(Git) 、 Navicat Premium 、 Postman 等等,熟练的使用这些开发工具,的确能提高开发效率,还能方便的进行测试和管理。
一开始进行了需求分析,虽然没有行内的专业人士的指导,在咨询一些做包车的同学以及结合自己的生活经验之后确定了系统模块的划分和具体模块的子功能,整个系统暂时被分为了三大模块,20+项的子功能,有些功能因为设计时考虑不够全面而遗漏,可能需要后期发掘到具体需求之后添加。需求分析之后,根据先前的需求开始了数据库设计,系统功能比较复杂,经过了多轮的讨论,最终确定下17张表,中途对整个系统的逻辑进行了重构,表格字段进行了多次的修改,这个过程是在是有点坎坷曲折、一波三折、九曲十八弯,好吧,有点跑远了(认˙-˙真),因为遇到了一个难点,就是需要结合多个表查询出具体班次的信息、余票、上落点等信息,每个班次的上落点不同,还有不同顺序,时间等信息,而且还有车票提前售卖等问题导致这个是设计的难点。
[2018年5月19日 更]
说好的不定期更新直接拖更到项目完全写完还不止哈哈,下面将对开发流程进行分享,先感谢某个童鞋帮助我一起完成这个项目。
1、系统结构的设计:
之所以选择基于符合RESTful标准的无状态API实现数据交互,是因为通过定义统一的接口,在未来的开发中无论是为Native Application还是Progressive Web Application都能提供无差别的服务;其无状态的性质简化了服务端组件的复杂程度且大大提高了可扩展性,更适合根据服务量来对分布式服务进行配置;直观易懂的URI和显式使用HTTP方法能极大地降低开发人员间对接的成本,多种不同的数据传输形式也能方便快捷的对所需资源进行获取和解析。
接下来列举一下设计符合 RESTful 标准的无状态 API 所遵循的四条基本原则:
(1)基于REST Web服务要求发送完整且独立的请求,请求中要求包含所需要的所有数据,因此服务器在处理请求时不需要对任何的上下文和历史状态进行检检索,无状态的规则可以提高Web服务的性能,降低服务端组件设计的复杂性,适合构建有负载均衡及故障转移功能的服务拓扑,以提高并发性能,优化用户体验。
(2)基于REST的Web服务在对资源进行访问时,需要根据互联网标准(RCF2616)的定义显式地使用HTTP 方法对操作进行映射,该基本设计原则建立了创建、检索、更新和删除操作与HTTP 方法之间的一对一映射:
• 使用POST 方法在服务器上创建资源。
• 使用GET 方法对服务器上的资源进行检索和获取。
• 使用PUT 方法对服务器上存在的资源进行状态的修改或内容的更新。
• 使用DELETE 方法对服务器上存在的资源进行删除。
由于操作已与对应的HTTP 方法相映射,所以,为了保证接口的通用性和突出资源为中心的设计原则,在URI中不应该定义更多的动词。
(3)基于REST 的Web 服务在对资源进行访问时所请求的URI应该直观易懂,可以通过定义目录结构式的URI。这类URI更加具有层次感,且定义了不同子路径以达到对不同资源进行访问的目的。例如:
https://www.test.com/paper/cs/{id}
示例清晰的展示了根/paper下有个/cs的节点专门存放计算机科学与技术专业的学术论文,只要传入编号就能检索到对应编号的论文。此外,在考虑URI的结构时还应注意一下几点:
• 隐藏服务端脚本文件扩展名,方便移植到其他脚本技术时无需更改URI。
• 内容保持小写。
• 将空格替换为连字符或下划线。
• 尽量避免字符串查询。
• 资源多重表述。
(4)基于REST 的Web 服务,客户端可以通过内容协商请求某种特定的数据格式以方便处理,一般来说,基于REST 的Web 服务常用JSON和XML两种数据格式进行传输。
2、技术栈的挑选和简介
一开始敲定了开发语言就是 php ,选择了 Lumen 框架的原因是它不仅特别针对了 API 服务的开发进行了并发性能等优化,而且具有 laravel 框架内绝大部分的特性,这些特性大多具有很高的实用性。下面进行部分特性的简单介绍。
借助 Lumen 框架开发仅负责数据处理和持久化的 API ,较为常用的功能有 Eloquent ORM , 这是基于 ActiveRecord 实现的 ORM , ORM通过屏蔽底层不同数据库的差异,应用程序只需要调用ORM封装好的接口即可对数据库对象与应用程序对象进行转换,以实现高效的数据操作和持久化;路由和中间件也是必不可缺的功能,路由用于匹配url和处理逻辑,而中间件用于在请求被处理前进行某些特定的操作。
用户交互方面采用了 Vue.js 进行开发,选择 Vue.js 的原因是在于 Vue.js 的响应式数据渲染能力,方便快捷的组件化开发,以及轻量却性能强大的特点,丰富的生态也使得开发更加便捷。
Vue.js 采用了MVVM的架构,MVVM 由 Model,View,ViewModel 三部分构成,Model 层代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑;View 代表UI 组件,它负责将数据模型转化成UI 展现出来,ViewModel 是一个同步View 和 Model的对象。在MVVM架构下,View 和 Model 之间并没有直接的联系,而是通过ViewModel进行交互,ViewModel 通过双向数据绑定把 View 层和 Model 层连接了起来,而View 和 Model 之间的同步工作完全是自动的,无需人为干涉,因此只需关注业务逻辑,不需要手动操作DOM, 不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统一管理。
Vue.js 的生态也十分丰富,配合 Axios 和 Vue-axios 能很方便的进行 http 请求;结合 Vue-Router 能更方便地将组件或者视图映射到特定的 url 上,以创建单页应用;还有就是通过 Vuex 可以很方便的对数据模型进行管理和操作。
3、开发历程
下面将主要介绍一些在开发过程中遇到的一些问题,不针对框架等特性的使用进行介绍。
(1)在测试异步请求的过程中,发现无法正常获取到 API 返回的数据,在浏览器控制台中发现以下错误:
经过查阅资料,在进行异步请求的过程中,存在跨域问题,而且客户端首先会向服务端发送一个option请求,因此,服务端要对发来的option请求进行相应,且允许跨域请求,要将以下代码注册成全局中间件:
//CatchAllOptionsRequestsMiddleware.php <?php namespace App\Http\Middleware; use Closure; class CatchAllOptionsRequestsMiddleware { /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { if ($request->isMethod('OPTIONS')) { app()->router->options($request->path(), function() { return response('', 200); }); } $response = $next($request); return $response; } }//CorsMiddleware.php <?php namespace App\Http\Middleware; use Closure; class CorsMiddleware { /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { $response = $next($request); $response->header('Access-Control-Allow-Methods', 'HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS'); $response->header('Access-Control-Allow-Headers', $request->header('Access-Control-Request-Headers')); $response->header('Access-Control-Allow-Origin', env('APP_ALLOW_ORIGIN', '*')); $response->header('Access-Control-Allow-Credentials', 'true'); return $response; } }(2)身份验证和Rbac权限管理
身份验证通过常规的解决方案,采用token鉴权方式,登录成功后将授予客户端一个随机生成且唯一的token,没用正确token的用户将被拒绝访问:// path: app/Http/Middleware/Authenticate.php public function handle($request, Closure $next, $guard = null) { // 验证Token if ($this->auth->guard($guard)->check()) { // 验证账户状态 $user = $this->auth->guard($guard)->user(); if (Gate::forUser($user)->allows('user.login')) { return $next($request); } } return response()->json([ 'code' => 401, 'errmsg' => "Unauthorized token." ], 401); }至于权限管理采用了Rbac的权限管理策略,通过赋予不同角色不同的权限,再将角色赋予给用户,达到权限隔离的效果。
// path: app/Http/Middleware/RbacPermission.php public function handle($request, Closure $next, $permissions) { $user_permissions = $request->user()->permissions()->select('name')->get()->pluck('name'); $count = 0; if (!is_array($permissions)) { $delimiter = stristr($permissions, '&') ? '&' : '|' ; $permissions = explode($delimiter, $permissions); } if ($delimiter == '&') $count = count($permissions) - 1; if ($user_permissions->intersect($permissions)->count() <= $count) { return response()->json([ 'code' => 403, 'errmsg' => "Forbidden" ], 403); } return $next($request); }// path:app/Providers/AuthServiceProvider.php if (Schema::hasTable('permissions')) { Permission::all()->each(function ($permission) { if (starts_with($permission->name, "admin.")) { $closure = function ($user) use ($permission) { return $user->hasPermission($permission->name); }; } else { $closure = function ($user, $related_uid = null) use ($permission) { if ($user->hasPermission("admin.{$permission->name}")) { return true; } elseif (is_null($related_uid)) { return $user->hasPermission($permission->name); } else { return $user->hasPermission($permission->name) && $related_uid === $user->id; } }; } Gate::define($permission->name, $closure); }); }(3)在客户端进行请求时,服务器返回401无法自动跳转登录界面,可以通过Axios全局钩子解决:
// path:src/api/index.js Axios.interceptors.response.use(res => { return res; }, error => { if (error.response) { switch (error.response.status) { case 401: sessionStorage.removeItem('token'); router.push({path: '/user/login'}); store.commit('CHANGE_LOGIN_STATUS', false); break; default: break; } } return Promise.reject(error.response); });(4)主文件过大导致页面加载缓慢,可以通过组件懒加载以及代码分块解决:
// path:src/router/index.js,通过备注不同的 chunk name 可以将多个组件打包到同一个chunk//Chunk help const HelpSupport = () => import(/* webpackChunkName: "help" */ '../components/page/help/support.vue'); const HelpDocFAQ = () => import(/* webpackChunkName: "help" */ '../components/page/help/faq.vue');代码分块则在webpack打包配置文件中进行配置
//webpack 3.x,需要插件支持 plugins:[ new ExtractTextPlugin({ filename: utils.assetsPath('css/[name].[contenthash].css'), allChunks: true, }), new OptimizeCSSPlugin({ cssProcessorOptions: config.build.productionSourceMap ? { safe: true, map: { inline: false } } : { safe: true } }), new webpack.optimize.ModuleConcatenationPlugin(), new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks (module) { return ( module.resource && /\.js$/.test(module.resource) && module.resource.indexOf( path.join(__dirname, '../node_modules') ) === 0 ) } }), new webpack.optimize.CommonsChunkPlugin({ name: 'manifest', minChunks: Infinity }), new webpack.optimize.CommonsChunkPlugin({ name: 'app', async: 'vendor-async', children: true, minChunks: 3 }), //webpack 4.x optimization: { splitChunks: { chunks: 'initial', cacheGroups: { vendor: { test: /node_modules\//, name: 'page/vendor', priority: 10, enforce: true }, commons: { test: /common\/|components\//, name: 'page/commons', priority: 10, enforce: true } } }, ](5)页面触底进行异步加载数据
//触底加载逻辑代码 handleRouterScroll: function (event) { let el = event.target; if (el) { let scrollTop = el.scrollTop; // 滚动条滚动高度 let scrollHeight = el.scrollHeight; // 文档高度 let windowHeight = 0; // 可视窗口高度 if (document.compatMode === "CSS1Compat") { windowHeight = document.documentElement.clientHeight; } else { windowHeight = document.body.clientHeight; } if ((scrollHeight - 40) <= windowHeight + scrollTop) { if(this.currentPage < this.totalPage) { //移除routerView的滚动事件监听器,防止多次监听 this.$refs.routerView.removeEventListener('scroll', this.handleRouterScroll); this.currentPage += 1 //进行异步加载...... this.$refs.routerView.addEventListener('scroll', this.handleRouterScroll); } } }(6)一个尚未解决的浏览器适配问题,其中在Safari上发现收到橡皮筋问题的影响,在拖动页面溢出的情况下会造成 Header 被覆盖的问题,还有就是在安卓设备上页面元素会在键盘被调出的情况下被压缩的情况,暂时未找到解决方案,假如有人知道如何解决并留言给我,感激不尽。
4、小总结:
真正写完一个完整的项目,感觉真正学到了不少的东西,虽然过程有时候很虐心,最后感谢一直有帮助我的童鞋 们,还有就是感谢Segment Fault、StackOverflow、CSDN、Google、Youtube(放松时候用的)、bilibili(放松时候用的)的大力支持。。。[/撒花]
3 条评论
无名氏 · 2017年9月4日 下午5:08
牛逼
PPTV · 2018年5月19日 下午4:59
牛批
昊炜 · 2018年5月19日 下午5:05
最优秀就是你啦