# 日期和多时区
协同服务器机房在中国,而全球各地都有用户,需要连到中国服务器办公,这里就存在“时区”的影响:
- 北京时间(UTC+8:2021-8-5 21:01:25)
- 伦敦时间(UTC/GMT:2021-8-5 13:01:25)
- 美国时间(UTC-5:2021-8-5 08:01:25)
不同地区的时间不一致,那么就提出一个问题:如果英国用户在2021-8-5 13:01:25发出的协同,我中国用户和美国用户打开协同应该看到的发起时间是什么时候?
显然,答案应该是看到的发起时间跟着自己地区的时间走,否则数据就会混乱。
那么如何实现不同地区发出、查看的数据能跟着自己本地的时区时间走呢?这里就涉及到“多时区”的概念。
# 时区案例
前面说了时区的概念,那么落实到具体场景:我们所谓的时区在PC电脑里是如何体现的呢?
实际我们个人电脑关于时区的体现就在我们PC日历上面,如下图所示是标准的北京时间:
而如果你处于伦敦、纽约、东京等不同地域,你可以手动调整日期显示格式到对应时区,或者选择自动设置时区:
切换时区后,你所在PC电脑上看到的日期就是对应时区的当前时间,这个不用赘述了。
# 技术如何实现
回到前面的问题,如果我一套V5协同管理系统要在不同国家地区使用,那么我如何保障一名伦敦时间用户发送的会议邀请(UTC/GMT:2021-8-5 13:01:25),在北京时区用户(UTC+8:2021-8-5 21:01:25)和美国用户(UTC-5:2021-8-5 08:01:25)打开会议时,看到的会议时间是北京、美国用户各自本地的时间?
这里就涉及一套统一的时区管理原则,开发人员在编写多时区代码的时候,一定要认真理解这个原理。
1)V5协同服务器和数据库要在同一时区下维护,所有地区的时间最终会转换为服务器的时区时间统一管理。
2)不同时区提交保存日期数据原则:对应时区的PC端传输自己时区的日期时间字符串到服务器,服务器将日期字符串通过日期转换函数+时区转换为统一的服务器日期对象,存储到数据库。
3)不同时区查看日期数据原理:对应时区的PC端向服务器发送获取日期数据的请求,服务器从数据取出日期对象,在服务器端通过日期转换函数+时区转换,将服务器时区的日期对象转换为客户端时区的日期字符串返回。
# 前提准备
多时区是一个独立的商务插件,需要向商务申请购买,更新加密狗Lic。
购买之后,还需要通过登录系统管理员->模块管理->开启“多时区”->重启一次服务器才生效。
# 标准API
1、平台的com.seeyon.ctp.util.Datetimes工具类是专门用于做日期(含时区)转换的,平台的com.seeyon.ctp.util.DateUtil也能实现相同功能,但我更推荐Datetimes(更全面)。
2、调用API就分下面几种场景:
// 字符串带时区转Date
Date date = Datetimes.parse(dateStr, pattern)
// 字符串不带时区转Date
Date date = Datetimes.parseNoTimeZone(dateStr, pattern)
// Date带时区转字符串
String str = Datetimes.format(date, pattern)
// Date不带时区转字符串
String str = Datetimes.formatNoTimeZone(date, pattern)
注:任何时候,日期的格式化都不允许使用SimpleDateFormat,要求全部使用平台的Datetimes实现!
Datetimes格式化常量:
public static final String datetimeStyle = "yyyy-MM-dd HH:mm:ss";
public static final String datetimeStyleNoSeparator = "yyyyMMddHHmmss";
public static final String dateStyle = "yyyy-MM-dd";
public static final String dateStyleWithoutYear = "MM-dd";
public static final String datetimeStartWithMonthStyle = "MM-dd HH:mm";
public static final String datetimeWithoutSecondStyle = "yyyy-MM-dd HH:mm";
public static final String datetimeAllStyle = "yyyy-MM-dd HH:mm:ss.S";
private static final String datetimeCSTStyle = "EEE MMM dd HH:mm:ss Z yyyy";
public static final String RFC822_PATTERN = "EEE', 'dd' 'MMM' 'yyyy' 'HH:mm:ss' 'Z";
# 时区编写示例
# 客户端到服务器
客户端来自不同地区,不同地区都有发协同等操作,那么同一时刻,不同时区发起的协同日期如何控制?
这里采用的是“服务器统一时间”的机制,即无论什么地区同一时刻发起的协同,发送到服务器之后通过时区工具类转换,必然会转为当前服务器的日期,这样就保证所有地区的数据存储日期一致。
从客户端传到服务器,需要使用Datetimes.parse将客户端的日期字符串转换为服务器的Date日期对象,没有直接的客户端String字符串转服务器String字符串的接口。
<!-- 前端日期数据 -->
<form>
<input name="startTime" type="text" value="2021-08-21 11:50:30"/>
</form>
// =======后端代码=======
// 获取客户端的日期字符串
String timeStr = request.getParameter("startTime");
// 通过pase方法将客户端的字符串日期转换为服务端的Date对象
Date startTime = Datetimes.parse(timeStr, Datetimes.datetimeStyle);
// 转换为服务器日期Date后,将对象存入数据库
sqlDao.save(startTime);
# 服务器到客户端
客户端来自不同地区,不同地区查看同一个协同,协同里面的发起时间要与不同时区对应,这种日期如何控制?
同样,如果你基于场景一“服务器统一时间”的规则进行开发,那么此时协同的发起时间是存入服务器数据库的。不同时区客户端向服务器发送请求获取协同的发起日期,实际是将服务器数据库中Date对象转换为客户端字符串日期的一个过程。
从服务器返回给客户端,需要使用Datetimes.format方法将服务器的Date日期对象转换为客户端的日期时间String字符串,切忌不要直接把Date日期对象返回给客户端!
// ======后端代码========
// 获取当前默认时间
Date beginDate = setHalfMinite();
Date endDate = Datetimes.addMinute(beginDate, 30);
// Bad code:如下参数如果需要返回给前端,绝对绝对不要使用Date对象传输,你服务器的日期跟客户端的日期很可能对不上
// result.put("beginTime",beginDate); // Bad code
// result.put("endTime",endDate); // Bad code
// 正确的实现如下:一定要通过Datetimes.format将日期Date对象转换为字符串输出给前端
result.put("beginTime",Datetimes.format(beginDate, Datetimes.datetimeStyle));
result.put("endTime",Datetimes.format(endDate, Datetimes.datetimeStyle));
<!-- 前端日期数据 -->
<form>
<input name="beginTime" type="text" value="${beginTime}"/>
<input name="endTime" type="text" value="${endTime}"/>
</form>
再次提醒:从后端服务器向前端传输日期数据时,请务必使用字符串传输,不要使用Date对象传输!
# 无需时区场景
那么,我们有的应用却是无需进行时区转换,客户端到服务器端传输保存都使用相同日期,则可以使用如下代码实现:
// 字符串不带时区转Date
Date date = Datetimes.parseNoTimeZone(dateStr, pattern)
// Date不带时区转字符串
String str = Datetimes.formatNoTimeZone(date, pattern)
# 时区实现原理
下面是时区的实现原理,方便后续集成接入使用。特别提醒:如果要做第三方登录到V5协同的开发,并且客户在全球办公,需要按照实现原理自行封装多时区能力。
以标准PC登录为例:
1、默认登录页(/seeyon/main/login/default/login.jsp)包装了时区的input参数,可自行查看login.jsp源代码。
其实现作用是:在点登录按钮的时候,通过Javascript调用获取客户端的时区值,将参数放到id="timezone"这个input元素中,通过form提交的形式将时区参数提交到服务器后端。
<form method="post" action="${path}/main.do?method=login" id="login_form" name="loginform" onsubmit="checkPwdStrength();">
<input id="timezone" type="hidden" name="login.timezone" value=""/>
....
</form>
<script type="text/javascript">
function loginSubmit(){//登录操作
var timeZoneId = getTimeZoneId();
$("#timezone").val(timeZoneId);
}
function getTimeZoneId(){
//获取客户端时区
var d = new Date();
var timezoneOffset = 0 - d.getTimezoneOffset();
var gmtHours = (timezoneOffset/60).toString();
//8 -8:30 8:45
var gmtHoursArr = gmtHours.split(".");
var h = gmtHoursArr[0];
if(h>=0){
h = "+"+h;
}
var m = "00";
if(gmtHoursArr.length>1){
m = Number("0."+gmtHoursArr[1]) * 60;
}
return "GMT"+h+":"+m;
}
</script>
2、服务器登录会调用loginControl.transDoLogin(request, session, response)这个接口,在加密狗中会从request中取到timezone,再将其放入User全局变量中。
transDoLogin(HttpServletRequest request){
// V8.X已经将此段代码放入加密代码中
User user = AppContext.getCurrentUser();
user.setTimeZone(TimeZone.getDefault());
String timeZone = request.getParameter("timeZone");
if(Strings.isNotBlank(timeZone)){
TimeZone tz = TimeZone.getTimeZone(timeZone);
if(null!=tz && TimeZoneUtil.isEnable()){
user.setTimeZone(tz);
}
}
}
3、Datetimes调用时区转换的实现原理,其实就是判断isTimeZoneEnable()==true(对应前面插件和开关,把时区打开),则调用getCustomerTimeZone()获取当前时区的方法。
public static Date parse(String dateStr, TimeZone timeZone, String pattern){
/** 加入时区 */
if(null == timeZone){
if(isTimeZoneEnable()){
timeZone = getCustomerTimeZone();
}else{
timeZone = TimeZone.getDefault();
}
}
}
4、层层往下,getCustomerTimeZone获取自定义时区,实际就是从当前登录人员的getTimeZone()封装中获取的值。
private static TimeZone getCustomerTimeZone() {
//时区转换以所在客户端时区为准(默认),
int transTimezoneType = TRANSTIMEZONE_BY_CUSTSET;
if(transTimezoneType==TRANSTIMEZONE_BY_PRIVSET){
return getTimeZoneByPrivSet();
}
return getTimeZoneByCustSet();
}
public static TimeZone getTimeZoneByCustSet(){
if(null != AppContext.getCurrentUser() && isEnable()){
return AppContext.getCurrentUser().getTimeZone();
}
return TimeZone.getDefault();
}
总结:如果涉及第三方登录到V5系统,并且要做时区集成的话,就要保障:
1)调用Login登录的时候,将本地时区通过?timezone=xxx传到后端
2)后端统一登录逻辑通过request.getParameter("timeZone");获取到时区变量,将其封装到User对象中
3)登录之后,User对象中一直保存有时区参数,并且保存在服务器端会话中,后续操作别的功能都不需要再传时区变量。
# 追加内容
[年-月-日 时:分:秒] 这种显示日期格式在国内是通用显示标准,但在美国、英国等国家的日期显示一般是“月/日/年”的形式。
而我们的代码又清一色使用“yyyy-MM-dd HH:mm”这种固定的日期格式,会导致国外不适用。这个是目前已知的问题,因为国外需求量极少,所以此问题未放入优先处理范围内。
如果有国外类似需求的用户,我们有二次开发建议方案:
1)基于国际化多语言的形式维护不同语言的日期显示格式 2)不使用Datetimes工具类(改用com.seeyon.ctp.report.engine.util.DateFormatUtil),或重写Datetimes工具类
代码实现原理是:通过国际化方案,给不同登录的国际化语言配置不同的日期显示格式:
# datetimes_zh_CN
datetimes.format.yyyyMMddHHmmss = yyyy-MM-dd HH:mm:ss
# datetimes_zh_TW
datetimes.format.yyyyMMddHHmmss = yyyy-MM-dd HH:mm:ss
# datetimes_en
datetimes.format.yyyyMMddHHmmss = MM/dd/yyyy HH:mm:ss
代码上依然调用工具类的format、parse方法:
public static String format(Date date, FormatType formatType) {
return format(date, formatType, null);
}
而底层实现则是根据当前登录人员所选择的登录显示语言去找对应的日期国际化显示格式:
public static String format(Date date, FormatType formatType, TimeZone timeZone, Locale... locale) {
if (formatType == null || date == null) {
return "";
}
Locale toLocale;
if (locale != null && locale.length > 0) {
toLocale = locale[0];
} else {
toLocale = AppContext.getLocale();
}
return formatters.get(formatType).format(date, toLocale, timeZone, formatType);
}
此问题的开发工作量不止在后端,前端的日期组件也需要修改,所以二次开发的时候务必要评估全面。