字数-32134

联想

最近一直在组建团队,成效却不理想,市面上散落大量的刚毕业、半路出家的,却几无可用之材。到最后基本上已经退到最低要求:

  • 一定的基础

  • 到位的理解与分析,精准的表达

  • 主动地将第二点表现出来

基础代码

再说回面试,比较多的人直接谈用过什么框架和技术,但请其例举一些在设计和代码方面的思考时,又几乎空白。框架确实解放了很多开发工作,但框架不可能解决所有开发工作,甚至一些看似简单却又重要的业务流程,或是交互流程。

下文将在业务、交互流程设计与重构上,进行思路代码的双重对比。

示例1 前端交互

1.1 交互要改进

前段时间了解的slack.com这个产品,让人耳目一新,但是国外产品针对的习惯(邮件加流式),一时难以接受,一些好的slack相关插件也由于被墙,无法真正发挥其威力,所以没有在项目或团队中推行,但是产品的交互体验确实非常棒。

日子默默过去一些。。。

前不久,web小组交出来的注册页面的交互逻辑实在是较多疏漏,效果更似demo水准。瞬间想起了slack的输入表单的效果,非常地舒服:

slack交互

果断提炼了一个交互设计:

一、基本定义

  • (1)提示效果元素定义:输入框背景色,输入框边框颜色,提示文字。但不同场景由这三种效果进行不同的组合,具体见下方的交互逻辑。

  • (2)提示文字,位于输入框下方,颜色不要用红色,太扎眼。

  • (3)提示内容上,基本上有四个:一个是为空时『提示输入内容』;二个是不为空但格式错误或长度不符『提示输入正确内容』;三个是部分表单,比如重复输入密码,可能会『提示内容不一致』;四个是『请求业务接口返回的信息』,比如手机号码已经被注册。

二、交互逻辑

  • (1)首次进入输入框,获得光标,边框加粗,没有编辑光标跳出,不需提示效果。

  • (2)进入输入框并开始编辑,3秒后无内容变化,验证内容是否合法,不合法则显示提示效果:无边框 + 黄色背景 + 浅色提示文字提示内容错误;用户又敲键盘了内容发生变化,立即清除提示效果;用户删除了所有内容,立即显示(不等3秒)提示效果:无边框 + 黄色背景 + 浅色提示文字提示输入内容。

  • (3)错误未修改,跳出,显示提示效果:带边框 + 黄色背景 + 浅色提示文字提示错误。

  • (4)用户再次进入框框,后续逻辑参考第(2)点。

具体的交互逻辑动态图如下:
交互逻辑动态图

三、其它元素
结合实际,可能还会有其它元素加到交互逻辑里,比如:

  • (1)页面上有两个按钮,第一个是获取验证码按钮,手机号码输入正确,且未被使用,则按钮变为可用,未被使用要调用业务接口的,如果业务接口返回错误,也要加提示效果,原因用文字描述;第二个按钮是注册按钮,需要手机号、验证码、密码和重复密码都通过检测,按钮才变为可用。

  • (2)有密码和确认密码两个输入框,两者其它验证都通过后,要做内容的比对,如果不一致,后编辑者输入框要有提示效果,还有提示文字。如果这时编辑另一个密码,其它验证通过,立即清除对方的效果,如果内容不一致,加到己方。

综上,交互逻辑还是有一些复杂,组合出来的效果以及效果的随事件变化,特别是第三部分的一些变量元素的加入,又增添了复杂程度。

1.2 改进效果

前端伙伴实施这个交互的过程,即验证了其复杂性,逻辑各种侧漏。一出现效果不佳,我就提示他做封装,他说这个明白了,能做出来,改一下就好了,好,我又忙到其它事了,回头随便一用,真是秒出它的问题了,到做了俩三个小规模的业务模块后,发现还是有问题,是不是没有想透这些交互设计和逻辑?最后,前端伙伴也认了,非得重构不可了。

看了一下代码,问题比较多,改变提示效果的代码散布,意味着逻辑不是一条主线,这怎么能HOLD住?大概代码随便贴一点:

input.js  
...
/*
 * 账号检测
 */
function testAccount () {
        $(".fail").text("");
        var reg = isPhone("user") || isMail("user");
        if($(".user").val()==""){
                empty($(".user"));
                $(".phone_fail").text("请输入手机号或邮箱号");
                return false;
        }else if (reg) {
                pass($(".user"));
                $(".user").attr("data-firstChange","false");
                return true;
        } else{
                wrong($(".user"));
                $(".phone_fail").text("请输入正确的手机号或邮箱号");
                return false;
        }
}

/*
 * 失去焦点
 * params jq对象
 * params 回调方法
 * params 正则判断布尔值
 */
function blurInput (ele,callback,reg) {
        clearTimeout(timer);
        if (ele.attr("data-change")=="true") {
                ele.attr("data-change","false");
                callback();
        }else{
                if (reg) {
                        ele.css("border","solid 1px #d1d1d1");
                } else if (ele.attr("data-firstChange")=="true") {
                        ele.css("border","solid 1px #ffa940");
                } else{
                        ele.css("border","solid 1px #d1d1d1");
                }
        }
}

