《AngularJS深度剖析与最佳实践》一1.6 实现AOP功能

简介:

本节书摘来自华章出版社《AngularJS深度剖析与最佳实践》一书中的第1章,第1.6节,作者 雪狼 破狼 彭洪伟,更多章节内容可以访问云栖社区“华章计算机”公众号查看

1.6 实现AOP功能

至此,实现路由页面时用到的技术我们已经基本示范过了,接下来我们将开始实现一些高级功能。这些功能具有全局性的影响—基本上每个路由都会涉及它,如果我们把它嵌入到每个路由的实现里,那么代码中将出现大量的重复,编写和维护将会变成噩梦。这类功能,我们称其为“AOP功能”,也就是“面向切面功能”(形象点说:路由是竖着并列在一起的,AOP功能则像一个平台一样支撑着它们)。最典型的就是“登录”和“错误处理”。接下来,我们先来实现“登录”。

1.6.1 实现登录功能

传统的登录过程是这样的:
浏览器访问一个网址。
服务器判断这个网址是否需要登录才能访问。
如果需要,则给浏览器回复一个Redirect头。
Redirect的地址是登录页,地址中还带有一个登录成功后的回调地址。
浏览器把登录页显示给用户,用户输入有效的用户名密码之后提交到服务器。
服务器检查用户名密码是否有效,如果有效则给浏览器回复Redirect头来跳转到回调地址。
这样用户就完成了登录。
单页面应用(SPA)下固然也可以用传统方式进行登录,但是在Ajax的支持下,还有更为友好的登录方式,这也是一些大型网站中常用的方式。
实现Ajax登录的核心思想是:只需要保护后端API就够了,通常不用保护前端URL。大致的登录过程如下:
前端发起一个请求。
服务端判断这个网址是否需要登录才能访问。
如果需要,则给前端发回一个状态码401(未认证身份,即:未登录)。
前端收到401状态码,则弹出一个对话框,提示用户输入用户名和密码。
用户输入用户名和密码之后提交。
前端发起一个登录请求,并等待登录成功。
登录成功后,前端重新发送刚才被拒绝的请求。
这种方式的优点如下:
把控制逻辑完全交给前端,后端只要提供“纯业务API”就够了,这样前后端的分工非常明确。
完全在当前页面中执行,不用多次加载页面,用户操作非常顺畅。
在Promise机制的支持下,登录过程对前端的应用逻辑可以是完全透明的(调用API的代码不需要区分中间是否发生过登录,也不需要对此做任何处理)。
原理清楚了,接下来我们就来考虑代码实现。
在Angular中有一种机制叫作拦截器(interceptor),它是$http的一个扩展点,它类似于后端框架中的过滤器(filter)机制。它会对每个$http请求的发送和接收过程进行过滤,由于$http是Angular中的底层服务,所以基于它的$resource等服务也同样会受到影响。
在Angular的官方文档中给出的拦截器范例代码如下所示:

// 把拦截器注册为一个factory型服务
angular.module('com.ngnice.app').factory('myHttpInterceptor', function($q, dependency1, dependency2) {
    return {
        // 发起请求之前的拦截函数。可选。
        'request': function(config) {
            // config就是执行$http时的参数,如 {url: '...', method: 'get', ...}
            // 可以返回原config、新config、新Promise或通过返回$q.reject(rejection)来阻止发起请求
            return config;
        },
        // 请求被其他拦截器阻止时的拦截函数。可选。
        'requestError': function(rejection) {
            // 如果这个错误是可以补救的,则返回一个响应对象或新的Promise,稍后会详细讲解这种机制
            if (canRecover(rejection)) {
                return responseOrNewPromise
            }
            return $q.reject(rejection);
        },
        // 收到服务器给出的成功回应时的拦截函数。可选。
        'response': function(response) {
            // 成功时可以直接返回响应对象,也可以返回一个新的Promise。
            return response;
        },
        // 收到服务器给出的失败回应时的拦截函数。可选。
        'responseError': function(rejection) {
            // 如果这个错误是可以补救的,则返回一个响应对象或新的Promise。
            if (canRecover(rejection)) {
                return responseOrNewPromise
            }
            return $q.reject(rejection);
        }
    };
});
// 把这个服务追加到$httpProvider.interceptors中,以便$http给它们进行拦截的机会。
$httpProvider.interceptors.push('myHttpInterceptor');
// 也可以不定义服务,直接把一个函数push进去。建议不要用这种写法,灵活性差,而且容易干扰前端工具链的依赖注入处理。
$httpProvider.interceptors.push(function($q, dependency1, dependency2) {
    return {
        'request': function(config) {
            // same as above
        },
        'response': function(response) {
            // same as above
        }
    };
});

