处理“本地日期”的一般问题
因此,对此有一个简短的答案,也有一个长答案。基本情况是,您不是使用任何"date aggregation operators",而是想要并且“需要”实际上对日期对象“做数学”。这里的主要事情是根据给定本地时区的 UTC 偏移量调整值,然后“四舍五入”到所需的时间间隔。
“更长的答案”也是要考虑的主要问题涉及日期通常会在一年中的不同时间与 UTC 的偏移量发生“夏令时”变化。因此,这意味着在为此类聚合目的转换为“本地时间”时,您确实应该考虑此类更改的边界存在于何处。
还有另一个考虑因素,即无论您如何以给定的时间间隔“聚合”,输出值“应该”至少最初以 UTC 的形式出现。这是一个很好的做法,因为显示到“区域设置”实际上是一个“客户端功能”,并且如后面所述,客户端界面通常具有在当前区域设置中显示的方式,这将基于它实际上是馈送的前提数据为 UTC。
确定语言环境偏移和夏令时
这通常是需要解决的主要问题。将日期“四舍五入”为间隔的一般数学是简单的部分,但没有真正的数学可以应用来知道何时适用此类边界,并且规则在每个区域设置并且通常每年都会发生变化。
所以这就是“库”的用武之地,作者认为 JavaScript 平台的最佳选择是 moment-timezone,它基本上是 moment.js 的“超集”,包括所有重要的“timezeone”我们想要使用的功能。
Moment Timezone 基本上为每个区域设置时区定义了这样一个结构:
{
name : 'America/Los_Angeles', // the unique identifier
abbrs : ['PDT', 'PST'], // the abbreviations
untils : [1414918800000, 1425808800000], // the timestamps in milliseconds
offsets : [420, 480] // the offsets in minutes
}
当然,相对于实际记录的untils 和offsets 属性,对象要大得多。但这是您需要访问的数据,以便查看在给定夏令时更改的情况下区域的偏移量是否确实发生了变化。
后面的代码清单中的这个块是我们基本上用来确定给定范围的start 和end 值的,如果有的话,跨越夏令时边界:
const zone = moment.tz.zone(locale);
if ( zone.hasOwnProperty('untils') ) {
let between = zone.untils.filter( u =>
u >= start.valueOf() && u < end.valueOf()
);
if ( between.length > 0 )
branches = between
.map( d => moment.tz(d, locale) )
.reduce((acc,curr,i,arr) =>
acc.concat(
( i === 0 )
? [{ start, end: curr }] : [{ start: acc[i-1].end, end: curr }],
( i === arr.length-1 ) ? [{ start: curr, end }] : []
)
,[]);
}
查看Australia/Sydney 语言环境的整个 2017 年,输出将是:
[
{
"start": "2016-12-31T13:00:00.000Z", // Interval is +11 hours here
"end": "2017-04-01T16:00:00.000Z"
},
{
"start": "2017-04-01T16:00:00.000Z", // Changes to +10 hours here
"end": "2017-09-30T16:00:00.000Z"
},
{
"start": "2017-09-30T16:00:00.000Z", // Changes back to +11 hours here
"end": "2017-12-31T13:00:00.000Z"
}
]
这基本上表明,在第一个日期序列之间,偏移量将为 +11 小时,然后在第二个序列中的日期之间变为 +10 小时,然后切换回 +11 小时,以覆盖到年份和指定范围。
然后,需要将此逻辑转换为 MongoDB 将其理解为聚合管道的一部分的结构。
应用数学
此处用于聚合到任何“四舍五入的日期间隔”的数学原理本质上依赖于使用表示日期的毫秒值,该值“四舍五入”到表示所需“间隔”的最接近的数字。
您实际上是通过查找应用于所需间隔的当前值的“模”或“余数”来做到这一点的。然后你从当前值中“减去”那个余数,返回一个最近间隔的值。
例如,给定当前日期:
var d = new Date("2017-07-14T01:28:34.931Z"); // toValue() is 1499995714931 millis
// 1000 millseconds * 60 seconds * 60 minutes = 1 hour or 3600000 millis
var v = d.valueOf() - ( d.valueOf() % ( 1000 * 60 * 60 ) );
// v equals 1499994000000 millis or as a date
new Date(1499994000000);
ISODate("2017-07-14T01:00:00Z")
// which removed the 28 minutes and change to nearest 1 hour interval
这是我们还需要使用$subtract 和$mod 操作在聚合管道中应用的一般数学,它们是用于上述相同数学操作的聚合表达式。
那么聚合管道的一般结构是:
let pipeline = [
{ "$match": {
"createdAt": { "$gte": start.toDate(), "$lt": end.toDate() }
}},
{ "$group": {
"_id": {
"$add": [
{ "$subtract": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
{ "$mod": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
interval
]}
]},
new Date(0)
]
},
"amount": { "$sum": "$amount" }
}},
{ "$addFields": {
"_id": {
"$add": [
"$_id", switchOffset(start,end,"$_id",true)
]
}
}},
{ "$sort": { "_id": 1 } }
];
这里您需要了解的主要部分是从存储在 MongoDB 中的 Date 对象到表示内部时间戳值的 Numeric 的转换。我们需要“数字”形式,这是一种数学技巧,我们从另一个中减去一个 BSON 日期,从而产生它们之间的数字差异。这正是该语句的作用:
{ "$subtract": [ "$createdAt", new Date(0) ] }
现在我们有一个数值要处理,我们可以应用模数并从日期的数字表示中减去它以“四舍五入”它。所以这个的“直接”表示是这样的:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
这反映了与前面所示相同的 JavaScript 数学方法,但应用于聚合管道中的实际文档值。您还将注意到另一个“技巧”,我们在其中应用 $add 操作以及 BSON 日期的另一种表示形式(或 0 毫秒),其中将 BSON 日期“添加”到“数字”值,返回一个“BSON 日期”,表示它作为输入给出的毫秒数。
当然,列出的代码中的另一个考虑因素是与 UTC 的实际“偏移”,它正在调整数值以确保在当前时区进行“四舍五入”。这是在一个函数中实现的,该函数基于前面关于查找不同偏移量发生位置的描述,并通过比较输入日期并返回正确的偏移量来返回可用于聚合管道表达式的格式。
随着所有细节的全面扩展,包括处理那些不同的“夏令时”时间偏移的生成,将如下所示:
[
{
"$match": {
"createdAt": {
"$gte": "2016-12-31T13:00:00.000Z",
"$lt": "2017-12-31T13:00:00.000Z"
}
}
},
{
"$group": {
"_id": {
"$add": [
{
"$subtract": [
{
"$subtract": [
{
"$subtract": [
"$createdAt",
"1970-01-01T00:00:00.000Z"
]
},
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2016-12-31T13:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-12-31T13:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
},
{
"$mod": [
{
"$subtract": [
{
"$subtract": [
"$createdAt",
"1970-01-01T00:00:00.000Z"
]
},
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2016-12-31T13:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-04-01T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$createdAt",
"2017-09-30T16:00:00.000Z"
]
},
{
"$lt": [
"$createdAt",
"2017-12-31T13:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
},
86400000
]
}
]
},
"1970-01-01T00:00:00.000Z"
]
},
"amount": {
"$sum": "$amount"
}
}
},
{
"$addFields": {
"_id": {
"$add": [
"$_id",
{
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-01-01T00:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2017-04-02T03:00:00.000Z"
]
}
]
},
"then": -39600000
},
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-04-02T02:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2017-10-01T02:00:00.000Z"
]
}
]
},
"then": -36000000
},
{
"case": {
"$and": [
{
"$gte": [
"$_id",
"2017-10-01T03:00:00.000Z"
]
},
{
"$lt": [
"$_id",
"2018-01-01T00:00:00.000Z"
]
}
]
},
"then": -39600000
}
]
}
}
]
}
}
},
{
"$sort": {
"_id": 1
}
}
]
该扩展使用$switch 语句来应用日期范围作为何时返回给定偏移值的条件。这是最方便的形式,因为"branches" 参数确实直接对应于“数组”,这是通过检查表示偏移“切点”的untils 确定的“范围”的最方便输出查询提供的日期范围内的给定时区。
在早期版本的 MongoDB 中可以使用 $cond 的“嵌套”实现来应用相同的逻辑,但是实现起来有点麻烦,所以我们在这里只使用最方便的实现方法。
一旦应用了所有这些条件,“聚合”日期实际上就是那些代表“本地”时间的日期,由提供的locale 定义。这实际上将我们带到了最终的聚合阶段,以及它存在的原因以及清单中所示的后期处理。
最终结果
我之前确实提到过,一般建议是“输出”仍应返回至少某种描述的 UTC 格式的日期值,因此这正是管道通过首先转换“来自”UTC 所做的事情通过在“四舍五入”时应用偏移量到本地,但随后“分组后”的最终数字将通过适用于“四舍五入”日期值的相同偏移量重新调整回来。
这里的清单给出了“三种”不同的输出可能性:
// ISO Format string from JSON stringify default
[
{
"_id": "2016-12-31T13:00:00.000Z",
"amount": 2
},
{
"_id": "2017-01-01T13:00:00.000Z",
"amount": 1
},
{
"_id": "2017-01-02T13:00:00.000Z",
"amount": 2
}
]
// Timestamp value - milliseconds from epoch UTC - least space!
[
{
"_id": 1483189200000,
"amount": 2
},
{
"_id": 1483275600000,
"amount": 1
},
{
"_id": 1483362000000,
"amount": 2
}
]
// Force locale format to string via moment .format()
[
{
"_id": "2017-01-01T00:00:00+11:00",
"amount": 2
},
{
"_id": "2017-01-02T00:00:00+11:00",
"amount": 1
},
{
"_id": "2017-01-03T00:00:00+11:00",
"amount": 2
}
]
这里需要注意的一点是,对于像 Angular 这样的“客户端”,这些格式中的每一种都会被它自己的DatePipe 接受,它实际上可以为您执行“语言环境格式”。但这取决于将数据提供给何处。 “好”库会意识到在当前语言环境中使用 UTC 日期。如果不是这种情况,那么您可能需要自己“字符串化”。
但这是一件简单的事情,您可以通过使用一个库来获得对此的最大支持,该库基本上基于它对“给定 UTC 值”的输出的操作。
当您询问聚合到本地时区这样的事情时,这里的主要内容是“了解您在做什么”。这样的过程应该考虑:
可以并且经常从不同时区的人们的角度查看数据。
数据一般由不同时区的人提供。结合第 1 点,这就是我们使用 UTC 存储的原因。
在世界许多时区中,时区通常与“夏令时”的“偏移量”发生变化,您在分析和处理数据时应考虑到这一点。
不管聚合间隔如何,输出“应该”实际上保持在 UTC 中,尽管根据提供的区域设置调整为按间隔聚合。这样就可以将演示文稿委托给“客户端”功能,就像它应该做的那样。
只要您牢记这些事项并按照此处的清单所示进行应用,那么您就可以做所有正确的事情来处理日期聚合,甚至是针对给定语言环境的一般存储。
所以你“应该”这样做,而你“不应该”做的是放弃并简单地将“语言环境日期”存储为字符串。如上所述,这将是一种非常不正确的方法,只会给您的应用程序带来更多问题。
注意:我在这里根本没有涉及的一个主题是聚合到“月”(或者实际上是“年”) 间隔。 “月”是整个过程中的数学异常,因为天数总是变化的,因此需要一整套逻辑才能应用。仅描述这一点至少与这篇文章一样长,因此将是另一个主题。对于一般情况下的一般分钟、小时和天数,这里的数学对于这些情况“足够好”。
完整列表
这是一个修补的“示范”。它使用所需的函数来提取要包含的偏移日期和值,并在提供的数据上运行聚合管道。
您可以在此处更改任何内容,但可能会从 locale 和 interval 参数开始,然后可能会为查询添加不同的数据和不同的 start 和 end 日期。但其余代码无需更改即可简单地更改任何这些值,因此可以演示使用不同的时间间隔(例如问题中提出的1 hour)和不同的语言环境。
例如,一旦提供了实际上需要以“1 小时间隔”进行聚合的有效数据,那么列表中的行将更改为:
const interval = moment.duration(1,'hour').asMilliseconds();
为了根据对日期执行的聚合操作的要求定义聚合间隔的毫秒值。
const moment = require('moment-timezone'),
mongoose = require('mongoose'),
Schema = mongoose.Schema;
mongoose.Promise = global.Promise;
mongoose.set('debug',true);
const uri = 'mongodb://localhost/test',
options = { useMongoClient: true };
const locale = 'Australia/Sydney';
const interval = moment.duration(1,'day').asMilliseconds();
const reportSchema = new Schema({
createdAt: Date,
amount: Number
});
const Report = mongoose.model('Report', reportSchema);
function log(data) {
console.log(JSON.stringify(data,undefined,2))
}
function switchOffset(start,end,field,reverseOffset) {
let branches = [{ start, end }]
const zone = moment.tz.zone(locale);
if ( zone.hasOwnProperty('untils') ) {
let between = zone.untils.filter( u =>
u >= start.valueOf() && u < end.valueOf()
);
if ( between.length > 0 )
branches = between
.map( d => moment.tz(d, locale) )
.reduce((acc,curr,i,arr) =>
acc.concat(
( i === 0 )
? [{ start, end: curr }] : [{ start: acc[i-1].end, end: curr }],
( i === arr.length-1 ) ? [{ start: curr, end }] : []
)
,[]);
}
log(branches);
branches = branches.map( d => ({
case: {
$and: [
{ $gte: [
field,
new Date(
d.start.valueOf()
+ ((reverseOffset)
? moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
: 0)
)
]},
{ $lt: [
field,
new Date(
d.end.valueOf()
+ ((reverseOffset)
? moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
: 0)
)
]}
]
},
then: -1 * moment.duration(d.start.utcOffset(),'minutes').asMilliseconds()
}));
return ({ $switch: { branches } });
}
(async function() {
try {
const conn = await mongoose.connect(uri,options);
// Data cleanup
await Promise.all(
Object.keys(conn.models).map( m => conn.models[m].remove({}))
);
let inserted = await Report.insertMany([
{ createdAt: moment.tz("2017-01-01",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-01",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-02",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-03",locale), amount: 1 },
{ createdAt: moment.tz("2017-01-03",locale), amount: 1 },
]);
log(inserted);
const start = moment.tz("2017-01-01", locale)
end = moment.tz("2018-01-01", locale)
let pipeline = [
{ "$match": {
"createdAt": { "$gte": start.toDate(), "$lt": end.toDate() }
}},
{ "$group": {
"_id": {
"$add": [
{ "$subtract": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
{ "$mod": [
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
switchOffset(start,end,"$createdAt",false)
]},
interval
]}
]},
new Date(0)
]
},
"amount": { "$sum": "$amount" }
}},
{ "$addFields": {
"_id": {
"$add": [
"$_id", switchOffset(start,end,"$_id",true)
]
}
}},
{ "$sort": { "_id": 1 } }
];
log(pipeline);
let results = await Report.aggregate(pipeline);
// log raw Date objects, will stringify as UTC in JSON
log(results);
// I like to output timestamp values and let the client format
results = results.map( d =>
Object.assign(d, { _id: d._id.valueOf() })
);
log(results);
// Or use moment to format the output for locale as a string
results = results.map( d =>
Object.assign(d, { _id: moment.tz(d._id, locale).format() } )
);
log(results);
} catch(e) {
console.error(e);
} finally {
mongoose.disconnect();
}
})()