/*
 * 密码变动
 */
function pswChangeInput () {
        $(".fail").text("");
        $("#password").attr("data-firstChange","true");
        $("#password").attr("data-change","true");
        clearTimeout(timer);
        if ($("#password").val()=="") {
                empty($("#password"));
                $(".password_fail").text("请输入密码");
        }else{
                clear($("#password"));
                $(".password_fail").text("");
                timer = setTimeout(testPassword,3000);
        }
}

/*
 * 获取焦点检测
 * params id
 * params 正则判断布尔值
 */
function validInput(id, reg){
        if(chgArray.contains(id)){
                if(reg){
                        $("#" + id).css({"border":"solid 1px #a0a0a2","background":"none"});
                }else{
                        $("#" + id).css({"border":"solid 1px transparent","background":"#fff1e1"});
                }
        }else{
                $("#" + id).css({"border":"solid 1px #a0a0a2","background":"none"});
        }
}
...

login.js

...

var chgArray = new Array();

$(".input").on('input propertychange', function () {
        var id = $(this).attr("id");
        if(!chgArray.contains(id)){
                chgArray.push(id);
        }
});


$("#user").focus(function () {
        var id = $(this).attr("id");
        var reg = isMail(id) || isPhone(id);
        validInput(id, reg);
});

$("#password").focus(function () {
        var id = $(this).attr("id");
        var reg = isPassword(id);
        validInput(id, reg);
});

/*
 * 失去焦点
 */
var     timer;
$("#user").on('input propertychange', function () {
        userChangeInput();
        testLogin();
}).blur(function () {
        var id = $(this).attr("id");
        var reg = isMail(id) || isPhone(id);
        blurInput($("#user"),testAccount,reg);
})

$("#password").on('input propertychange', function () {
        pswChangeInput();
        testLogin();
}).blur(function () {
        var id = $(this).attr("id");
        var reg = isPassword(id);
        blurInput($("#password"),testPassword,reg);
})
$(".login").click(function () {
        $(".fail").text("");
        $(this).attr("disabled","disabled");
        //TODO 下行为真实提交,ajax为模拟数据
//      $(".container").submit();
        $.ajax({
                url:"/user/account/login",
                data:$(".container").serialize(),
                type:"POST",
                async:false,
                cache:false,
                success:function (data) {
                        if (data.RES=="SUCCESS") {
                                location.href="/views/individual/home_page.html";
                        } else if (data.RES=="FAIL"){
                                $(".fail_tip").text(data.MSG);
                                $(".login").removeAttr("disabled");
                        }
                },
                error: function(XMLHttpRequest, textStatus, errorThrown){
                        $(".fail_tip").text("网络异常,请重试一次");
                        $(".login").removeAttr("disabled");
                }
        })
})
...

代码只一二,每个表单都写了一些验证方法、绑定了事件,并实现了一套交互逻辑。问题可见一斑:

  • 交互逻辑其实是一套共有的规则,完全可以抽象,不需要套用元素的个性去重复实现,这样完全不具备重用性,逻辑 一处要修改完善,处处要;

  • 表单之间除了具体数据的不同,拥有的数据项其实是相差不大,也是可以抽象数据模型的,当这套模型逐渐完善,灵活性将大增;

  • 由于表单间的业务逻辑存在繁杂的关联性,不在表单属性上下手,而是将原本模型的东西实现成逻辑和控制成份,比如在硬编码上加逻辑分支,这样处理关联性,显然是错漏难免,也无法维护出高质量高效的代码。

我是根本无法忍受这种代码,于是,花了大约1个半工作日将这套东西完整实现如下,后续只要引用公共模块代码,定义页面特有的数据,即可开发出一套带复杂验证的页面。

改进后的代码如下:

