Общ проблем за справяне с "местни дати"
Така че има кратък отговор на това и дълъг отговор. Основният случай е, че вместо да използвате някой от "операторите за агрегиране на дати", вместо това вместо това искате и "трябва" всъщност да "извършите математиката" на обектите за дата. Основното нещо тук е да коригирате стойностите чрез отместване от 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
}
Където, разбира се, предметите са много по-голямо по отношение на 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 }] : []
)
,[]);
}
Разглеждайки цялата 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 като част от конвейер за агрегиране.
Прилагане на математиката
Математическият принцип тук за агрегиране към всеки „закръглен интервал от дата“ по същество разчита на използването на стойността в милисекунди на представената дата, която се „закръглява“ надолу до най-близкото число, представляващо необходимия „интервал“.
По същество правите това, като намирате "модула" или "остана" от текущата стойност, приложена към необходимия интервал. След това "изваждате" този остатък от текущата стойност, която връща стойност в най-близкия интервал.
Например, като се има предвид текущата дата:
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
операция с друго представяне на дата 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, който всъщност може да направи "locale format" вместо вас. Но зависи от това къде се доставят данните. „Добрите“ библиотеки ще са наясно с използването на UTC дата в настоящия локал. Когато това не е така, може да се наложи да се "нанизвате".
Но това е просто нещо и вие получавате най-голяма поддръжка за това, като използвате библиотека, която по същество основава манипулирането й на изхода от „дадена UTC стойност“.
Основното тук е да "разбирате какво правите", когато питате такова нещо като обобщаване към местна часова зона. Такъв процес трябва да вземе предвид:
-
Данните могат да бъдат и често се разглеждат от гледна точка на хора в различни часови зони.
-
Данните обикновено се предоставят от хора в различни часови зони. В комбинация с точка 1, ето защо съхраняваме в UTC.
-
Часовите зони често са обект на промяна на „отместване“ от „лятно часово време“ в много от световните часови зони и трябва да вземете предвид това, когато анализирате и обработвате данните.
-
Независимо от интервалите на агрегиране, изходът "трябва" всъщност да остане в UTC, макар и коригиран да се агрегира на интервал според предоставения локал. Това оставя презентацията да бъде делегирана на функция "клиент", точно както трябва.
Докато имате предвид тези неща и прилагате точно както показва списъкът тук, тогава правите всички правилни неща за справяне с агрегирането на дати и дори общото съхранение по отношение на даден локал.
Така че вие "трябва" да правите това, а това, което "не трябва" да правите, е да се откажете и просто да съхранявате "locale date" като низ. Както е описано, това би било много неправилен подход и не причинява нищо освен допълнителни проблеми за вашето приложение.
ЗАБЕЛЕЖКА :Единствената тема, която изобщо не засягам тук, е обобщаването до "месец" (или наистина "година") интервал. „Месеците“ са математическата аномалия в целия процес, тъй като броят на дните винаги варира и по този начин изисква съвсем друг набор от логика, за да се приложи. Описването само по себе си е поне толкова дълго, колкото и тази публикация и следователно би било друга тема. За общи минути, часове и дни, което е често срещан случай, математиката тук е „достатъчно добра“ за тези случаи.
Пълен списък
Това служи като "демонстрация" за бърникане. Той използва необходимата функция за извличане на изместените дати и стойности, които да бъдат включени, и изпълнява конвейер за агрегация върху предоставените данни.
Можете да промените всичко тук, но вероятно ще започне с 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();
}
})()