MongoDB中按日期分组与本地时区

Ank*_*iya 3 javascript mongoose mongodb node.js aggregation-framework

我是mongodb的新手.以下是我的查询.

Model.aggregate()
            .match({ 'activationId': activationId, "t": { "$gte": new Date(fromTime), "$lt": new Date(toTime) } })
            .group({ '_id': { 'date': { $dateToString: { format: "%Y-%m-%d %H", date: "$datefield" } } }, uniqueCount: { $addToSet: "$mac" } })
            .project({ "date": 1, "month": 1, "hour": 1, uniqueMacCount: { $size: "$uniqueCount" } })
            .exec()
            .then(function (docs) {
                return docs;
            });
Run Code Online (Sandbox Code Playgroud)

问题是在iso时区的mongodb存储日期.我需要这些数据来显示面积图.

我想按日期与当地时区分组.是否有任何方法可以在分组时将时间偏移添加到日期?

Nei*_*unn 8

处理"当地日期"的一般问题

所以对此有一个简短的答案,也是一个很长的答案.基本情况是,而不是使用任何"日期聚合运算符",而是希望并且"需要"实际上"在日期对象上进行数学运算".这里主要的是通过给定本地时区的UTC偏移调整值,然后"舍入"到所需的时间间隔.

"更长的答案"以及要考虑的主要问题涉及日期通常受到一年中不同时间与UTC的偏移的"夏令时"变化的影响.因此,这意味着当转换为"本地时间"以进行此类聚合时,您应该考虑存在此类更改的边界.

还有另一个考虑因素,即无论你在给定的时间间隔"聚合"做什么,输出值"应该"至少最初以UTC形式出现.这是一个很好的做法,因为显示到"locale"实际上是一个"客户端功能",并且如后面所述,客户端接口通常会有一种在当前区域设置中显示的方式,该方式将基于它实际上已经被馈送的前提数据为UTC.

确定区域设置偏移和夏令时

这通常是需要解决的主要问题.将日期"舍入"到区间的一般数学是简单的部分,但是没有真正的数学可以应用于知道何时适用这些边界,并且规则在每个区域变化并且通常每年都会变化.

所以这就是"库"的用武之地,作者对JavaScript平台的看法中最好的选择是时刻 - 时区,它基本上是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
}
Run Code Online (Sandbox Code Playgroud)

当然,对于实际记录的和属性,对象的位置要大得多.但这是您需要访问的数据,以便在夏令时更改的情况下查看区域的偏移量是否实际发生了变化.untilsoffsets

后面的代码清单的这个块是我们基本上用来确定给定范围的a 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 }] : []
          )
        ,[]);
  }
Run Code Online (Sandbox Code Playgroud)

查看2017年的整个Australia/Sydney区域设置,其输​​出为:

[
  {
    "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"
  }
]
Run Code Online (Sandbox Code Playgroud)

这基本上表明,在第一个日期序列之间,偏移量为+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
Run Code Online (Sandbox Code Playgroud)

这是我们还需要使用$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 } }
    ];
Run Code Online (Sandbox Code Playgroud)

您需要了解的主要部分是从Date存储在MongoDB中的对象转换为Numeric表示内部时间戳值.我们需要"数字"形式,这样做是一个数学技巧,我们从另一个BSON日期中减去一个BSON日期,产生它们之间的数字差异.这正是本声明的作用:

{ "$subtract": [ "$createdAt", new Date(0) ] }
Run Code Online (Sandbox Code Playgroud)

现在我们有一个数值来处理,我们可以应用模数并从日期的数字表示中减去它,以便"舍入"它.因此,"直接"表示如下:

{ "$subtract": [
  { "$subtract": [ "$createdAt", new Date(0) ] },
  { "$mod": [
    { "$subtract": [ "$createdAt", new Date(0) ] },
    ( 1000 * 60 * 60 * 24 ) // 24 hours
  ]}
]}
Run Code Online (Sandbox Code Playgroud)

它反映了前面所示的相同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
    }
  }
]
Run Code Online (Sandbox Code Playgroud)

That expansion is using the $switch statement in order to apply the date ranges as conditions to when to return the given offset values. This is the most convenient form since the "branches" argument does correspond directly to an "array", which is the most convenient output of the "ranges" determined by examination of the untils representing the offset "cut-points" for the given timezone on the supplied date range of the query.

It is possible to apply the same logic in earlier versions of MongoDB using a "nested" implementation of $cond instead, but it is a little messier to implement, so we are just using the most convenient method in implementation here.

Once all of those conditions are applied, the dates "aggregated" are actually those representing the "local" time as defined by the supplied locale. This actually brings us to what the final aggregation stage is, and the reason why it is there as well as the later handling as demonstrated in the listing.

End Results

