描述

这是一个”简单”的任务, 系统将数据按周做了分组统计, 现在你要在报表中显示每一周的数据, 那每个周就需要一个文本标签, 比如“2019年第24周”这样, 如何正确的根据日期生成这个时间格式呢?

比较莽的同学可能已经在想一些简单或者复杂的算法了, 什么一年365天, 怎么怎么除一下再取余, 哦, 忘了还有闰年, 还得确认某个基准点是星期几, 是1900/1/1还是1970/1/1还是公元0年开始算, 还像还得先去查一下历法什么的…

比较灵活的可能已经想到, 各个编程语言必然已经有现成的日期相关的库可以调用来实现这个功能. 日期格式字符串不就是干这个的吗, 然后一查Java手册, 年份是y, 星期是w, 那么合起来就是yyyy-ww, 搞定!

  1. DateFormat dateFormat = new SimpleDateFormat("yyyy-ww");
  2. System.out.println(dateFormat.format(new Date()));

事情当然没有这么简单, 请你思考一下下面几个问题:

  1. 一个星期的起点一定是星期一吗? 你可以随便翻开手机里的或是实体的日历表, 不少是以星期日为起点的
    image.png

而且这个是可以设置为任意一天的

image.png

  1. 如果1月1号所在的星期跨年了, 比如上图中2019年1月1号是星期二, 它所在的星期算是2019年第一周, 日期格式则为2019-01, 那么和他同一周的2018年12月31号应该算什么呢? 是同为2019-01, 还是2018-52? 如果按照上面的Java程序的话, 应该是2018-01, 显得更怪异了.
  2. 如果我把这个任务放在其他程序环境, 它们的标准一样吗? 比如C#/Python/MySQL等等

国际标准

显然这些乱七八糟的事情前人早就想明白了, 按照国际标准ISO 8601和国标GB/T 7408-2005 的规定(具体的可以看这里这里):

  1. 一周的第一天是星期一(好多软件将起点默认为周日, 其实是不合标准的, 可能因为很少用这个的原因吧, 或者是为了显得对称, 反正也都习惯了)
  2. 将本年第一个星期四所在的星期定为第一个日历星期, 它还有其他3个完全等效的说法:
    1. 本年度第一个至少有4天在同一星期内的星期
    2. 1月4日所在的星期
    3. 星期一在去年12月29日至今年1月4日以内的星期
  3. 使用日历星期的同时, 最好同时使用日历年, 以统一年份的显示

Java中的做法

通过查询文档, 可以得知Java中的日期格式化符号里使用了ISO 8601的标准: 用小写y表示正常年份, 大写Y表示Week Year, 然后w代表星期(Week of Year). 要注意的是, 这个大写Y是JDK 7新出的, 如果你使用JDK 6, 抱歉, 没有!

所以来一个小测试, 按照ISO 8601的标准, 2018/12/30号属于2018年的最后一个星期, 字符串应该是2018-52:

  1. DateFormat ymd = new SimpleDateFormat("yyyy-MM-dd");
  2. Date date = ymd.parse("2018-12-30");
  3. DateFormat dateFormat = new SimpleDateFormat("YYYY-ww");
  4. System.out.println(dateFormat.format(date));
  5. // 输出: 2019-01

嗯? 为啥不对呢, 仔细一查会发现, 首先Java的日期类使用的默认历法是格里高利历, 也就是俗称的公历, 但是具体怎么定义日历星期, 这个历法是没有定义的, 所以实际会根据地区(Locale)来设置的. 设置这个一共有两个参数: FirstDayOfWeekMinimalDaysInFirstWeek, 分别代表一个星期的起点是星期几和一年的第一周至少需要包含几天才算当年的第一个星期.

某些地区比如中国, 默认这两个参数是星期日和1天, 而某些地区比如法国(基本上欧洲的国家都是这个标准), 默认这两个参数是星期一和4天(和ISO标准一致).

所以先看一下Java的设置:

  1. Calendar instance = Calendar.getInstance(TimeZone.getDefault(), Locale.CHINA);
  2. System.out.println(instance.getFirstDayOfWeek());
  3. System.out.println(instance.getMinimalDaysInFirstWeek());
  4. // 显式设置成中国, 输出: 1 1, 也就是星期日和1天
  5. Calendar instance2 = Calendar.getInstance(TimeZone.getDefault(), Locale.FRENCH);
  6. System.out.println(instance2.getFirstDayOfWeek());
  7. System.out.println(instance2.getMinimalDaysInFirstWeek());
  8. // 设置成法国了, 输出: 2 4, OK!

好了, 那只要设置那两个参数就可以了:

  1. instance.setFirstDayOfWeek(Calendar.MONDAY);
  2. instance.setMinimalDaysInFirstWeek(4);
  3. DateFormat dateFormat = new SimpleDateFormat("YYYY-ww");
  4. System.out.println(dateFormat.format(instance.getTime()));
  5. // 输出: 2019-01

为啥还是不对呢? 因为SimpleDateFormat还没有收到这个设置, 再来:

  1. ...
  2. DateFormat dateFormat = new SimpleDateFormat("YYYY-ww", Locale.FRENCH); // 加上这个地区设置
  3. // 或者这样写也行, 可以自定义设置
  4. // dateFormat.setCalendar(instance);
  5. System.out.println(dateFormat.format(instance.getTime()));
  6. // 输出: 2018-52