这段代码其实很简单:先声明一个对象,这个对象有四个拦截函数,它们分别在四个不同的情况下被调用,让你有机会进行处理,然后把这个对象注册进$http中即可。
这段代码的难点在于这里有两个很重要的新概念:Provider和Promise。
我们先不用深究Provider的实现原理,第2章“概念介绍”中会讲到,这里我们只需要了解它的用途是对特定的服务进行配置,比如:$httpProvider就是对$http服务进行配置的,$locationProvider服务就是对$location服务进行配置的。这里的interceptors就是$http的一种配置项。
Promise则要难一些,简单地说,Promise就是一个承诺,比如,我们调用$http时的代码:

$http.get('/api/readers').then(function(data) {
    ...
});

这里的$http.get函数返回的对象就是一个Promise(承诺)表示“我现在先给你一个承诺,我现在不能给你答案,但是一旦条件具备了我就会履行这个承诺”。这个Promise有一个成员函数叫then。我们可以把一个回调函数传给then函数,它会把这个回调函数保存在Promise对象中。当$http.get获得了从后端返回的数据时,我们传进去的这个回调函数就会被执行,并且把来自服务器的响应对象传给我们的回调函数。
Promise在Angular中是个非常重要而且非常有用的概念,在实际项目中会大量用到。如果仍然没有理解,可以翻到第2章“概念介绍”中通过生活中的一个例子了解其基本概念再往下读。
接下来,我们回到主题,看看拦截器的工作原理。
由于Angular中$http和interceptors部分的代码涉及Promise的高级用法,读起来有点绕。这里用最直观的伪代码来表明其工作原理:

// 演示两个interceptor时的工作原理
var interceptors = [interceptor1, interceptor2];
var $http = function(config) {
    // request阶段,先注册的先执行
    var requestPromise1 = interceptor1.request(config);
    // 第一个interceptor.request的结果作为第二个interceptor.request的参数,以此类推
    requestPromise1.then(function(config1) {
        var requestPromise2 = interceptor2.request(config1);
        requestPromise2.then(function(config2) {
            // 最后一个interceptor.request的结果将被实际发送出去
            sendAjaxRequest(config2, function successHandler(response) {
                // response阶段,后注册的先执行
                var responsePromise2 = interceptor2.response(response);
                responsePromise2.then(function(response2) {
                    var responsePromise1 = interceptor1.response(response2);
                    responsePromise1.then(function(response1) {
                        // 此处触发$http(config).then的第一个回调函数,并且把response1传过去
                    });
                });
            }, function errorHandler(rejection) {
                // responseError阶段,后注册的先执行
                var responseErrorPromise2 = interceptor2.responseError(rejection);
                responseErrorPromise2.then(function(rejection2) {
                    var responseErrorPromise1 = interceptor1.responseError(rejection2);
                    responseErrorPromise1.then(function(rejection1) {
                        // 此处触发$http(config).then的第二个回调函数,并且把rejection1传过去
                    });
                });
            });
        });
    });
}

相比Angular中的代码,这段代码臃肿不堪,而且只能支持两个拦截器,这是因为没有充分发挥Promise的威力。读者可以自己尝试用Promise来实现支持无限个拦截器,然后在Angular源码中搜索var chain = [serverRequest, undefined];,并与其对照,来学习Promise的高级用法。
我们要实现登录对话框,那么该怎么写拦截器呢?
请看代码:

angular.module('com.ngnice.app').factory('AuthHandler', function AuthHandlerFactory($q) {
    return {
        responseError: function(rejection) {
            // 如果服务器返回了401 unauthorized,那么就表示需要登录
            if (rejection.status === 401) {
                // “未登录”是一个可补救的错误,但我们先不补救,直接弹出一个对话框
                alert('需要登录');
                return $q.reject(rejection);
            } else {
                // 其他错误不用管,留给其他interceptor去处理
                return $q.reject(rejection);
            }
        }
    };
});

