字数-32134
联想
最近一直在组建团队,成效却不理想,市面上散落大量的刚毕业、半路出家的,却几无可用之材。到最后基本上已经退到最低要求:
-
一定的基础
-
到位的理解与分析,精准的表达
-
主动地将第二点表现出来
基础代码
再说回面试,比较多的人直接谈用过什么框架和技术,但请其例举一些在设计和代码方面的思考时,又几乎空白。框架确实解放了很多开发工作,但框架不可能解决所有开发工作,甚至一些看似简单却又重要的业务流程,或是交互流程。
下文将在业务、交互流程设计与重构上,进行思路和代码的双重对比。
示例1 前端交互
1.1 交互要改进
前段时间了解的slack.com这个产品,让人耳目一新,但是国外产品针对的习惯(邮件加流式),一时难以接受,一些好的slack相关插件也由于被墙,无法真正发挥其威力,所以没有在项目或团队中推行,但是产品的交互体验确实非常棒。
日子默默过去一些。。。
前不久,web小组交出来的注册页面的交互逻辑实在是较多疏漏,效果更似demo水准。瞬间想起了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的时效
完整的流程图如下:
基于这样一个流程,后端业务服务程序,之前的版本文件目录是这样的:
考虑到后台服务程序也需要实现这样一个流程,本身业务端小哥写的这个流程的实现就复杂化了,特花了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 后记
这次的改造给出的启示就是,服务类的职责定位如果不清晰,所谓的面向接口与对象、面向服务的编程便难以实现。