Algemeen probleem van omgaan met "lokale datums"
Hierop is dus een kort antwoord en ook een lang antwoord. Het basisgeval is dat in plaats van een van de "datumaggregatie-operatoren" te gebruiken, u in plaats daarvan liever "de wiskunde" wilt en "moet" doen op de datumobjecten. Het belangrijkste hier is om de waarden aan te passen met de verschuiving van UTC voor de gegeven lokale tijdzone en vervolgens "af te ronden" naar het vereiste interval.
Het "veel langere antwoord" en ook het belangrijkste probleem om te overwegen, is dat datums vaak onderhevig zijn aan "zomertijd"-veranderingen in de offset van UTC op verschillende tijdstippen van het jaar. Dit betekent dus dat u bij het converteren naar "lokale tijd" voor dergelijke aggregatiedoeleinden echt moet overwegen waar de grenzen voor dergelijke wijzigingen liggen.
Er is ook een andere overweging, namelijk dat wat u ook doet om met een bepaald interval te "aggregeren", de uitvoerwaarden in ieder geval in eerste instantie als UTC moeten verschijnen. Dit is een goede gewoonte, aangezien weergave naar "locale" echt een "clientfunctie" is, en zoals later wordt beschreven, zullen de clientinterfaces gewoonlijk een manier hebben om in de huidige locale weer te geven, gebaseerd op het uitgangspunt dat het in feite werd gevoed gegevens als UTC.
Lokale offset en zomertijd bepalen
Dit is over het algemeen het belangrijkste probleem dat moet worden opgelost. De algemene wiskunde voor het "afronden" van een datum naar een interval is het eenvoudige deel, maar er is geen echte wiskunde die u kunt toepassen om te weten wanneer dergelijke grenzen van toepassing zijn, en de regels veranderen in elke landinstelling en vaak elk jaar.
Dus dit is waar een "bibliotheek" binnenkomt, en de beste optie hier naar de mening van de auteurs voor een JavaScript-platform is moment-tijdzone, wat in feite een "superset" is van moment.js inclusief alle belangrijke "timezeone" -functies die we willen te gebruiken.
Moment Timezone definieert in principe een dergelijke structuur voor elke lokale tijdzone als:
{
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
}
Waar natuurlijk de objecten veel zijn groter ten opzichte van de untils
en offsets
eigenschappen die daadwerkelijk zijn geregistreerd. Maar dat zijn de gegevens die u nodig hebt om te zien of er daadwerkelijk een wijziging is in de offset voor een zone, gegeven veranderingen in de zomertijd.
Dit blok van de latere codelijst is wat we in principe gebruiken om te bepalen gegeven een start
en end
waarde voor een bereik, welke grenzen voor zomertijd worden overschreden, indien van toepassing:
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 }] : []
)
,[]);
}
Kijkend naar heel 2017 voor de Australia/Sydney
locale de output hiervan zou zijn:
[
{
"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"
}
]
Wat in feite onthult dat tussen de eerste reeks datums de verschuiving +11 uur zou zijn, dan verandert in +10 uur tussen de datums in de tweede reeks en dan terugschakelt naar +11 uur voor het interval tot het einde van het jaar en de gespecificeerd bereik.
Deze logica moet vervolgens worden vertaald in een structuur die door MongoDB zal worden begrepen als onderdeel van een aggregatiepijplijn.
De wiskunde toepassen
Het wiskundige principe hier voor het aggregeren tot een "afgerond datuminterval" is in wezen gebaseerd op het gebruik van de millisecondenwaarde van de weergegeven datum die wordt "afgerond" naar het dichtstbijzijnde getal dat het vereiste "interval" vertegenwoordigt.
U doet dit in wezen door de "modulo" of "rest" van de huidige waarde te vinden die op het vereiste interval is toegepast. Vervolgens "trekt" u dat restant van de huidige waarde af, wat een waarde oplevert met het dichtstbijzijnde interval.
Bijvoorbeeld, gezien de huidige datum:
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
Dit is de algemene wiskunde die we ook moeten toepassen in de aggregatiepijplijn met behulp van de $subtract
en $mod
bewerkingen, dit zijn de aggregatie-uitdrukkingen die worden gebruikt voor dezelfde wiskundige bewerkingen als hierboven.
De algemene structuur van de aggregatiepijplijn is dan:
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 } }
];
De belangrijkste onderdelen die u hier moet begrijpen, is de conversie van een Date
object zoals opgeslagen in MongoDB naar Numeric
die de interne tijdstempelwaarde vertegenwoordigt. We hebben de "numerieke" vorm nodig, en om dit te doen is een wiskundig trucje waarbij we de ene BSON-datum van de andere aftrekken, wat het numerieke verschil tussen beide oplevert. Dit is precies wat deze verklaring doet:
{ "$subtract": [ "$createdAt", new Date(0) ] }
Nu hebben we een numerieke waarde om mee om te gaan, we kunnen de modulo toepassen en die aftrekken van de numerieke representatie van de datum om deze af te ronden. Dus de "rechte" weergave hiervan is als volgt:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
Die weerspiegelt dezelfde JavaScript-wiskundebenadering als eerder getoond, maar toegepast op de werkelijke documentwaarden in de aggregatiepijplijn. U zult daar ook de andere "truc" opmerken waar we een $add
. toepassen bewerking met een andere weergave van een BSON-datum vanaf epoche (of 0 milliseconden) waarbij de "toevoeging" van een BSON-datum aan een "numerieke" waarde een "BSON-datum" retourneert die de milliseconden vertegenwoordigt die het als invoer is opgegeven.
Natuurlijk is de andere overweging in de vermelde code de werkelijke "offset" van UTC die de numerieke waarden aanpast om ervoor te zorgen dat de "afronding" plaatsvindt voor de huidige tijdzone. Dit wordt geïmplementeerd in een functie die is gebaseerd op de eerdere beschrijving van het vinden van waar de verschillende offsets voorkomen, en retourneert een formaat dat bruikbaar is in een aggregatiepijplijnexpressie door de invoerdatums te vergelijken en de juiste offset te retourneren.
Met de volledige uitbreiding van alle details, inclusief het genereren van de verwerking van die verschillende "Zomertijd"-tijdverschuivingen, zou het dan zijn als:
[
{
"$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
}
}
]
Die uitbreiding gebruikt de $switch
statement om de datumbereiken toe te passen als voorwaarden voor het retourneren van de gegeven offsetwaarden. Dit is de handigste vorm sinds de "branches"
argument komt rechtstreeks overeen met een "array", wat de handigste uitvoer is van de "bereiken" die worden bepaald door onderzoek van de untils
die de offset "cut-points" vertegenwoordigen voor de gegeven tijdzone op het opgegeven datumbereik van de zoekopdracht.
Het is mogelijk om dezelfde logica toe te passen in eerdere versies van MongoDB met behulp van een "geneste" implementatie van $cond
in plaats daarvan, maar het is een beetje rommeliger om te implementeren, dus we gebruiken hier gewoon de handigste methode voor de implementatie.
Zodra al deze voorwaarden zijn toegepast, zijn de "geaggregeerde" datums in feite de datums die de "lokale" tijd vertegenwoordigen zoals gedefinieerd door de meegeleverde locale
. Dit brengt ons eigenlijk bij wat de laatste aggregatiefase is, en de reden waarom het er is, evenals de latere verwerking zoals aangetoond in de lijst.
Resultaten beëindigen
Ik heb eerder gezegd dat de algemene aanbeveling is dat de "uitvoer" nog steeds de datumwaarden in UTC-indeling van ten minste een bepaalde beschrijving moet retourneren, en daarom is dat precies wat de pijplijn hier doet door eerst "van" UTC naar lokaal te converteren door de offset toepassen bij "afronden", maar dan worden de definitieve getallen "na de groepering" opnieuw aangepast met dezelfde offset die van toepassing is op de "afgeronde" datumwaarden.
De lijst hier geeft hier "drie" verschillende uitvoermogelijkheden als:
// 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
}
]
Het enige dat hier opvalt, is dat voor een "client" zoals Angular, elk van die formaten zou worden geaccepteerd door zijn eigen DatePipe, die het "lokale formaat" voor u kan doen. Maar het hangt af van waar de gegevens aan worden geleverd. "Goede" bibliotheken zullen zich bewust zijn van het gebruik van een UTC-datum in de huidige landinstelling. Waar dat niet het geval is, moet u mogelijk zelf "stringenten".
Maar het is iets eenvoudigs, en u krijgt hiervoor de meeste ondersteuning door een bibliotheek te gebruiken die in wezen de manipulatie van uitvoer baseert op een "gegeven UTC-waarde".
Het belangrijkste hier is om te "begrijpen wat je doet" wanneer je zoiets als aggregeren naar een lokale tijdzone vraagt. Een dergelijk proces moet rekening houden met:
-
De gegevens kunnen en worden vaak bekeken vanuit het perspectief van mensen binnen verschillende tijdzones.
-
De gegevens worden over het algemeen aangeleverd door mensen in verschillende tijdzones. In combinatie met punt 1 slaan we daarom op in UTC.
-
Tijdzones zijn vaak onderhevig aan een veranderende "offset" van "Zomertijd" in veel van de wereldtijdzones, en u moet daar rekening mee houden bij het analyseren en verwerken van de gegevens.
-
Ongeacht de aggregatie-intervallen, "zou" de uitvoer in feite in UTC moeten blijven, zij het aangepast om op interval te aggregeren volgens de opgegeven landinstelling. Dit laat de presentatie over aan een "klant"-functie, precies zoals het hoort.
Zolang je die dingen in gedachten houdt en toepast, zoals de lijst hier laat zien, doe je alle juiste dingen voor het omgaan met aggregatie van datums en zelfs algemene opslag met betrekking tot een bepaalde landinstelling.
Dus je "zou" dit moeten doen, en wat je "niet" zou moeten doen is het opgeven en simpelweg de "lokale datum" opslaan als een string. Zoals beschreven, zou dat een zeer onjuiste benadering zijn en alleen maar verdere problemen veroorzaken voor uw toepassing.
OPMERKING :Het enige onderwerp waar ik hier helemaal niet op inga, is samenvoegen tot een "maand" (of inderdaad "jaar") interval. "Maanden" zijn de wiskundige anomalie in het hele proces, aangezien het aantal dagen altijd varieert en er dus een hele andere reeks logica nodig is om van toepassing te zijn. Alleen dat beschrijven is minstens zo lang als dit bericht, en zou daarom een ander onderwerp zijn. Voor algemene minuten, uren en dagen, wat gebruikelijk is, is de wiskunde hier "goed genoeg" voor die gevallen.
Volledige vermelding
Dit dient als een "demonstratie" om aan te sleutelen. Het gebruikt de vereiste functie om de op te nemen offsetdatums en -waarden te extraheren en voert een aggregatiepijplijn uit over de aangeleverde gegevens.
Je kunt hier alles veranderen, maar zal waarschijnlijk beginnen met de locale
en interval
parameters, en dan misschien andere gegevens en andere start
. toevoegen en end
datums voor de zoekopdracht. Maar de rest van de code hoeft niet te worden gewijzigd om eenvoudigweg wijzigingen aan te brengen in een van die waarden, en kan daarom worden gedemonstreerd met verschillende intervallen (zoals 1 hour
zoals gevraagd in de vraag ) en verschillende talen.
Als u bijvoorbeeld geldige gegevens aanlevert die eigenlijk moeten worden geaggregeerd met een "interval van 1 uur", wordt de regel in de lijst gewijzigd als:
const interval = moment.duration(1,'hour').asMilliseconds();
Om een millisecondenwaarde voor het aggregatie-interval te definiëren, zoals vereist door de aggregatiebewerkingen die op de datums worden uitgevoerd.
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();
}
})()