「現地の日付」を扱う際の一般的な問題
したがって、これには短い答えと長い答えがあります。基本的なケースは、「日付集計演算子」を使用する代わりに、日付オブジェクトに対して実際に「計算を行う」必要があります。ここでの主なことは、指定されたローカルタイムゾーンのUTCからのオフセットによって値を調整してから、必要な間隔に「丸める」ことです。
「はるかに長い答え」と考慮すべき主な問題は、日付が1年のさまざまな時期にUTCからのオフセットで「夏時間」の変更を受けることが多いことです。つまり、このような集計目的で「現地時間」に変換する場合は、そのような変更の境界がどこにあるかを実際に考慮する必要があります。
別の考慮事項もあります。特定の間隔で「集約」するために何をしても、出力値は少なくとも最初はUTCとして出力される必要があります。 「ロケール」への表示は実際には「クライアント関数」であるため、これは良い習慣です。後で説明するように、クライアントインターフェイスには通常、実際にフィードされたという前提に基づいて、現在のロケールで表示する方法があります。 UTCとしてのデータ。
ロケールオフセットと夏時間の決定
これは一般的に解決する必要がある主な問題です。日付を間隔に「丸める」ための一般的な計算は単純な部分ですが、そのような境界がいつ適用されるかを知るために適用できる実際の計算はなく、ルールはすべてのロケールで、多くの場合毎年変更されます。
そこで、ここで「ライブラリ」が登場します。JavaScriptプラットフォームに関する著者の意見では、moment-timezoneが最適です。これは、基本的に、必要なすべての重要な「timezeone」機能を含むmoment.jsの「スーパーセット」です。使用します。
モーメントタイムゾーンは基本的に、各ロケールタイムゾーンの構造を次のように定義します。
{
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時間になり、次に2番目のシーケンスの日付の間で+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 } }
];
ここで理解する必要がある主な部分は、Date
からの変換です。 MongoDBに保存されているオブジェクトを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
を適用する他の「トリック」にも注意してください。 エポック(または0ミリ秒)の時点でのBSON日付の別の表現を使用した操作。ここで、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
の調査によって決定された「範囲」の最も便利な出力です。 クエリの指定された日付範囲の指定されたタイムゾーンのオフセット「カットポイント」を表します。
$cond
の「ネストされた」実装を使用して、以前のバージョンのMongoDBに同じロジックを適用することができます。 代わりに、実装するのが少し面倒なので、ここでは実装で最も便利な方法を使用しています。
これらの条件がすべて適用されると、「集計」された日付は、実際には、提供されたlocale
によって定義された「ローカル」時間を表す日付になります。 。これにより、実際には、最終的な集計段階とは何か、それが存在する理由、およびリストに示されている後の処理がわかります。
最終結果
一般的な推奨事項は、「出力」が少なくとも何らかの説明のUTC形式で日付値を返す必要があることです。したがって、ここでのパイプラインは、最初に「from」UTCをローカルに変換することによって正確に実行しています。 「丸め」時にオフセットを適用しますが、「グループ化後」の最終的な数値は、「丸められた」日付値に適用されるのと同じオフセットで再調整されます。
ここにリストされているのは、次のように「3つの」異なる出力の可能性を示しています。
// 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
}
]
ここで注意すべきことの1つは、Angularなどの「クライアント」の場合、これらの形式のすべてが、実際に「ロケール形式」を実行できる独自のDatePipeによって受け入れられることです。ただし、データの提供先によって異なります。 「適切な」ライブラリは、現在のロケールでUTC日付を使用していることを認識します。そうでない場合は、自分で「文字列化」する必要があるかもしれません。
しかし、これは単純なことであり、基本的に「指定されたUTC値」からの出力の操作に基づくライブラリを使用することで、これを最大限にサポートできます。
ここでの主なことは、ローカルタイムゾーンに集約するなどの質問をするときに「自分が何をしているのかを理解する」ことです。このようなプロセスでは、次のことを考慮する必要があります。
-
データは、さまざまなタイムゾーン内の人々の視点から表示される可能性があり、多くの場合表示されます。
-
データは通常、さまざまなタイムゾーンの人々によって提供されます。ポイント1と組み合わせると、UTCで保存する理由になります。
-
タイムゾーンは、世界の多くのタイムゾーンで「夏時間」から「夏時間」に変更されることがよくあります。データを分析および処理するときは、そのことを考慮する必要があります。
-
集約間隔に関係なく、提供されたロケールに従って間隔で集約するように調整されていても、出力は実際にはUTCのままである必要があります。これにより、プレゼンテーションは「クライアント」機能に委任されたままになります。
これらのことを念頭に置いて、ここに示すリストのように適用する限り、特定のロケールに関する日付の集計や一般的なストレージを処理するためにすべての正しいことを行っています。
したがって、これを「行うべき」であり、「すべきでない」ことは、あきらめて「ロケールの日付」を文字列として格納することです。説明したように、これは非常に誤ったアプローチであり、アプリケーションにさらなる問題を引き起こすだけです。
注 :ここでまったく触れていないトピックの1つは、「月」(または実際には「年」)に集約することです。 間隔。 「月」は、日数が常に変化し、適用するために他のすべてのロジックセットを必要とするため、プロセス全体の数学的異常です。それだけを説明することは、少なくともこの投稿と同じくらい長いので、別の主題になるでしょう。一般的なケースである一般的な分、時間、日については、ここでの計算はそれらのケースに「十分」です。
完全なリスト
これは、いじくり回すための「デモンストレーション」として機能します。必要な関数を使用して、含まれるオフセットの日付と値を抽出し、提供されたデータに対して集計パイプラインを実行します。
ここで何でも変更できますが、おそらく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();
}
})()