<!DOCTYPE html>
<html>

	<head>
		<meta charset="utf-8" />
		<title>TEST</title>
	</head>

	<body>
		<p>	
			你的电话:<input id="phonenum" type="text" value="" callback="isPhoneNumExists" placeholder="你的电话号" trigger="['btn_getcode', 'btn_register']"/><br />
			<span id="msg_phonenum"></span><br /> 
			
			验证号码:<input id="code" type="text" value="" placeholder="验证码" trigger="['btn_register']" /><br />
			<span id="msg_code"></span><br />
			
			<button id="btn_getcode" disabled>获取验证码</button><br />
			<br /> 
			
			你的密码:<input id="pwd" related="pwd2" type="text" value="" placeholder="你的密码" trigger="['btn_register']" /><br />			
			<span id="msg_pwd"></span><br /> 
			
			确认密码:<input id="pwd2" related="pwd" type="text" value="" placeholder="再次输入密码" trigger="['btn_register']" /><br />			
			<span id="msg_pwd2"></span>
		</p>
		<p><button id="btn_register" disabled>注册</button></p>
	</body>
	<script type="text/javascript" src="http://code.jquery.com/jquery-3.2.1.min.js"></script>
	<script type="text/javascript">		
		//array contains
		Array.prototype.contains = function(ele){
			for(var i = 0; i < this.length; i++){
				if(this[i] == ele)
					return true;
			}
			return false;
		}
		//string startwith
		String.prototype.startWith = function(s){
		    if(s == null ||s == "" || this.length == 0 || s.length > this.length)
		        return false;
		    if (this.substr(0,s.length) == s)
		        return true;
		    else
		        return false;
		
		    return true;
		}
		//=============以下是自定义的内容=============
		
		var meta_input = {
			//格式需要符合标准,
			//(1)需要显示的信息有5类,MSG_INDEX = {NotHandle: -1, None: 0, Empty: 1, Invalid:2, Different: 3, Unavailable: 4}
			//(2)用正则式验证内容		
			phonenum: {msg: ["", "请输入手机号", "手机号格式不正确","",""], reg:/^1[34578]\d{9}$/},
			code: {msg: ["", "请填入验证码","验证码填写错误,为6位数"], reg: /^\d{6}$/},
			pwd: {msg: ["", "请输入密码","密码格式正确,输入6-18位字母、数字或下划线组合","密码不一致"], reg: /^\w{6,16}$/},
			pwd2: {msg: ["", "请输入密码","密码格式正确,输入6-18位字母、数字或下划线组合","密码不一致"], reg: /^\w{6,16}$/}			 
		};			
				
		var meta_triggers = {
			//需要在表单的trigger属性中以数组的形式引用(如btn_getcode)
			btn_getcode: {//按钮对象,获取验证码				
				dom_me: $("#btn_getcode"),//DOM
				disabled: [false, true] //true表示禁用,此处可以用应用成为不同的样式(需要 改模块代码)
			},
			
			btn_register: {				
				dom_me: $("#btn_register"),//DOM
				disabled: [false, true] //true表示禁用,此处可以用改变为不同的样式
			}		
		};
		
		var isPhoneNumExists = function (_input) {
			console.log(_input.value);
			console.log("A request to be continue..");
			
			if ("18188888888" == _input.value) {
				_input.arr_msg[MSG_INDEX.Unavailable] = "手机号码已经被注册了,请使用其它手机号";
				return false;
			}
			return true;
		}
		
		//=============以下是处理交互逻辑的公共模块=============		
		var meta_input_styles = {
			//样式内容随意定义,内容格式须符合jQuery的用法
			ok_out: {"background":"none", "border":"solid 1px #d1d1d1"},//正常
			ok_in: {"background":"none", "border":"solid 1px #a0a0a2"}, //正常,边框加深
			err_out:  {"background":"#fff1e1", "border":"solid 1px #ffa940"},//有错误提示,带边框
			err_in: {"background":"#fff1e1", "border":"solid 1px transparent"} //有错误提示,无边框
		}
		
		var MSG_INDEX = {NotHandle: -1, None: 0, Empty: 1, Invalid:2, Different: 3, Unavailable: 4}
		
		//硬编码:trigger/msg_/related都是属于规范
		var timer;
		var frm_inputs = [];	
		
		$("[trigger]").each(function(){		
			var id = $(this).attr("id");
			
			var frm_input = {
				id: id,
				dom_me: $(this),//DOM
				dom_msg: $("#msg_" + id),//DOM,表单出错的文字提示信息
				reg: meta_input[id].reg,//表单校验的正则表达 式
				isok: false,//表示是否校验通过,(或要求与id_obj_related内容是否一致)
				arr_msg: meta_input[id].msg,//文字提示的内容选项
				msg_index: MSG_INDEX.NotHandle,//选择哪一项文字提示,默认未处理
				value: $(this).val(),//表单内容
				cursor: "out",//光标是否进入表单in,离开表单out,正在表单里输入change				
				id_input_related: $(this).attr("related") ? $(this).attr("related") : undefined,//与另一个表单需要内容一致
				triggers: [],//可能会触发多个按钮的变化
				callback: $(this).attr("callback") ? eval($(this).attr("callback")) : undefined,//表单验证OK,需要调用其它回调函数
			}
			
			var trigger_names = eval($(this).attr("trigger"));
			
			for (var i = 0; i < trigger_names.length; i++) {
				frm_input.triggers.push(trigger_names[i]);
			}
			
			frm_inputs.push(frm_input); 
			
			//绑定事件到表单
			$(this)
			.focus(function () {
				frm_input.cursor = "in";
				display(frm_input);
			})
			.on('input', function () {//TODO 粘贴事件未处理
				frm_input.cursor = "change";
				change(frm_input);
			})
			.blur(function () {
				frm_input.cursor = "out";
				display(frm_input);
			});
		});

		function change(_input) {
		   _input.value = _input.dom_me.val();
		   verify(_input); //验证内容,判断内容是否合法
		   display(_input);//第一时间改变状态
		   clearTimeout(timer);
		   timer = setTimeout(function(){displayWhenEdit(_input)}, 3000);//重新启动,开始计时
		}
		
		function findInputById(id){	
			for (var i = 0; i < frm_inputs.length; i++){
				_input = frm_inputs[i];
				
				if (_input.id == id) {
					return _input;
				}
			}	
		}
		
		//验证输入的内容是否合法
		function verify(_input){
						
			if (_input.value == "") {		
				_input.isok = false;
				_input.msg_index = MSG_INDEX.Empty;	//将提示输入内容
			} else {
				_input.isok = _input.reg.test(_input.value);			
				
				if (!_input.isok) {
					_input.msg_index = MSG_INDEX.Invalid;//将提示为内容错误
					
				} else {
					_input.isok = true;
					_input.msg_index = MSG_INDEX.None;
					
					if (_input.callback && (typeof _input.callback === "function")) {
						var isEnalbe = _input.callback(_input);
						
						if (!isEnalbe) {//业务接口不允许此参数
							_input.isok = false;
							_input.msg_index = MSG_INDEX.Unavailable;
						}
					}
				}
			}
			
			//其它验证通过,需要与另一个表单进行内容比对
			if (_input.isok && _input.id_input_related) {
				var _input_related = findInputById(_input.id_input_related);
				
				if (_input_related) {//对方存在
					//对方没有错误效果,或对方有内容不一致错误效果
					if (_input_related.isok || (_input_related.msg_index == MSG_INDEX.Different)) {	//且错误提示为内容不一致
						if (!_input_related.isok) {
							//清除掉对方的内容不一致的提示
							_input_related.isok = true;
							_input_related.msg_index = MSG_INDEX.None;
							display(_input_related);
						}
							
						if (_input.value != _input_related.value) {//错误效果加到己方
							_input.isok = false;	
							_input.msg_index = MSG_INDEX.Different;
						} 
					}					
				}
			}   
			refreshTrigger(_input);   
		}
		
		//输入框有变化则尝试去更新按钮状态
		function refreshTrigger(_input) { 
			var triggers = _input.triggers;
			
			for (var i = 0; i < triggers.length; i++) {//刷新此表单绑定的按钮
				var trigger = triggers[i];
				var enable = true;
				
				for (var j = 0; j < frm_inputs.length; j++) {//与其它表单联合认证
					var frm_input = frm_inputs[j];
					
					if (frm_input.triggers && frm_input.triggers.contains(trigger)) {
						enable = enable && frm_input.isok; //按钮是否可用
					}
				}
				meta_triggers[trigger].dom_me.attr("disabled", meta_triggers[trigger].disabled[enable ? 0 : 1]);//0位置值为false,即可用				
			} 	
		}
		
		//变更错误提示效果,包括输入框和文字提示
		function changeStyle(_input, style, bDisplayMsg) {
			_input.dom_me.css(style);
			_input.dom_msg.text(bDisplayMsg ? _input.arr_msg[_input.msg_index] : "");
		}
		
		//提示交互效果或有变化
		function display(_input) {
			if (_input.cursor == "in") {				
				if (_input.isok) {//无错误效果边框加粗					
					changeStyle(_input, meta_input_styles.ok_in, true);
		  		 } else {//有错误效果,保持,去边框
	  		 		if (_input.msg_index != -1) {//=-1表示第一次进来,isok且为false,不处理错误	  		 			
	  		 			changeStyle(_input, meta_input_styles.err_in, true);
	  		 		}
		  		 }
			}
		
			if (_input.cursor == "change") {
				if (_input.value == "") {
					displayWhenEdit(_input);			
				} else {		
					//正在输入,则不管是否REG,不显示错误效果
					changeStyle(_input, meta_input_styles.ok_in, false);			
				}		       
			}
		
			if (_input.cursor == "out") {
				if (_input.isok) {
					changeStyle(_input, meta_input_styles.ok_out, true);	
				} else {
					if (_input.msg_index != -1) {//第一次进来,isok初 始为false,msg_index肯定为-1,不显示错误
						changeStyle(_input, meta_input_styles.err_out, true);
					}
				}
			}
		}
		
		//编辑时可能需要特别调用的,比如:过了3秒、或删除了所有内容
		function displayWhenEdit(_input) {
			if (_input.isok) {//输入的内容没问题
				changeStyle(_input, meta_input_styles.ok_in, true);
			} else {//为空或不合法
				changeStyle(_input, meta_input_styles.err_in, true);
			}
		}
	</script>