这是一个最简单的登录处理器,它不会弹出登录对话框,让用户登录,来试图“补救”这个错误,而是直接弹出一个对话框,然后继续原有逻辑。
接下来,就是重头戏了,我们要“补救”它!弹出一个输入框,提示用户输入密码,然后我们把固定的用户名(xuelang)和用户输入的密码发给服务器。

angular.module('com.ngnice.app').factory('AuthHandler', function AuthHandlerFactory ($q, $injector) {
    return {
        responseError: function (rejection) {
            // 如果服务器返回了401 unauthorized,那么就表示需要登录
            if (rejection.status === 401) {
                var password = prompt('请输入密码:');
                if (password) {
                    var Login = $injector.get('Login');
                    var $http = $injector.get('$http');
                    // 尝试登录
                    return Login.save({
                        username: 'xuelang',
                        password: password
                    }).$promise.then(function () {
                        // 登录成功了,就补救:重新发送刚才的请求,返回一个新的Promise,当这个Promise完成时,原有的then回调函数会被执行
                        return $http(rejection.config);
                    });
                } else {
                    return $q.reject(rejection);
                }
            } else {
                // 其他的错误,留给其他的拦截器来处理
                return $q.reject(rejection);
            }
        }
    };
});

当实现了这段代码之后,整个执行过程是这样的:
前端调用需要登录的api:$http.get('/api/readers').then(ajaxCallback);。
服务器返回401(未认证)。
触发AuthHandler的responseError函数。
弹出prompt对话框,要求输入密码。
发送登录请求,传入用户名和密码。
登录完成后,重新发送刚才的$http.get('/api/readers')请求,包含所有参数。
把这个请求作为新的Promise,当它完成时,会触发刚才注册的ajaxCallback函数。
对于/api/readers的调用者来说,登录过程是完全“透明”的,它不用对登录过程做任何特殊处理—不用管401,不用管重发。它的控制逻辑和不需要登录的API是完全一样的。
这种编程风格称为AOP—面向切面编程(Aspect-Oriented Programming)。它的优点是“透明性”,专业的说法是“无侵入性”,也就是实现具体业务的代码不需要做任何改写。非常适合实现登录、错误处理、日志等与具体业务无关的通用类功能。
这样一来,开发具体业务逻辑的程序员就不需要关注这些了,而是由一两个程序员来专注处理这些通用功能。分工更加专业,沟通需求和耦合程度更低,开发组织的结构更加优化。

1.6.2 实现对话框

我们刚才通过系统的Prompt API实现了登录功能,但显然,它还不能用于产品级别:不能填写用户名、密码是明文、UI无法定制。
我们需要一个可以完全由自己控制的登录对话框。不过,我们不用自己实现它—第三方组件库angular-bootstrap中就有一个。我们直接借用它就行了。
实现代码如下。

(1)JavaScript
// 使用独立controller,以便编写单元测试和复用
angular.module('com.ngnice.app').controller('UiPromptCtrl', function UiPromptCtrl($scope) {
    var vm = this;
    vm.submit = function () {
        // 有输入时才关闭
        if (vm.result) {
            $scope.$close(vm.result);
        }
    };
});
angular.module('com.ngnice.app').service('ui', function Ui($modal, $rootScope) {
    this.prompt = function (message, defaultValue, title, secret) {
        // 想把参数传到界面中,就要通过一个scope传进去,我们这里通过给$new()传一个true参数,来创建一个独立作用域
        var scope = $rootScope.$new(true);
        scope.title = title;
        scope.message = message;
        scope.secret = secret;
        // 打开对话框
        var modal = $modal.open({
            templateUrl: 'services/ui/prompt.html',
            controller: 'UiPromptCtrl as vm',
            // 指定对话框大小
            size: 'sm',
            scope: scope
        });
        // 返回一个监听对话框是否被关闭或取消的Promise
        return modal.result;
    };
    this.promptPassword = function (message, defaultValue, title) {
        return this.prompt(message, defaultValue, title, true);
    };
});
(2)模板
<div class="modal-header">
    <button type="button" class="close" ng-click="$dismiss()"><span aria-hidden="true">&times;</span><span
        class="sr-only">关闭</span></button>
    <h2 class="modal-title">{{title}}</h2>
