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

时间:2017-07-11 15:26:36

标签: javascript node.js mongodb mongoose 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;
            });

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

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

2 个答案:

答案 0 :(得分:8)

处理"本地日期"

的一般问题

所以对此有一个简短的答案,也是一个很长的答案。基本情况是,而不是使用任何"date aggregation operators",而不是想要和#34;需要"实际上"做数学"而在日期对象上。这里主要的是通过给定本地时区的UTC偏移调整值,然后" round"达到规定的时间间隔。

"更长的答案"而且要考虑的主要问题是日期往往受到夏令时的影响"在一年中的不同时间与UTC的偏移量的变化。所以这意味着当转换到"当地时间"出于这种聚合的目的,你真的应该考虑这种变化的边界在哪里。

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

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

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

所以这就是"库"作者认为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
}

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

后面的代码清单的这个块是我们基本上用来确定给定范围的startend值,如果有的话,则划过夏令时边界:

  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 }] : []
          )
        ,[]);
  }

查看整个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"
  }
]

这基本上表明,在第一个日期序列之间,偏移量将是+11小时,然后在第二个序列中的日期之间变为+10小时,然后切换回+11小时,间隔覆盖到结束时间。年和指定的范围。

然后需要将此逻辑转换为MongoDB将其理解为聚合管道的一部分的结构。

应用数学

这里的数学原理用于汇总到任何&#34;舍入日期间隔&#34;基本上依赖于使用所表示日期的毫秒值,即&#34;舍入&#34;到最近的数字代表&#34;间隔&#34;必需的。

你基本上是通过找到&#34; modulo&#34;或&#34;余数&#34;应用于所需间隔的当前值。然后你&#34;减去&#34;来自当前值的剩余部分,它以最近的间隔返回一个值。

例如,给定当前日期:

  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的转换。我们需要&#34;数字&#34;形式,这是一个数学技巧,我们从另一个BSON日期中减去一个BSON日期,产生它们之间的数字差异。这正是本声明的作用:

{ "$subtract": [ "$createdAt", new Date(0) ] }

现在我们有一个数值要处理,我们可以应用模数并从日期的数字表示中减去它,以便&#34; round&#34;它。所以&#34;直#34;这表示如下:

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

它反映了与前面所示相同的JavaScript数学方法,但应用于聚合管道中的实际文档值。你还会注意到另一个&#34;技巧&#34;在那里我们应用$add操作与另一个BSON日期的表示,作为纪元(或0毫秒),其中&#34;添加&#34; BSON日期到&#34;数字&#34;值,返回&#34; BSON日期&#34;表示作为输入给出的毫秒数。

当然,列出的代码中的另一个考虑因素是实际的&#34;偏移&#34;从UTC调整数值,以确保&#34;舍入&#34;发生在现在的时区。这是在基于先前描述查找不同偏移发生位置的函数中实现的,并通过比较输入日期并返回正确的偏移量来返回聚合管道表达式中可用的格式。

随着所有细节的全面扩展,包括处理那些不同的&#34;夏令时&#34;时间偏移就像是:

[
  {
    "$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"参数直接对应于&#34;数组&#34;,这是&#34;范围&#34;中最方便的输出。通过检查代表偏移的untils&#34;切点&#34;对于查询提供的日期范围内的给定时区。

可以使用&#34;嵌套&#34;在早期版本的MongoDB中应用相同的逻辑。相反,$cond的实现,但实现起来有点麻烦,所以我们只是在这里使用最方便的方法。

一旦应用了所有这些条件,日期&#34;汇总&#34;实际上是代表&#34; local&#34;由提供的locale定义的时间。这实际上将我们带到了最终的聚合阶段,以及它在那里的原因以及后面的处理,如清单所示。

最终结果

我之前提到的一般建议是&#34;输出&#34;仍然应该返回至少某些描述的UTC格式的日期值,因此这正是这里的管道首先转换&#34;来自&#34;通过在&#34;舍入&#34;时应用偏移量,然后在分组&#34;之后应用最终数字&#34;来将UTC转换为本地通过适用于&#34;舍入&#34;的相同偏移量重新调整。日期值。

这里的列表给出了&#34;三个&#34;这里有不同的输出可能性:

// 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
  }
]

这里要注意的一点是,对于一个&#34;客户&#34;例如Angular,这些格式中的每一种都可以被它自己的DatePipe接受,它实际上可以做&#34;语言环境格式&#34;为了你。但这取决于数据的提供位置。 &#34;良好的&#34;库将知道在当前语言环境中使用UTC日期。如果不是这种情况,那么你可能需要&#34; stringify&#34;自己。

但这是一件简单的事情,你可以通过使用一个库来获得最大的支持,这个库基本上是根据给定的UTC值&#34;来处理输出。

这里最重要的是要了解你在做什么&#34;当你问这样的事情聚合到当地时区。这样的过程应该考虑:

  1. 可以并且经常从不同时区内的人的角度查看数据。

  2. 数据通常由不同时区的人提供。结合第1点,这就是我们以UTC格式存储的原因。

  3. 时区经常会发生变化&#34;偏移&#34;来自&#34;夏令时&#34;在许多世界时区,你应该在分析和处理数据时考虑到这一点。

  4. 无论聚合间隔如何,输出&#34;应该&#34;实际上保留在UTC中,尽管根据提供的区域设置调整为按间隔聚合。这使得演示文稿被委托给&#34;客户&#34;功能,就像它应该的那样。

  5. 只要你记住这些事情并且像这里的列表那样应用就可以了,那么你就可以做所有正确的事情来处理日期的汇总,甚至是关于给定语言环境的一般存储。

    所以你&#34;应该&#34;这样做,以及你不应该做什么&#34;要做的就是放弃并简单地存储&#34;区域设置日期&#34;作为一个字符串。如上所述,这将是一种非常不正确的方法,只会导致您的应用程序出现进一步的问题。

      

    注意:我在这里根本没有涉及的一个主题是聚合到&#34;月&#34; (或确实&#34;年&#34;)间隔。 &#34;月&#34;是整个过程中的数学异常,因为天数总是变化的,因此需要一整套其他逻辑才能应用。单独描述这个帖子至少和这篇文章一样长,因此将成为另一个主题。对于一般情况下的一般分钟,小时和天数,这里的数学是足够好的&#34;对于那些案件。

    完整列表

    这是一个&#34;演示&#34;修修补补。它使用所需的函数来提取要包含的偏移日期和值,并在提供的数据上运行聚合管道。

    您可以在此更改任何内容,但可能会从localeinterval参数开始,然后可能会添加不同的数据以及不同的startend日期查询。但是其他代码不需要更改为只更改任何这些值,因此可以演示使用不同的时间间隔(例如问题中提到的1 hour)和不同的语言环境。

    例如,一旦提供实际需要在&#34; 1小时间隔聚合的有效数据&#34;然后列表中的行将更改为:

    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();
      }
    })()
    

答案 1 :(得分:0)

2017年11月,发布了MongoDB v3.6,其中包括可识别时区的日期聚合运算符。我会鼓励任何阅读此书的人都可以使用它们,而不是像Neil的回答中所展示的那样依靠客户端日期操纵,尤其是因为它更易于阅读和理解。

根据要求,不同的运算符可能会派上用场,但是我发现$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" }
});

或者,可能希望将日期部分组合回日期对象。使用反$dateFromParts逆运算符也很简单:

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

这里很棒的是,所有基础日期都保留在UTC中,所有返回的日期也都保留在UTC中。

不幸的是,似乎很难按非常规的任意范围进行分组,例如半天。不过,我没有考虑太多。