Obecný problém nakládání s "místními daty"
Takže na to je krátká odpověď a také dlouhá odpověď. Základním případem je, že namísto použití kteréhokoli z „operátorů agregace dat“ místo toho spíše chcete a „potřebujete“ ve skutečnosti „počítat“ na datech. Primární je zde upravit hodnoty o offset od UTC pro dané místní časové pásmo a následně „zaokrouhlit“ na požadovaný interval.
„Mnohem delší odpověď“ a také hlavní problém, který je třeba vzít v úvahu, spočívá v tom, že data často podléhají změnám „letního času“ v offsetu od UTC v různých obdobích roku. To tedy znamená, že při převodu na „místní čas“ pro účely takové agregace byste skutečně měli zvážit, kde existují hranice pro takové změny.
Existuje také další úvaha, že bez ohledu na to, co uděláte pro "agregaci" v daném intervalu, výstupní hodnoty "by měly" alespoň zpočátku vycházet jako UTC. To je dobrá praxe, protože zobrazení do „lokálního prostředí“ je skutečně „funkce klienta“, a jak bylo popsáno později, klientská rozhraní budou mít běžně způsob zobrazení v aktuálním národním prostředí, který bude založen na předpokladu, že bylo ve skutečnosti napájeno. data jako UTC.
Určení místního posunu a letního času
Toto je obecně hlavní problém, který je třeba vyřešit. Obecná matematika pro „zaokrouhlení“ data na interval je jednoduchá část, ale neexistuje žádná skutečná matematika, kterou byste mohli použít, abyste věděli, kdy takové hranice platí, a pravidla se mění v každém národním prostředí a často každý rok.
Zde tedy přichází na řadu „knihovna“ a nejlepší možností zde podle názoru autorů pro platformu JavaScript je moment-timezone, což je v podstatě „nadmnožina“ moment.js včetně všech důležitých funkcí „timezeone“, které chceme. použít.
Moment Timezone v podstatě definuje takovou strukturu pro každé časové pásmo místního nastavení jako:
{
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
}
Kde jsou samozřejmě předmětyhodně větší s ohledem na untils
a offsets
skutečně zaznamenané vlastnosti. Ale to jsou data, ke kterým potřebujete mít přístup, abyste viděli, zda skutečně došlo ke změně offsetu pro zónu při změnách letního času.
Tento blok pozdějšího výpisu kódu je to, co v podstatě používáme k určení daného start
a end
hodnota pro rozsah, které hranice letního času jsou překročeny, pokud existují:
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 }] : []
)
,[]);
}
Při pohledu na celý rok 2017 Australia/Sydney
locale, výstup by byl:
[
{
"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"
}
]
Což v podstatě odhaluje, že mezi první sekvencí dat by byl posun +11 hodin, pak se změnil na +10 hodin mezi daty ve druhé sekvenci a pak se přepnul zpět na +11 hodin pro interval pokrývající konec roku a zadaný rozsah.
Tuto logiku je pak potřeba převést do struktury, kterou bude MongoDB chápat jako součást agregačního potrubí.
Použití matematiky
Matematický princip zde pro agregaci na jakýkoli „interval zaokrouhleného data“ v podstatě spoléhá na použití milisekundové hodnoty reprezentovaného data, která je „zaokrouhlena“ dolů na nejbližší číslo představující požadovaný „interval“.
V podstatě to uděláte nalezením "modulo" nebo "zbytku" aktuální hodnoty aplikované na požadovaný interval. Potom tento zbytek "odečtete" od aktuální hodnoty, která vrátí hodnotu v nejbližším intervalu.
Například vzhledem k aktuálnímu datu:
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
Toto je obecná matematika, kterou také musíme použít v agregačním kanálu pomocí $subtract
a $mod
operace, což jsou agregační výrazy používané pro stejné matematické operace uvedené výše.
Obecná struktura agregačního kanálu je pak:
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 } }
];
Hlavní část, kterou zde musíte pochopit, je převod z Date
objekt uložený v MongoDB na Numeric
představující hodnotu vnitřního časového razítka. Potřebujeme "číselnou" formu a k tomu je matematický trik, kdy odečteme jedno BSON Datum od druhého, čímž se získá číselný rozdíl mezi nimi. Toto je přesně to, co dělá toto prohlášení:
{ "$subtract": [ "$createdAt", new Date(0) ] }
Nyní máme číselnou hodnotu, se kterou se musíme vypořádat, můžeme použít modulo a odečíst jej od číselné reprezentace data, abychom jej „zaokrouhlili“. Takže "přímá" reprezentace tohoto je jako:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
Což odráží stejný matematický přístup JavaScriptu, jaký byl uveden dříve, ale aplikovaný na skutečné hodnoty dokumentu v agregačním kanálu. Všimněte si také dalšího "triku", kde aplikujeme $add
operace s jinou reprezentací data BSON z epochy (nebo 0 milisekund), kde "přidání" data BSON k "číselné" hodnotě vrátí "Datum BSON" představující milisekundy, které bylo zadáno jako vstup.
Samozřejmě dalším aspektem v uvedeném kódu je skutečný "offset" od UTC, který upravuje číselné hodnoty, aby se zajistilo "zaokrouhlení" pro aktuální časové pásmo. To je implementováno ve funkci založené na dřívějším popisu zjištění, kde se vyskytují různé offsety, a vrací formát, který je použitelný ve výrazu agregačního kanálu porovnáním vstupních dat a vracením správného offsetu.
S úplným rozšířením všech detailů, včetně generování manipulace s těmito různými časovými posuny „letního času“ by pak vypadalo takto:
[
{
"$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
}
}
]
Toto rozšíření používá $switch
za účelem použití rozsahů dat jako podmínek, kdy vrátit dané hodnoty offsetu. Toto je nejpohodlnější forma od "branches"
argument přímo odpovídá "pole", což je nejpohodlnější výstup "rozsahů" určených zkoumáním untils
představující offsetové „mezní body“ pro dané časové pásmo v dodaném časovém období dotazu.
Je možné použít stejnou logiku v dřívějších verzích MongoDB pomocí "vnořené" implementace $cond
místo toho, ale implementace je trochu komplikovanější, takže zde používáme nejpohodlnější metodu implementace.
Jakmile jsou všechny tyto podmínky aplikovány, „agregovaná“ data jsou ve skutečnosti ta, která představují „místní“ čas definovaný dodaným locale
. To nás ve skutečnosti přivádí k tomu, co je konečná fáze agregace, a důvod, proč tam je, stejně jako pozdější zpracování, jak je ukázáno ve výpisu.
Ukončit výsledky
Již jsem zmínil, že obecné doporučení je, že „výstup“ by měl stále vracet hodnoty data ve formátu UTC alespoň nějakého popisu, a proto je to přesně to, co zde potrubí dělá tím, že nejprve převede „z“ UTC na místní pomocí použití posunu při "zaokrouhlení", ale poté jsou konečná čísla "po seskupení" znovu upravena zpět o stejný posun, který platí pro "zaokrouhlené" hodnoty data.
Zde uvedený výpis poskytuje "tři" různé výstupní možnosti jako:
// 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
}
]
Jedna věc, kterou zde stojí za zmínku, je, že pro „klienta“, jako je Angular, by každý z těchto formátů akceptoval jeho vlastní DatePipe, který za vás může ve skutečnosti udělat „lokální formát“. Ale záleží na tom, kam jsou data dodávána. "Dobré" knihovny si budou vědomy použití data UTC v aktuálním národním prostředí. Pokud tomu tak není, možná se budete muset "zavětřit".
Ale je to jednoduchá věc a největší podporu pro to získáte použitím knihovny, která v podstatě zakládá její manipulaci s výstupem z "dané hodnoty UTC".
Hlavní věcí je zde „rozumět tomu, co děláte“, když se ptáte na takovou věc, jako je agregace do místního časového pásma. Takový proces by měl vzít v úvahu:
-
Data mohou být a často jsou nahlížena z pohledu lidí v různých časových pásmech.
-
Data obvykle poskytují lidé v různých časových pásmech. V kombinaci s bodem 1 to je důvod, proč ukládáme v UTC.
-
Časová pásma v mnoha světových časových pásmech často podléhají měnícímu se „posunu“ od „letního času“ a měli byste s tím počítat při analýze a zpracování dat.
-
Bez ohledu na agregační intervaly by výstup „měl“ ve skutečnosti zůstat v UTC, i když byl upraven tak, aby se agregoval v intervalu podle poskytnutého národního prostředí. Prezentace tak může být delegována na „klientskou“ funkci, jak by měla.
Pokud budete mít tyto věci na paměti a použijete je přesně tak, jak ukazuje zde uvedený seznam, pak děláte všechny správné věci pro řešení agregace dat a dokonce i obecného úložiště s ohledem na dané národní prostředí.
Takže byste to „měli“ dělat a co byste „neměli“ dělat, je vzdát se a jednoduše uložit „místní datum“ jako řetězec. Jak bylo popsáno, byl by to velmi nesprávný přístup a nezpůsobuje vaší aplikaci nic jiného než další problémy.
POZNÁMKA :Jediné téma, kterého se zde vůbec nedotýkám, je agregace na „měsíc“ (nebo skutečně „rok“ ) interval. "Měsíce" jsou matematickou anomálií v celém procesu, protože počet dní se vždy liší, a proto vyžaduje celou jinou sadu logiky, aby se dal použít. Samotné popisování je minimálně stejně dlouhé jako tento příspěvek, a proto by bylo na další téma. Pro obecné minuty, hodiny a dny, což je běžný případ, je zde matematika pro tyto případy „dost dobrá“.
Úplný seznam
To slouží jako "demonstrace" k šťouchání. Využívá požadovanou funkci k extrakci offsetových dat a hodnot, které mají být zahrnuty, a spouští agregační kanál přes dodaná data.
Zde můžete změnit cokoli, ale pravděpodobně to bude začínat locale
a interval
parametry a pak možná přidat jiná data a jiný start
a end
termíny pro dotaz. Zbytek kódu však není třeba měnit, abyste jednoduše provedli změny kterékoli z těchto hodnot, a proto lze demonstrovat pomocí různých intervalů (například 1 hour
jak je položeno v otázce ) a různá národní prostředí.
Například po zadání platných dat, která by ve skutečnosti vyžadovala agregaci v „1hodinovém intervalu“, by se řádek ve výpisu změnil takto:
const interval = moment.duration(1,'hour').asMilliseconds();
Aby bylo možné definovat hodnotu v milisekundách pro interval agregace, jak to vyžadují operace agregace prováděné v datech.
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();
}
})()