</html>

去看示例效果

示例2:auth 2.0授权流程

流程说明

我们开发的RESTFul接口鉴权采用的是auth2.0的CODE模式,相关内容见RESTFUL风格与auth2.0

2.1 实现

我们采用是分离开发思路,后台的程序分三块:

(1)数据服务提供基础的数据处理,略偏于业务组织服务。我除了设计还负责数据服务程序的开发。
(2)业务服务程序,完全的处理和响应前端的业务请求
(3)后台服务程序提供后台管理的相关功能,本质上类似业务服务程序。

后端数据服务程序中,相关的申请授权的接口已经准备好,调用这些接口都不用授权,基本上包括:

  • /v1/oauth2/auth,获得code
  • /v1/oauth2/access_token,获得access token和refresh token
  • /v1/oauth2/refresh_token,刷新access token的时效

完整的流程图如下:
code模式完整流程图

基于这样一个流程,后端业务服务程序,之前的版本文件目录是这样的:
文件目录

考虑到后台服务程序也需要实现这样一个流程,本身业务端小哥写的这个流程的实现就复杂化了,特花了4个小时,重写实现,改造之后,只需要一个文件:RedisStorage.java,其它文件去哪了?被封装了一个公共服务类。先大概看一下之前的文件里的一些内容:

请求参数体,改造后被删除:

package xx.client.beans;

public class OauthAppInfo {

	private String appid;

	private String app_secret;

	public String getAppid() {
		return appid;
	}

	public void setAppid(String appid) {
		this.appid = appid;
	}

	public String getApp_secret() {
		return app_secret;
	}

	public void setApp_secret(String app_secret) {
		this.app_secret = app_secret;
	}
}

另一个请求参数体,改造后被删除:

package xx.client.beans;

public class OauthToken {
	
	private String access_token;
	private long expires_in;
	private String refresh_token;
	private String scope;
	private String appid;
	public String getAccess_token() {
		return access_token;
	}
	public void setAccess_token(String access_token) {
		this.access_token = access_token;
	}
	...	
}

服务实现类,即实现基本的auth授权流程,准确说这个没有体现流程,就是说没串起来,改造后是串起来的:

package com.xx.client.ws.oauth2.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import xx.client.beans.OauthToken;
import xx.client.beans.OauthAppInfo;
import xx.client.beans.SystemParams;
import xx.client.web.exception.FrameworkException;
import xx.client.ws.oauth2.ServerOauthService;
import xx.corelib.utils.GsonUtil;

@Component("serverOauthService")
public class ServerOauthServiceImpl implements ServerOauthService{
	
	@Autowired
	SystemParams systemParams;
	
	@Autowired
	private OauthAppInfo oauthAppInfo;
	
	@Autowired
	private RestTemplate restTemplate;

	@Override
	public OauthToken accessToken() {
		OauthToken oauthToken = null;
		try{
			//第一步 获取code;
			StringBuffer urlBuffer = new StringBuffer();
			urlBuffer.append(systemParams.getServerHost())
				.append("/v1/oauth2/auth/?appid=").append(oauthAppInfo.getAppid());
			String jsonStr = restTemplate.getForObject(urlBuffer.toString(),	String.class);
			String code = (String)GsonUtil.getStringPropByJsonStr(jsonStr, "code");
			
			//第二步获取access_token
			urlBuffer = new StringBuffer();
			urlBuffer.append(systemParams.getServerHost()).append("/v1/oauth2/access_token/?appid=")
				.append(oauthAppInfo.getAppid()).append("&appSecret=").append(oauthAppInfo.getApp_secret())
				.append("&code=").append(code);
			
			jsonStr = restTemplate.getForObject(urlBuffer.toString(), String.class);
			oauthToken = GsonUtil.jsonStr2Bean(jsonStr, OauthToken.class);
		}catch(RestClientException e){
			FrameworkException fe = new FrameworkException(this.getClass().getName(), "auto");
			fe.addErrorMessage("server.service.net.error");
			fe.addErrorMessage(e.getMessage());
			fe.printStackTrace();
			throw fe;
		}catch (Exception e) {
			e.printStackTrace();
		}
		return oauthToken;
	}

	@Override
	public OauthToken refreshToken(String refreshToken) {
		OauthToken oauthToken = null;
		try{
			StringBuffer urlBuf = new StringBuffer();
			urlBuf.append(systemParams.getServerHost()).append("/v1/oauth2/refresh_token/?appid=")
				.append(oauthAppInfo.getAppid()).append("&refresh_token=").append(refreshToken);
			String jsonStr = restTemplate.getForObject(urlBuf.toString(), String.class);
			oauthToken = GsonUtil.jsonStr2Bean(jsonStr, OauthToken.class);
		}catch(RestClientException e){
			FrameworkException fe = new FrameworkException(this.getClass().getName(), "auto");
			fe.addErrorMessage("server.service.net.error");
			fe.addErrorMessage(e.getMessage());
			fe.printStackTrace();
			throw fe;
		}catch (Exception e) {
			e.printStackTrace();
		}
		return oauthToken;
	}

}

调度类,本身这个refresToken方法的逻辑就没写清楚,改造后被删除:

package xx.client.task;

import javax.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import xx.beans.OauthToken;
import xx.constant.SysConstant;
import xx.ws.oauth2.ServerOauthService;
import xx.corelib.redis.JedisManager;