</div>
<div class="modal-body">
    <form ng-submit="vm.submit()">
        <label>
            {{message}}
        </label>
        <input type="text" ng-if="!secret" class="form-control" ng-model="vm.result"/>
        <input type="password" ng-if="secret" class="form-control" ng-model="vm.result"/>
    </form>
</div>
<div class="modal-footer" autofocus>
    <button type="button" class="btn btn-primary" ng-click="vm.submit()">确定</button>
    <button type="button" class="btn btn-default" ng-click="$dismiss()">取消</button>
</div>

使用代码如下:

ui.promptPassword('这是一个测试' /*提示信息*/, 'abc'/*默认值*/, '提问'/*标题*/).then(function(value) {
    console.log(value);
});

angular-bootstrap的$modal函数已经封装了大部分逻辑,我们只是在它的基础上来定制一些特定用途的对话框,提高表意性,并去除冗余代码。
$modal的接口文档可以参见其官方文档,这虽然是英文的,不过都很浅显,而且还有范例。
这里面需要注意的有几点。
控制器不要内联在$modal调用中,如$modal.open({...,controller: function() {});的形式是不提倡的,这将带来两大缺点:
这种控制器无法在外部引用到,因此难以被单元测试或被复用。
部分minify工具无法识别它,导致控制器中注入的变量被重命名而注入失败。
modal.open的结果是一个对象,包括四个成员:
opened:一个Promise,当对话框成功打开时触发then函数。
result:一个Promise,当对话框打开后再被关闭时触发then函数,这通常表示用户点击了“确定”等按钮。
close:一个函数,用于通过程序关闭对话框,触发then函数。
dismiss:一个函数,用于通过程序关闭对话框,触发catch函数或then的第二个回调函数。
vm的使用,这里是为了避免原型链继承问题,这个问题比较复杂,请参见第4章“最佳实践”中的4.9节“使用controller as vm方式”。
像prompt这类函数,通常只要关心最终结果就行了,所以没必要响应opened事件。所以这里做了一个简单的封装,使其直接返回result这个Promise。

1.6.3 实现错误处理功能

用户的任何操作都可能产生错误,如果把错误处理代码分散的到处都是,代码就会显得很“脏”。怎么解决这个问题呢?本节中我们就来分析并给出解决方案。
错误分成两大类:一种是用户操作违反了业务规则导致的错误,我们称之为用户错误,例如我们要求密码至少有六位长,而用户输入了3位就提交了;另一种是我们自己程序本身的缺陷导致的错误,我们称之为系统错误,比如数据库连接中断。用户错误是意料之中的,我们知道用户可能出错,而系统错误是意料之外的,我们可能在设计之初就没有想到它会中断,或者认为它是小概率事件而暂时未加防范。
对用户错误的处理,我们已经在1.4.5节“添加验证器”等节中示范过,不再讲解,本节我们主要处理系统错误。
处理系统错误的原理和实现登录功能一样,都是基于Interceptor机制,区别在于登录功能处理的是401错误,而本节处理的是所有其他错误(4xx、5xx等)。代码如下:

angular.module('com.ngnice.app').factory('myHttpInterceptor', function($q, dependency1, dependency2) {
    return {
        // 收到服务器给出的失败回应时的拦截函数。可选。
       'responseError': function(rejection) {
            // 0表示用户取消了Ajax请求,这通常出现在用户刷新当前页面的时候,不用管它
            if (rejection.status === 0) {
                return $q.reject(rejection);
            }
            // 401表示用户需要登录,我们在另一个interceptor中处理了它,这里忽略
            if (rejection.status === 401) {
                return $q.reject(rejection);
            }
            // 显示信息
            alert(rejection.data);
            return $q.reject(rejection);
        }
    };
});

这里不管三七二十一,把服务端返回的错误信息直接弹对话框显示出来。为了对用户更加友好,我们需要对status进行区分,翻译成更加友好的提示信息。另外,需要进一步改造对话框的外观,这里不再展开,读者可以参照前面的prompt函数来自己实现。

相关文章
|
设计模式 容器
AngularJS实战之依赖注入与应用实践
AngularJS实战之依赖注入与应用实践
AngularJS实战之依赖注入与应用实践
|
安全 前端开发 测试技术
《AngularJS深度剖析与最佳实践》一导读
现在,软件不但运行在PC上,还要运行在智能手机上,运行在各种Pad上,屏幕分辨率更是多到让研发和测试工程师发怵的地步。
1401 0