一、前言
半年前左右折腾了一个前后端分离的架子,这几天才想起来翻出来分享给大家。关于前后端分离这个话题大家也谈了很久了,希望我这个实践能对大家有点点帮助,演示和源码都贴在后面。
二、技术架构
这两年angularjs和reactjs算是比较火的项目了,而我选择angularjs并不是因为它火,而是因它的模块化、双向数据绑定、注入、指令等都是非常适合架构较复杂的前端应用,而且文档是相当的全,碰到问题基本上可以在网上都找到答案。所以前端基本思路就以angularjs为主、代码模块化,通过requirejs实现动态加载,ui选择dhtmlx为主配合少量bootstrap3使用。前端项目dhtmlx_web:
开发工具 Sublime Text 前端框架angularjs 模块加载requirejs 前端UI dhtmlx + bt3 包管理 bower 构建工具 gruntjs 服务架设 http_server.js 浏览器支持IE8+ 实际是为了支持IE8我做了很多的努力,因为angluarjs 1.3已经不再支持IE8了,而我使用的angularjs是1.3.9 引入的一些其它类库或插件就不列出来了,太多了服务端主要是提供restful数据服务,所以.net下毫无疑问选择asp.net webapi来实现了。 后端项目dhtmlx_webapi:
开发工具 VS2012 数据服务 Asp.net WebApi 跨域实现 CORS 依赖注入 Autofac 日志组件 Log4net 数据库已改为MS Access (为了方便大家可以直接运行)三、前端介绍
1、基本说明
项目主要分了三个文件夹assets存放引用类库及插件,app中则是项目文件,build中存放构建后的文件,先让大家看几个实现的页面,再介绍代码吧 这是查询页面,查询条件、分页、排序都可用这是虚拟分页的实现,也实现了过滤行,我自己也是挺喜欢这种风格的。
编辑页面
2、程序入口
以上的几个页面都比较典型,如果大家右键查看源码的话只能看到:权限管理系统
那我们就从这里开始讲起,实际上我设计的这个前端也可以看做是单页面应用SPA,只有一个页面也就是index.html点左边菜单栏打开的新tab实际只是加载了一个ng的controller渲染出来的一个层而已,当然为了实用也支持输入一个页面地址。
当然这个页面是build之后的结果,build之前的index.src.html不忍直视,主要是因为不想引入所有的dhtmlx类库,只是选择性引入很多文件没有用一个dhtml.js替代权限管理系统
不过从过里可以看出,我们的入口文件是main.js
!function () { //config requirejs require.config({ baseUrl: './app/js/', paths: { assets: '../../assets/', css: '../../assets/lib/requirejs/css', text: '../../assets/lib/requirejs/text', views: '../views', config: 'config/global', 'angular-resource': '../../assets/lib/angularjs/1.3.9/angular-resource' }, shim: {}, urlArgs: 'v=201502100127&r='+Math.random() }); //init main require(['app', 'config', 'angular-resource', 'service/index', 'directive/index', 'controller/index'], function (app, config) { app.init(); } );}();
在main.js中,我们配置requriejs并且启动主程序,启动时载入了
app、config、angular-resource、service/index、directive/index、controller/index这六个模块,那我们就看app模块,其它不再分析了,大家自己去看代码吧 这里加载的app位于app/js/下的app.js文件define(function (require) { 'use strict'; var app = angular.module('chituApp', ['ngResource']); app.init = function () { angular.bootstrap(document, ['chituApp']); }; app.config(function ($controllerProvider, $provide, $compileProvider, $resourceProvider) { // Save the older references. app._controller = app.controller; app._service = app.service; app._factory = app.factory; app._value = app.value; app._directive = app.directive; // Provider-based controller. app.controller = function (name, constructor) { $controllerProvider.register(name, constructor); return (this); }; // Provider-based service. app.service = function (name, constructor) { $provide.service(name, constructor); return (this); }; // Provider-based factory. app.factory = function (name, factory) { $provide.factory(name, factory); return (this); }; // Provider-based value. app.value = function (name, value) { $provide.value(name, value); return (this); }; // Provider-based directive. app.directive = function (name, factory) { $compileProvider.directive(name, factory); return (this); }; // $resource settings $resourceProvider.defaults.stripTrailingSlashes = false; }); app.run(function (){ //run some code here ... }); return app;});
3、与后台restful api交互 数据服务我准备都放在service文件夹下,比如菜单的数据服务在service/index,目前是静态数据。不过项目所有的ajax访问都是由ngResource实现的,实际对$http的封装,$resource可以方便的与resultful接口接合,我们可以大大简化操作,我是比较推荐它,一个简单示例:
//定义app.factory('api', ['$resource', function ($resource) { return $resource(url,{query:{..},update:{..},remove:{..},get:{..},insert:{..}});}]);app.controller('test',['api', function (api) { //查询 api.query(params, function(data){ var list = data; }); //获取并更新 api.get({id:1}, function(data){ data.name = 'new name'; data.update(); }); //新增 api.insert(data); //删除 api.remove({id:1});}]);
define(['app'], function (app) { app.directive('dhtmlxgrid', function ($resource) { return { restrict: 'A', replace: true, scope: { fields: '@', header1: '@', header2: '@', colwidth: '@', colalign: '@', coltype: '@', colsorting: '@', pagingsetting: '@', autoheight: '=', url: '@', params:'@' }, link: function (scope, element, attrs) { scope.uid = app.genStr(12); element.attr("id", "dhx_grid_" + scope.uid); element.css({ "width": "100%", "border-width": "1px 0 0 0"}); scope.grid = new dhtmlXGridObject(element.attr("id")); scope.header1 && scope.grid.setHeader(scope.header1); scope.header2 && scope.grid.attachHeader(scope.header2); scope.fields && scope.grid.setFields(scope.fields); scope.colwidth && scope.grid.setInitWidths(scope.colwidth) scope.colalign && scope.grid.setColAlign(scope.colalign) scope.coltype && scope.grid.setColTypes(scope.coltype); scope.colsorting && scope.grid.setColSorting(scope.colsorting); scope.grid.entBox.onselectstart = function () { return true; }; if (scope.pagingsetting) { var pagingArr = scope.pagingsetting.split(","); var pageSize = parseInt(pagingArr[0]); var pagesInGrp = parseInt(pagingArr[1]); var pagingArea = document.createElement("div"); pagingArea.id = "pagingArea_" + scope.uid; pagingArea.style.borderWidth = "1px 0 0 0"; var recinfoArea = document.createElement("div"); recinfoArea.id = "recinfoArea_" + scope.uid; element.after(pagingArea); element.after(recinfoArea); scope.grid.enablePaging(true, pageSize, pagesInGrp, pagingArea.id, true, recinfoArea.id); scope.grid.setPagingSkin("toolbar", "dhx_skyblue"); scope.grid.i18n.paging = { results: "结果", records: "显示", to: "-", page: "页", perpage: "行每页", first: "首页", previous: "上一页", found: "找到数据", next: "下一页", last: "末页", of: " 的 ", notfound: "查询无数据" }; } scope.grid.setImagePath(app.getProjectRoot("assets/lib/dhtmlx/v403_pro/skins/skyblue/imgs/")); scope.grid.init(); if (scope.autoheight) { var resizeGrid = function () { element.height(element.parent().parent().height() - scope.autoheight); scope.grid.setSizes(); }; $(window).resize(resizeGrid); resizeGrid(); } //scope.grid.enableSmartRendering(true); if (scope.url) { var url = app.getApiUrl(scope.url); var param = scope.$parent[scope.params] || {}; var api = $resource(url, {}, { query: { method: 'GET', isArray: false } }); scope.grid.setQuery(api.query, param); } //保存grid到父作用域中 attrs.dhtmlxgrid && (scope.$parent[attrs.dhtmlxgrid] = scope.grid); } }; }); app.directive('dhtmlxtoolbar', function () { return { restrict: 'A', replace: false, scope: { iconspath: '@', items:'@' }, link: function (scope, element, attrs) { scope.uid = app.genStr(12); element.attr("id", "dhx_toolbar_" + scope.uid); element.css({ "border-width": "0 0 1px 0" }); scope.toolbar = new dhtmlXToolbarObject(element.attr("id")); scope.toolbar.setIconsPath(app.getProjectRoot(scope.iconspath)); var items = eval("(" + scope.items + ")"); //scope.toolbar.loadStruct(items); var index = 1; var eventmap = {}; for (var i in items) { var item = items[i]; if (item.action) eventmap[item.id] = item.action; if (item.type == 'button') { scope.toolbar.addButton(item.id, index++, item.text, item.img, item.imgdis); item.enabled == false && scope.toolbar.disableItem(item.id); } else if (item.type == 'separator') { scope.toolbar.addSeparator(index++); } } scope.toolbar.attachEvent("onClick", function (id) { var name = eventmap[id]; if (name && scope.$parent[name] && angular.isFunction(scope.$parent[name])) scope.$parent[name].call(this); }); attrs.dhtmlxtoolbar && (scope.$parent[attrs.dhtmlxtoolbar] = scope.toolbar); } } });});
function provider(name, provider_) { assertNotHasOwnProperty(name, 'service'); if (isFunction(provider_) || isArray(provider_)) { provider_ = providerInjector.instantiate(provider_); } if (!provider_.$get) { throw $injectorMinErr('pget', "Provider '{0}' must define $get factory method.", name); } return providerCache[name + providerSuffix] = provider_;}function enforceReturnValue(name, factory) { return function enforcedReturnValue() { var result = instanceInjector.invoke(factory, this); if (isUndefined(result)) { throw $injectorMinErr('undef', "Provider '{0}' must return a value from $get factory method.", name); } return result; };}function factory(name, factoryFn, enforce) { return provider(name, { $get: enforce !== false ? enforceReturnValue(name, factoryFn) : factoryFn });}function service(name, constructor) { return factory(name, ['$injector', function($injector) { return $injector.instantiate(constructor); }]);}
7、关于UI类库的说明
接下来说下UI的东西,适合自己的UI都会有需要改的如easyui我也改了很多,同样dhtmlx这款UI也有很多不合适或有问题的地方,比如不支持字体图标,比如grid更好的数据加载机制等等,我对它的修改集中放在dhtml.custom.js中,把它改到我能用或更好用,花费了我大量的时间,因为需要读懂它的源码后才能修改,具体修改大家可以查看这个文件。还有大家可能会好奇我为什么在项目中还有引入
其实就是只使用dhtmlx开发出来的页面是很单调的,这些类库我在组件中都有用到,比如myheader中的这些,还有表单控件dhtmlx的form太丑了,所以引入bt3
8、关于nodejs的工具使用
这不是我们的重点,你只要知道npm install命令下载node_modules,然后就是运行grunt及httpjs,关于它们的详细介绍及如何配置gruntfile大家自己去了解,最后代码修改完成之后,记得运行grunt重新构建。 四、服务端介绍服务端大家都很熟悉,没什么说的。
关于服务端为什么没分层,这里仅仅是一个数据服务而已,没必要太复杂所以我就没分层了。如果业务比较复杂是可以把控制层Controller、服务层Sevice分开的,同时也衍生出接口层 Interface及数据实体层Entity,关于服务层是不是需要再分或是不同业务要不要分开就要看具体情况了,看过一段时间的DDD,感觉是从技术上把简单的问题复杂化了,觉得实际做项目是没必要,可能是我的理解不够深,我的理念还是能满足需求的情况下能简则简。 1、配置 在Global.asax.cs中添加FrameworkConfig.Register(),在FrameworkConfig.cs中Configuration.Instance() .RegisterComponents() //注册公共组件 .RegisterDependencyResolver() //注册依赖注入 .RegisterProjectModules() //注册项目模块 .RegisterHttpCorsSupport(); //开启CORS支持
public class TenantController : ApiController{ private ITenantService _tenantService; private RequestParameter _requestParameter; ////// 注入参数及服务 /// /// /// public TenantController(ITenantService tenantService, RequestParameter requestParameter) { _tenantService = tenantService; _requestParameter = requestParameter; } ////// 查询返回多条租户数据结果集 /// ///public Paging Get() { var query = _requestParameter.ReadAsQueryEntity(); var result = _tenantService.GetTenantListWithPaging(query); return result; } /// /// 创建新的租户信息 /// public string Post([FromBody]bas_tenant tenant) { return _tenantService.Insert(tenant); } ////// 查询返回单个租户信息 /// /// ///public bas_tenant Get(string id) { return _tenantService.GetTenant(id); } /// /// 更新租户信息 /// /// public void Put(string id,[FromBody]bas_tenant tenant) { _tenantService.Update(id, tenant); } ////// 删除租户信息 /// /// public void Delete(string id) { _tenantService.Delete(id); }}
服务中处理TenantService.cs
public class TenantService : ITenantService{ private IUser _user; private IDbContext _db; //注入用户信息及数据访问上下文 public TenantService(IUser user,IDbContext db) { _user = user; _db = db; } //不分页查询 public ListGetTenantList() { var result = _db.Select("*") .From("bas_tenant") .QueryMany (); return result; } //分页查询 public Paging GetTenantListWithPaging(QueryEntity qe) { var result = _db.Select("*") .From("bas_tenant") .Where<_StartWith>("tenant_id,tel", qe).IgnoreEmpty() //商户编码、手机号 使用startwith查询 忽略空值 .Where<_Like>("tenant_name,charge_person,addr", qe).IgnoreEmpty() //商户名、责任人、地址 使用like查询 忽略空值 .Where<_Eq>("*", qe).IgnoreEmpty() //剩下的其它的字段都 使用equal查询 忽略空值 .Paging(qe, "tenant_id") //分页参数在qe中 默认按商户编码排序 自动处理页面上的排序、分页请求 .QueryManyWithPaging (); return result; } //获取单条记录 public bas_tenant GetTenant(string id) { var result = _db.Select("*") .From("bas_tenant") .Where("tenant_id",id) .QuerySingle (); return result; } //更新 public int Update(string id, bas_tenant tenant) { var result = _db.Update("bas_tenant", tenant) .AutoMap(x => x.tenant_id) .Where("tenant_id", id) .Execute(); return result; } //添加 public string Insert(bas_tenant tenant) { tenant.tenant_id = "T" + (new Random().Next(100) + 500).ToString(); var result = _db.Insert("bas_tenant", tenant) .AutoMap() .Execute(); return tenant.tenant_id; } //删除 public int Delete(string id) { var result = _db.Delete("bas_tenant") .Where("tenant_id", id) .Execute(); return result; }}
3、权限认证
在前端权限控制中我们说了,$http请求时request header中添加了身份认证的Ticket,我们要在每一次请求返回数据前都要验证这个Ticket,当然不能写在每个方法当中了,可以在过滤器中实现:public class TicketAuthorizeAttribute : AuthorizationFilterAttribute{ public override void OnAuthorization(HttpActionContext actionContext) { bool isAuthorizated = false; IPrincipal principal = Thread.CurrentPrincipal; var RequestHeader = actionContext.Request.Headers; if (RequestHeader.Contains("Ticket")) { //在这里验证Ticket string requestTicket = RequestHeader.GetValues("Ticket").First(); string serverTicket = EncryptHelper.MD5(...); if (requestTicket == serverTicket) isAuthorizated = true; } if (!isAuthorizated) actionContext.Response = actionContext.ControllerContext.Request .CreateErrorResponse(HttpStatusCode.Unauthorized, "已拒绝为此请求授权。"); }}
演示地址: (前端页面架设在8081,数据服务架设在8082)不稳定有时不能访问
前端项目 dhtmlx_web:
后端项目 dhtmlx_webapi:大家下载代码后直接打开index.html是无效的因为其中有很多的ajax请求必须架设在web服务上。 如果没有nodejs的环境,也可以用VS打开运行或架设到IIS上。
如果大家有nodejs的环境,可以运行目录下的http.bat实现上是调用nodejs的http简易服务程序,注意端口我写死了是8080,自己去修改。单独运行前端项目也能打开页面,但是没有动态数据,大家可以先运行服务端程序,运行起来后比如端口为8082,那么其数据服务地址为:
只要把这个服务端地址复制到前端项目的配置js/config/global.js中再运行前端项目就可以看到数据了。这个架子折腾了一阵子可能还有一些不完整的地方,共享出来权当给大家一个参考,如果大家有什么意见或建议可以给我留言。
如果大家感兴趣就在右下角帮我【推荐】一下吧,谢谢大家。