@Component("oauthTask")
public class OauthTask {
	
	@Autowired
	ServerOauthService serverOauthService;
	
	@Autowired
	JedisManager jedisManager;
	
	/**
	 * 每隔1小时刷新accessToken的有效期 
	 */
	@Scheduled(cron = "0 0 */1 * * ?") //秒 分 时 天 月 周 年
	public void refresToken(){
		String refreshToken = jedisManager.get(SysConstant.REDIS_OAUTH_REFRESH_TOKEN);
		if(null != refreshToken){ 
			//若缓存中存在refresh_token,则刷新access_token
			try{
				OauthToken oauthToken = serverOauthService.refreshToken(refreshToken);
				setTokenCache(oauthToken);
			}catch(Exception e){
				//若刷新token时出现异常则重新获取token,例如服务器refresh_token已失效
				initToken();
			}
		}
		else if(null == refreshToken){
			//若缓存中已不存在refreshToken,则重新获取token
			OauthToken oauthToken = serverOauthService.accessToken();
			setTokenCache(oauthToken);
		}
	}
	
	
	
	/**
	 * 启动项目时先执行一次
	 * 
	 * */
	@PostConstruct   //所有依赖注入都完成后直接执行一次
	public void initToken(){
		OauthToken oauthToken = serverOauthService.accessToken();
		setTokenCache(oauthToken);
	}
	
	/**
	 * 将获取的access_token和refresh_token都缓存到redis
	 * @param oauthToken
	 */
	private void setTokenCache(OauthToken oauthToken){
		jedisManager.set(SysConstant.REDIS_OAUTH_ACCESS_TOKEN, oauthToken.getAccess_token(), JedisManager.TWO_HOUR);
		// refresh最多保存30天
		int refreshExpireTime = 30 * 24 * 60 * 60;
		jedisManager.set(SysConstant.REDIS_OAUTH_REFRESH_TOKEN, oauthToken.getRefresh_token(), refreshExpireTime);
	}
}

改造前的base service中的方法,通过它去构造RESTful请求的url,Access token是放在url的参数中的, 这些获取access token/refresh_token的方法很明显欠缺封装性,auth service的方法仅提供网络访问 而不开放接口有点不科学,.refreshToken/setTokenCache等方法在这里体现,很明显就是把流程控制的 东西散布在外面了,这样的代码很难维护:

/**
 * 构造访问数据服务url
 * @param url
 * @return
 */
public String buildUrl(String url){
	String access_token = jedisManager.get(SysConstant.REDIS_OAUTH_ACCESS_TOKEN);
	String refresh_token = jedisManager.get(SysConstant.REDIS_OAUTH_REFRESH_TOKEN);
	
	if(null == access_token){
		if(null != refresh_token){
			//若缓存中存在refresh_token,则刷新access_token
			try{
				OauthToken oauthToken = serverOauthService.refreshToken(refresh_token);
				setTokenCache(oauthToken);
			}catch(Exception e){
				//若刷新token时出现异常则重新获取token,例如服务器refresh_token已失效
				initToken();
			}
		}
		else if(null == refresh_token){
			//若缓存中已不存在refreshToken,则重新获取token
			OauthToken oauthToken = serverOauthService.refreshToken(refresh_token);
			setTokenCache(oauthToken);
		}
	}
	
	String appid = oauthAppInfo.getAppid();
	if(url.contains("?")){
		StringBuffer urlBuffer = new StringBuffer();
		urlBuffer.append(systemParams.getServerHost()).append(url)
			.append("&appid=").append(appid).append("&access_token=").append(access_token);
		return urlBuffer.toString();
	}else{
		StringBuffer urlBuffer = new StringBuffer();
		urlBuffer.append(systemParams.getServerHost()).append(url)
			.append("?appid=").append(appid).append("&access_token=").append(access_token);
		return urlBuffer.toString();
	}
}

改造后的调用就非常简洁了,只暴露一个getAccessToken出来,内部干嘛完全不用管:

public String buildUrl(String url){
	String servUrl = null;
	try{
		String access_token = authService.getAccessToken();
		String appid = systemParams.getAppid();		
		
		servUrl = new StringBuilder(systemParams.getServerHost()).append(url)
			.append(url.contains("?") ? "&appid=" : "?appid=").append(appid).append("&access_token=").append(access_token).toString();		
	}catch(IOException ioException){
		ioException.printStackTrace();
	}
	return servUrl;
}

下一章节看一下改造之后中的内容。

2.2 实现改进

很简单,封装一个基础服务类AuthService,构造服务对象时,需要传入一个缓存的实现,一个基本的配置(APPID/APP SECRET/AUTH URL),接下来就是过程式编写了:

  • 缓存中没有access token则去找refresh token,后者也无,则走一整个申请流程,这个过程外部不用管,通过getAccessToken暴露给外部;
  • 如果外部使用access token说过了有效期了,调用一下refreshAccessToken,具体的如果refresh token过期了也会重走申请流程,这些也是黑盒。