I did mention earlier that the general recommendation is that the "output" should still return the date values in UTC format of at least some description, and therefore that is exactly what the pipeline here is doing by first converting "from" UTC to local by applying the offset when "rounding", but then the final numbers "after the grouping" are re-adjusted back by the same offset that applies to the "rounded" date values.

The listing here gives "three" different output possibilities here as:

// 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
  }
]
Run Code Online (Sandbox Code Playgroud)

The one thing of note here is that for a "client" such as Angular, every single one of those formats would be accepted by it's own DatePipe which can actually do the "locale format" for you. But it depends on where the data is supplied to. "Good" libraries will be aware of using a UTC date in the present locale. Where that is not the case, then you might need to "stringify" yourself.

But it is a simple thing, and you get the most support for this by using a library which essentially bases it's manipulation of output from a "given UTC value".

The main thing here is to "understand what you are doing" when you ask such a thing as aggregating to a local time zone. Such a process should consider:

  1. The data can be and often is viewed from the perspective of people within different timezones.

  2. The data is generally provided by people in different timezones. Combined with point 1, this is why we store in UTC.

  3. Timezones are often subject to a changing "offset" from "Daylight Savings Time" in many of the world timezones, and you should account for that when analyzing and processing the data.

  4. Regardless of aggregation intervals, output "should" in fact remain in UTC, albeit adjusted to aggregate on interval according to the locale provided. This leaves presentation to be delegated to a "client" function, just as it should.

As long as you keep those things in mind and apply just like the listing here demonstrates, then you are doing all the right things for dealing with aggregation of dates and even general storage with respect to a given locale.

So you "should" be doing this, and what you "should not" be doing is giving up and simply storing the "locale date" as a string. As described, that would be a very incorrect approach and causes nothing but further problems for your application.

NOTE: The one topic I do not touch on here at all is aggregating to a "month" ( or indeed "year" ) interval. "Months" are the mathematical anomaly in the whole process since the number of days always varies and thus requires a whole other set of logic in order to apply. Describing that alone is at least as long as this post, and therefore would be another subject. For general minutes, hours, and days which is the common case, the math here is "good enough" for those cases.


Full Listing

This serves as a "demonstration" to tinker with. It employs the required function to extract the offset dates and values to be included and runs an aggregation pipeline over the supplied data.

You can change anything in here, but will probably start with the locale and interval parameters, and then maybe add different data and different start and end dates for the query. But the rest of the code need not be changed to simply make changes to any of those values, and can therefore demonstrate using different intervals ( such as 1 hour as asked in the question ) and different locales.

For instance, once supplying valid data which would actually require aggregation at a "1 hour interval" then the line in the listing would be changed as:

const interval = moment.duration(1,'hour').asMilliseconds();
Run Code Online (Sandbox Code Playgroud)

In order to define a milliseconds value for the aggregation interval as required by the aggregation operations being performed on the dates.


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();
  }
})()
Run Code Online (Sandbox Code Playgroud)


Avi*_*ius 5

2017 年 11 月发布了 MongoDB v3.6,其中包括时区感知的日期聚合运算符。我鼓励任何阅读本文的人使用它们,而不是依赖客户端日期操作,如尼尔的回答所示,特别是因为它更容易阅读和理解。

根据要求,不同的运算符可能会派上用场,但我发现$dateToParts这是最通用/通用的。这是使用 OP 示例的基本演示:

project({
  dateParts: {
    // This will split the date stored in `dateField` into parts
    $dateToParts: {
      date: "$dateField",
      // This can be an Olson timezone, such as Europe/London, or
      // a fixed offset, such as +0530 for India.
      timezone: "+05:30"
    }
  }
})
.group({
  _id: {
    // Here we group by hour! Using these date parts grouping
    // by hour/day/month/etc. is trivial - start with the year
    // and add every unit greater than or equal to the target
    // unit.
    year: "$dateParts.year",
    month: "$dateParts.month",
    day: "$dateParts.day",
    hour: "$dateParts.hour"
  },
  uniqueCount: {
    $addToSet: "$mac"
  }
})
.project({
  _id: 0,
  year: "$_id.year",
  month: "$_id.month",
  day: "$_id.day",
  hour: "$_id.hour",
  uniqueMacCount: { $size: "$uniqueCount" }
});
Run Code Online (Sandbox Code Playgroud)

或者,人们可能希望将日期部分组装回日期对象。使用逆运算符,这也非常简单$dateFromParts:

project({
  _id: 0,
  date: {
    $dateFromParts: {
      year: "$_id.year",
      month: "$_id.month",
      day: "$_id.day",
      hour: "$_id.hour",
      timezone: "+05:30"
    }
  },
  uniqueMacCount: { $size: "$uniqueCount" }
})
Run Code Online (Sandbox Code Playgroud)

这里最棒的是所有基础日期都保留 UTC 格式,并且任何返回的日期也都采用 UTC 格式。

不幸的是,似乎按更不寻常的任意范围(例如半天)进行分组可能会更困难。不过我还没有考虑太多。