终于对了!

Java中的日期相关的类还是有不少坑的, 这个算是比较简单的了. 再来看看MySQL的.

MySQL

MySQL日期显示相关的标准可以看这里.

可以看到MySQL根据星期的起点是周日还是周一, 星期的编号是从0开始还是从1开始和年度第一星期至少需要包含几天3个方面, 排列组合一下, 一共分了8种模式, 即Week Mode 0-7. 其中和ISO标准一致的是Mode 3, 即下面这个:

Mode First day of week Range Week 1 is the first week …
3 Monday 1-53 with 4 or more days this year

但是具体默认用哪个模式, 是根据不同系统设置来的, 所以出现下面这个结果也不必大惊小怪了:

  1. SELECT YEARWEEK('2019-01-01');
  2. # 2019年第一天居然属于2018年最后一周, 其实是因为
  3. # 默认模式Mode 0, 所以
  4. # 输出: 201852

只要显式的设置模式, 就可以统一了:

  1. SELECT YEARWEEK('2019-01-01', 3);
  2. # 使用模式Mode 3(ISO的标准)
  3. # 输出: 201901

如果对上面这个YEARWEEK函数的输出格式不满意, 有可以使用自定义格式化字符串, 用DATE_FORMAT函数来输出, 具体可以看这里.

对应前面的8种模式, 这里格式中分开了年份和星期, 年份有两种模式, 星期有4种模式, 组合起来2*4=8, 完美对应那8个模式. 所以对应ISO标准的例子是这样:

  1. SELECT DATE_FORMAT('2019-01-01', '%x-%v');
  2. # 输出: 2019-01

下面来看看Python

Python

Python的日期标准看这里, 可以发现在Python的老版本(<3.6)中是没有个ISO 8601标准对应的格式字符串的, 在3.6的时候才增加了对应的%G, %u%V.

来个例子:

  1. d = datetime.strptime('2019-01-01', '%Y-%m-%d')
  2. print(datetime.strftime(d, '%G-%V'))
  3. # 输出: 2019-01

再来看看Javascript的

Javascript

其实Javascript这个真的让我有点出乎意料, 因为Javascript真的没有原生的自定义日期格式化函数, 顶多只有一些固定格式的格式化输出, 比如:

  1. let d = new Date('2019-01-01')
  2. d.toLocaleString()
  3. // 输出: 1/1/2019, 8:00:00 AM
  4. d.toDateString()
  5. // 输出: Tue Jan 01 2019

但是Javascript出名的什么, 是它庞大丰富的社区和类库, 所以市面上自然也有无数的类库可以做到自定义格式化, 这里就用一个比较常见的Moment.js.

根据它的文档, 在2.1.0版本时, 新增了ISO的星期格式:

  1. let m = moment('2018-12-31')
  2. m.format("GGGG-WW")
  3. // 输出: 2019-01

同时非ISO格式字串的标准其实和Java类似, 通过两个参数控制, 并使用Locale配置的:

  1. moment.locale('fr', {
  2. // ......
  3. week : {
  4. dow : 1, // 星期一是一周的第一天
  5. doy : 4 // 一年的第一周至少需要包含几天
  6. }
  7. });

所以如果是使用老版本的Moment.js的话, 可以通过使用对应的本地化文件或修改对应的配置来达到ISO的标准.

最后再来看一个Excel的.

Excel

Excel的WEEKNUM函数的说明在这里.

Excel按照星期起点是星期几和采用什么标准划分出了10个模式, 对应ISO 8601标准的是模式21. 所以

  1. # 假设A1是 2018-12-31
  2. =WEEKNUM(A1) # 输出: 53, 因为是模式1
  3. =WEEKNUM(A1,21) # 输出: 1, 因为是模式21

在Excel 2013, 它还引入了一个新函数ISOWEEKNUM, 等价于上面的模式21.

但是, 年份呢? YEAR函数只是返回自然年, 并不能返回和星期相关的年份, 查了一圈, 发现确实没有相关的函数, 只能通过复杂的公式模拟出来:

  1. # 假设A1 2018-12-31
  2. =YEAR(A1-WEEKDAY(A1,3)+3)
  3. # 输出: 2019

总结

怎样获取年度的第几个星期, 因为各种历史原因, 并没有完全统一规则. 虽然已经有ISO国际标准和国内的GB/T标准(这个只是个T, 也就是推荐标准), 但是事实上, 各个国家和地区, 还是在规则上使用不同的标准, 带来不大不小的坑.

从上面对各个语言在ISO标准支持方面也可以看出, 各个语言几乎都在较新的版本中增加了对ISO的支持, 让程序在日期方面可以更方便的统一, 这也是一个比较好的趋势.

对于普通人一般很少用到这个值, 但是对于程序员, 数据分析人员, 做统计的人员还是有可能会遇到按星期统计的需求的, 这个时候就要特别注意你应该使用的什么样的规则标准, 特别是数据会在不同程序中做处理的时候, 说不定就会遇到Python做的统计为什么和Excel做的统计总是小小的出入, 说不定就是这个标准的问题.

希望文章对你有帮助, 欢迎留言/评论/点赞/分享, Good Luck, Have Fun!