总之,这样流程实现就比较完整,外部使用的就只有两个方法getAccessToken/refreshAccessToken,由于我并不知道access token会过期(不会每次去判断),因此必须要调用者来触发refreshAccessToken的调用。

package xx.corelib.auth;

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

import org.apache.commons.lang.StringUtils;

import com.google.gson.JsonObject;
import xx.corelib.comm.CoreCommConst;
import xx.corelib.comm.ErrNo;
import xx.corelib.exception.xxException;
import xx.corelib.utils.GsonUtil;
import xx.corelib.utils.PropertiesUtil;

public class AuthService {	
	private IAuthStorage storage;
	String BASE_URL;
	String APP_ID;
	String APP_SECRET;	
	PropertiesUtil prop;
	
	public AuthService(IAuthStorage storage, String classPathFile) throws IOException {
		super();		
		this.storage = storage;
		prop = PropertiesUtil.getInstance(classPathFile);
		BASE_URL = prop.getProperty(CoreCommConst.PROPERTY_KEY_AUTH_URL);
		APP_ID = prop.getProperty(CoreCommConst.PROPERTY_KEY_APPID);
		APP_SECRET = prop.getProperty(CoreCommConst.PROPERTY_KEY_APPSECRET);
	}
	
	/**
	 * 从存储里拿access token,如果不存在则通过刷新的方式尝试去获得
	 * @return access token 可能可期已无效
	 * @throws IOException
	 */
	public  String getAccessToken() throws IOException {
		String s_access_token = storage.getAccessToken();
		
		if (!StringUtils.isEmpty(s_access_token)) {//缓存有,直接拿
			return s_access_token;
		}
		return refreshAccessToken();		
	}
	/**
	 * 通过刷新的方式获得有效的access token,如果用于刷新的refresh token不存在或者失效,会重跑申请流程
	 * @return access token 可能可期已无效
	 * @throws IOException
	 */
	public String refreshAccessToken() throws IOException {
		//首先判断存储方案有无
		String s_refresh_token = storage.getRefreshToken();
		String access_token = null;
		
		if (StringUtils.isEmpty(s_refresh_token)) {//缓存还有refresh token,调用接口刷新access_token
			//申请
			access_token = applyAuth();
		} else {
			//可能报refresh token过期,decodeHttpJson()里会调用auth()跑申请流程
			access_token = decodeHttpJson(new StringBuilder("/refresh_token?appid=").append(APP_ID).append("&refresh_token=")
					.append(s_refresh_token).toString(), "refresh_token");
		}
		return access_token;
	}
	
	/**
	 * 请求授权流程:app id->get code->app id/app secret/app id->get access_token/refresh token
	 * @return 返回有效的access token
	 * @throws IOException
	 */
	private String applyAuth(){
		String code = decodeHttpJson("/auth?appid=" + APP_ID, "code");	
		String access_token = decodeHttpJson(new StringBuilder("/access_token?appid=").append(APP_ID).append("&appsecret=")
				.append(APP_SECRET).append("&code=").append(code).toString(), "access_token");
		return access_token;
	}

	/**
	 * 主要功能:
	 * (1)解包http json内容,合法的返回示例如下:
 	{
	    "access_token": "ae6wuyaycwwgfumgedwccs7okpnkzeckxdqu5rhlirk8n3llz8ama2agqjd8hymjggbltxe0uotzujse9fmwyfhj", 
	    "expires_in": 7200, 
	    "refresh_token": "cxusc1kqefjl0k2mzropsbzwvipwcvur1xqoff1c4ey3isk8ledqedzurnofezedmndijgserzln5czeaxbz9uz9", 
	    "scope": "snsapi_base", 
	    "appid": "wx897ade81e8f7a793", 
	    "errcode": 0
	}
	 * (2)或,执行存储策略存储access token/refresh token
	 * (3)返回有效的access token
	 * 
	 * 处理HTTP状态字为500以下的RESPONSE数据
	 * 
	 * @param url 接口地址
	 * @param jsonFieldName 返回指定的JSON field
	 * @return
	 * @throws IOException 
	 */
	private String decodeHttpJson(String url, String jsonFieldName) {	
		String respContent = null;
		
		String ret;
		respContent = sendHttpReq(url);
		JsonObject jo = GsonUtil.str2JsonObject(respContent);		
		
		if (jo.has("errno")) {//出错了	
			int errno = jo.get("errno").getAsInt();
			
			switch (errno) {							
				case ErrNo.EXPIRED_REFRESH_TOKEN:
					return applyAuth();//刷新access token失败(错误的或失效的),重跑授权流程
				default:
					break;
			}				
		}
		
		if (!jo.has(jsonFieldName)) {
			throw new xxException(CoreCommConst.RESP_STATUS_INTERNAL_SERVER_ERROR 
					, ErrNo.RES_AUTH_JSON_FIELD_NOT_FOUND, String.format("授权服务接口返回的JSON数据缺少字项:%s", jsonFieldName));
		}
		ret = jo.get(jsonFieldName).getAsString();
		
		if ("access_token".equals(jsonFieldName)) {				
			storage.setAccessToken(ret);
			storage.setRefreshToken(jo.get("refresh_token").getAsString());
		} else if ("refresh_token".equals(jsonFieldName)){
			//情况有点特殊,非返回field字段的值,偷换返回access token的值
			ret = jo.get("access_token").getAsString();
			storage.setAccessToken(ret);//仅缓存access_token
		}
		return ret;		
	}

	private String sendHttpReq(String suffixUrl) {
		URL url = null;
        HttpURLConnection urlConn = null;
        InputStream is = null;
        int code = 0;
        
        try {
            url = new URL(BASE_URL + suffixUrl);
            urlConn = (HttpURLConnection) url.openConnection();
            urlConn.setDoOutput(true);
            urlConn.setRequestMethod("GET"); 
            urlConn.connect();   
            code = urlConn.getResponseCode();
            is = urlConn.getInputStream();
            
            if (code < 400) {
            	byte[] bs = new byte[is.available()];
                is.read(bs);
                is.close();
                return new String(bs);
            }       
        } catch (IOException e) {  
        	//HTTP状态字UNAUTHORIZED,需要作恢复处理,(1)授权服务类需要重新跑申请流程(2)或业务服务类需要调用授权服务类刷新(非获取)ACCESS TOKEN
        	if (code == CoreCommConst.RESP_STATUS_UNAUTHORIZED) { 
        		is = urlConn.getErrorStream();
        		
        		try {
        			byte[] bs = new byte[is.available()];
                    is.read(bs);
                    is.close();
                    return new String(bs);
				} catch (IOException ioe) {					
				}        		
        	}   
        	throw new xxException(String.format("调用授权服务失败,服务返回的错误详情: %s", e.getMessage()), e); 
        } finally{
        	if (is != null) {
        		try {
					is.close();
				} catch (IOException e) {					
				}
        	}
            if (urlConn != null) {
                urlConn.disconnect();
            }
        }
        throw new xxException(String.format("调用授权服务失败,服务返回的状态HTTP状态字为: %s", code));
	}

	//测试

	public static void main(String[] args) throws IOException {
		IAuthStorage store = new IAuthStorage() {
			String access_token;
			String refresh_token;
	
			@Override
			public void setAccessToken(String new_access_token) {
				//this.access_token = new_access_token;
			}
	
			@Override
			public String getAccessToken() {
				return access_token;
			}
	
			@Override
			public void setRefreshToken(String new_refresh_token) {
				refresh_token = new_refresh_token;
			}
	
			@Override
			public String getRefreshToken() {
				return refresh_token;
			}
			
			
		};
		AuthService auth = new AuthService(store, "properties");
		System.out.println(auth.getAccessToken());
		System.out.println(auth.refreshAccessToken());
	}

}

缓存的实现,只要实现以下接口就好了:

package xx.corelib.auth;

public interface IAuthStorage {	
	public void setAccessToken(String new_access_token);
	public String getAccessToken();
	public void setRefreshToken(String new_refresh_token);
	public String getRefreshToken();
}

比如:

package xx.client.serv.auth;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import xx.client.constant.SysConstant;
import xx.corelib.auth.IAuthStorage;
import xx.corelib.redis.JedisManager;


@Component("redisStorage")
public class RedisStorage implements IAuthStorage  {
	
	@Autowired
	JedisManager jedisManager;

	@Override
	public void setAccessToken(String new_access_token) {
		//access_token保存1小时50分钟,服务器有效时间是2小时,这样避免本地缓存的access_token是无效的
		jedisManager.set(SysConstant.REDIS_OAUTH_ACCESS_TOKEN, new_access_token, 90 * 60);
	}

	@Override
	public String getAccessToken() {
		String token = jedisManager.get(SysConstant.REDIS_OAUTH_ACCESS_TOKEN);
		return token;
	}

	@Override
	public void setRefreshToken(String new_refresh_token) {
		//refresh_token保存29天,服务器有效时间是30天,这样避免本地缓存的new_refresh_token是无效的
		jedisManager.set(SysConstant.REDIS_OAUTH_REFRESH_TOKEN, new_refresh_token, 29 * 60 * 60);
	}

	@Override
	public String getRefreshToken() {
		String refresh_token = jedisManager.get(SysConstant.REDIS_OAUTH_REFRESH_TOKEN);
		return refresh_token;
	}
	
}

由于利用了缓存的失效机制,定时器也免了,服务对象一经被调用,一发现缓存中无内容就会发起授权申请的,只是说某一个请求的过程会多等一下下。

2.3 后记

这次的改造给出的启示就是,服务类的职责定位如果不清晰,所谓的面向接口与对象、面向服务的编程便难以实现。