sql >> Database teknologi >  >> NoSQL >> MongoDB

Gruppér efter dato med lokal tidszone i MongoDB

Generelt problem med at håndtere "lokale datoer"

Så der er et kort svar på dette og også et langt svar. Grundsagen er, at i stedet for at bruge nogen af ​​"datoaggregationsoperatorerne" vil du i stedet hellere have lyst og "nødvendigt" faktisk "regne" på datoobjekterne i stedet for. Det primære her er at justere værdierne med offset fra UTC for den givne lokale tidszone og derefter "runde" til det påkrævede interval.

Det "meget længere svar" og også det største problem at overveje involverer, at datoer ofte er underlagt "sommertid"-ændringer i offset fra UTC på forskellige tidspunkter af året. Så det betyder, at når du konverterer til "lokal tid" til sådanne sammenlægningsformål, bør du virkelig overveje, hvor grænserne for sådanne ændringer findes.

Der er også en anden overvejelse, nemlig at uanset hvad du gør for at "aggregere" ved et givet interval, "skal" outputværdierne i det mindste til at begynde med komme ud som UTC. Dette er god praksis, da visning til "lokalitet" virkelig er en "klientfunktion", og som senere beskrevet vil klientgrænseflader almindeligvis have en måde at blive vist på i den nuværende lokalitet, som vil være baseret på den forudsætning, at den faktisk blev leveret data som UTC.

Bestemmelse af lokalitetsforskydning og sommertid

Dette er generelt hovedproblemet, der skal løses. Den generelle matematik for at "afrunde" en dato til et interval er den simple del, men der er ingen rigtig matematik, du kan anvende for at vide, hvornår sådanne grænser gælder, og reglerne ændres i alle lokaliteter og ofte hvert år.

Så det er her et "bibliotek" kommer ind, og den bedste mulighed her efter forfatterens mening for en JavaScript-platform er moment-timezone, som dybest set er et "supersæt" af moment.js inklusive alle de vigtige "timezeone"-funktioner, vi ønsker. at bruge.

Moment Timezone definerer dybest set en sådan struktur for hver lokale tidszone som:

{ name :'America/Los_Angeles', // den unikke identifikator abbrs :['PDT', 'PST'], // forkortelserne indtil:[1414918800000, 1425808800000], // tidsstemplerne i millisekunder offsets :[420, 480] // forskydningerne i minutter 

Hvor objekterne selvfølgelig er meget større i forhold til indtil og offsets faktisk registrerede ejendomme. Men det er de data, du skal have adgang til for at se, om der faktisk er en ændring i forskydningen for en zone givet sommertid.

Denne blok af den senere kodeliste er, hvad vi grundlæggende bruger til at bestemme givet en start og end værdi for et interval, hvilke sommertid grænser krydses, hvis nogen:

const zone =moment.tz.zone(locale); if ( zone.hasOwnProperty('untils') ) { let between =zone.untils.filter( u => u>=start.valueOf() &&u 0 ) branchs =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 }] :[] ), []); }

Ser vi på hele 2017 for Australien/Sydney lokalitet outputtet af dette ville være:

[ { "start":"2016-12-31T13:00:00.000Z", // Interval er +11 timer her "end":"2017-04-01T16:00:00.000Z" } , { "start":"2017-04-01T16:00:00.000Z", // Ændringer til +10 timer her "end":"2017-09-30T16:00:00.000Z" }, { "start":"2017-09-30T16:00:00.000Z", // Ændrer tilbage til +11 timer her "end":"2017-12-31T13:00:00.000Z" }] 

Hvilket dybest set afslører, at mellem den første sekvens af datoer vil forskydningen være +11 timer og derefter ændres til +10 timer mellem datoerne i den anden sekvens og derefter skifte tilbage til +11 timer for intervallet, der dækker til slutningen af ​​året og specificeret område.

Denne logik skal så oversættes til en struktur, der vil blive forstået af MongoDB som en del af en aggregeringspipeline.

Anvendelse af matematikken

Det matematiske princip her for aggregering til ethvert "afrundet datointerval" er i det væsentlige afhængig af brugen af ​​millisekunderværdien af ​​den repræsenterede dato, som er "afrundet" ned til det nærmeste tal, der repræsenterer det påkrævede "interval".

Det gør du i det væsentlige ved at finde "modulo" eller "resten" af den aktuelle værdi anvendt på det krævede interval. Derefter "trækker" du resten fra den aktuelle værdi, som returnerer en værdi med det nærmeste interval.

For eksempel givet den aktuelle dato:

 var d =new Date("2017-07-14T01:28:34.931Z"); // toValue() er 1499995714931 millis // 1000 millisekunder * 60 sekunder * 60 minutter =1 time eller 3600000 millis var v =d.valueOf() - ( d.valueOf() % ( 1000 * 0 60 ) * ); // v er lig med 1499994000000 millis eller som en dato ny Dato(1499994000000); ISODate("2017-07-14T01:00:00Z") // som fjernede de 28 minutter og skiftede til nærmeste 1 times interval 

Dette er den generelle matematik, vi også skal anvende i aggregeringspipelinen ved hjælp af $subtract og $mod operationer, som er de aggregeringsudtryk, der bruges til de samme matematiske operationer som vist ovenfor.

Den generelle struktur af aggregeringspipelinen er da:

 let pipeline =[ { "$match":{ "createdAt":{ "$gte":start.toDate(), "$lt":end.toDate() } }}, { "$ group":{ "_id":{ "$add":[ { "$subtract":[ { "$subtract":[ { "$subtract":[ "$createdAt", ny dato(0) ] }, switchOffset (start, slut,"$createdAt",false) ]}, { "$mod":[ { "$subtract":[ { "$subtract":[ "$createdAt", new Date(0) ] }, switchOffset (start, slut,"$createdAt",false) ]}, interval ]} ]}, ny Dato(0) ] }, "amount":{ "$sum":"$amount" } }}, { "$ addFields":{ "_id":{ "$add":[ "$_id", switchOffset(start,end,"$_id",true) ] }}, { "$sort":{ "_id":1 } } ]; 

De vigtigste dele her, du skal forstå, er konverteringen fra en Dato objekt som gemt i MongoDB til Numeric repræsenterer den interne tidsstempelværdi. Vi har brug for den "numeriske" form, og for at gøre dette er et matematiktrick, hvor vi trækker en BSON-dato fra en anden, hvilket giver den numeriske forskel mellem dem. Det er præcis, hvad denne erklæring gør:

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

Nu har vi en numerisk værdi at forholde sig til, vi kan anvende modulo og trække den fra den numeriske repræsentation af datoen for at "runde" den. Så den "lige" repræsentation af dette er som:

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

Hvilket afspejler den samme JavaScript-matematiktilgang som vist tidligere, men anvendt på de faktiske dokumentværdier i aggregeringspipelinen. Du vil også bemærke det andet "trick" der, hvor vi anvender en $add operation med en anden repræsentation af en BSON-dato fra epoken (eller 0 millisekunder), hvor "tilføjelsen" af en BSON-dato til en "numerisk" værdi, returnerer en "BSON-dato", der repræsenterer de millisekunder, den blev givet som input.

Selvfølgelig er den anden overvejelse i den listede kode den faktiske "offset" fra UTC, som justerer de numeriske værdier for at sikre, at "afrundingen" finder sted for den nuværende tidszone. Dette er implementeret i en funktion baseret på den tidligere beskrivelse af at finde, hvor de forskellige forskydninger forekommer, og returnerer et format som kan bruges i et aggregeringspipelineudtryk ved at sammenligne inputdatoer og returnere den korrekte forskydning.

Med den fulde udvidelse af alle detaljer, inklusive generering af håndtering af de forskellige "Sommertid" ville tidsforskydninger være som:

[ { "$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":[ "$createdA t", "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 } }] 

Denne udvidelse bruger $switch erklæring for at anvende datointervallerne som betingelser for, hvornår de givne offsetværdier skal returneres. Dette er den mest bekvemme form siden "grenene" argument svarer direkte til et "array", som er det mest bekvemme output af "intervallerne" bestemt ved undersøgelse af til repræsenterer offset "cut-points" for den givne tidszone på det angivne datointerval for forespørgslen.

Det er muligt at anvende den samme logik i tidligere versioner af MongoDB ved hjælp af en "indlejret" implementering af $cond i stedet, men det er lidt mere rodet at implementere, så vi bruger bare den mest bekvemme metode til implementering her.

Når alle disse betingelser er anvendt, er datoerne "aggregeret" faktisk dem, der repræsenterer den "lokale" tid som defineret af den leverede locale . Dette bringer os faktisk til, hvad det endelige aggregeringsstadium er, og grunden til, at det er der, samt den senere håndtering, som vist i listen.

Slutresultater

Jeg nævnte tidligere, at den generelle anbefaling er, at "outputtet" stadig skal returnere datoværdierne i UTC-format med i det mindste en vis beskrivelse, og derfor er det præcis, hvad pipelinen her gør ved først at konvertere "fra" UTC til lokal ved at anvender forskydningen ved "afrunding", men derefter justeres de endelige tal "efter grupperingen" tilbage med den samme forskydning, der gælder for de "afrundede" datoværdier.

Listen her giver "tre" forskellige outputmuligheder her som:

// ISO-formatstreng fra 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 }]// Tidsstempelværdi - millisekunder fra epoke UTC - mindste plads![ { "_id":1483189200000, "amount":2 }, { "_id":1483275600000, "amount":1 }, { "_id":1483362000000, "amount"/:Force } lokalitetsformat til streng 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 }] 

Den ene ting at bemærke her er, at for en "klient" såsom Angular, vil hver enkelt af disse formater blive accepteret af sin egen DatePipe, som faktisk kan lave "lokalformatet" for dig. Men det afhænger af, hvor dataene bliver leveret til. "Gode" biblioteker vil være opmærksomme på at bruge en UTC-dato i den nuværende lokalitet. Hvor det ikke er tilfældet, så skal du måske "strenge" dig selv.

Men det er en simpel ting, og du får mest støtte til dette ved at bruge et bibliotek, som i det væsentlige baserer dets manipulation af output fra en "given UTC-værdi".

Det vigtigste her er at "forstå, hvad du laver", når du spørger sådan noget som at aggregere til en lokal tidszone. En sådan proces bør overveje:

  1. Dataene kan og er ofte set fra personers perspektiv inden for forskellige tidszoner.

  2. Dataene leveres generelt af personer i forskellige tidszoner. Kombineret med punkt 1 er det derfor, vi gemmer i UTC.

  3. Tidszoner er ofte underlagt en skiftende "offset" fra "Sommertid" i mange af verdens tidszoner, og du bør tage højde for det, når du analyserer og behandler dataene.

  4. Uanset aggregeringsintervaller, "bør" output faktisk forblive i UTC, omend justeret for at aggregere på interval i henhold til den angivne lokalitet. Dette lader præsentationen delegeres til en "klient"-funktion, præcis som den skal.

Så længe du har disse ting i tankerne og anvender ligesom listen her viser, så gør du alle de rigtige ting for at håndtere sammenlægning af datoer og endda generel lagring med hensyn til en given lokalitet.

Så du "burde" gøre dette, og hvad du "ikke burde" gøre er at give op og simpelthen gemme "lokaldatoen" som en streng. Som beskrevet ville det være en meget forkert tilgang og forårsager intet andet end yderligere problemer for din ansøgning.

BEMÆRK :Det ene emne, jeg slet ikke berører her, er sammenlægning til en "måned" (eller faktisk "år") interval. "Måneder" er den matematiske anomali i hele processen, da antallet af dage altid varierer og derfor kræver et helt andet sæt logik for at kunne anvendes. At beskrive det alene er mindst lige så langt som dette indlæg, og det ville derfor være et andet emne. For generelle minutter, timer og dage, som er det almindelige tilfælde, er matematikken her "god nok" til disse tilfælde.

Fuld fortegnelse

Dette tjener som en "demonstration" at pille ved. Den anvender den nødvendige funktion til at udtrække offsetdatoer og værdier, der skal inkluderes, og kører en aggregeringspipeline over de leverede data.

Du kan ændre hvad som helst her, men vil sandsynligvis starte med locale og interval parametre, og derefter måske tilføje forskellige data og anden start og end datoer for forespørgslen. Men resten af ​​koden behøver ikke ændres for blot at foretage ændringer til nogen af ​​disse værdier, og kan derfor demonstreres ved brug af forskellige intervaller (såsom 1 time som stillet i spørgsmålet ) og forskellige lokaliteter.

For eksempel, når gyldige data, som faktisk ville kræve aggregering med et "1 times interval", så linjen i listen blive ændret som:

konst interval =moment.duration(1,'time').asMilliseconds(); 

For at definere en millisekundværdi for aggregeringsintervallet som krævet af de aggregeringsoperationer, der udføres på datoerne.

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) ))}funktionsskiftOffset(start,slut,felt,omvendtOffset) { lad branches =[{ start, end }] const zone =moment.tz.zone(locale); if ( zone.hasOwnProperty('untils') ) { let between =zone.untils.filter( u => u>=start.valueOf() &&u  0 ) branchs =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(grene); 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) ) ]} ] }, derefter:-1 * moment.duration(d.start.utcOffset(),'minutes').asMilliseconds() })); return ({ $switch:{ branches } });}(async function() { prøv { const conn =await mongoose.connect(uri,options); // Dataoprydning afventer Promise.all( Object.keys(conn.models) ).map( m => conn.models[m].remove({})) ); lad indsat =await Report.insertMany([ { createdAt:moment.tz("2017-01-01",locale), beløb :1 }, { createdAt:moment.tz("2017-01-01",locale), beløb:1 }, { createdAt:moment.tz("2017-01-02",locale), beløb:1 }, { createdAt:moment.tz("2017-01-03",locale), beløb:1 }, { createdAt:moment.tz("2017-01-03",locale), beløb:1 }, ]); log (indsat); const start =moment.tz("2017-01-01", locale) end =moment.tz("2018-01-01", locale) lad pipeline =[ { "$match":{ "createdAt ":{ "$gte":start.toDate(), "$lt":end.toDate() } }}, { "$group":{ "_id":{ "$add":[ { "$subtract ":[ { "$subtract":[ { "$subtract":[ "$createdAt", ny dato(0) ] }, switchOffset(start,end,"$createdAt",false) ]}, { "$mod":[ { "$subtract":[ { "$subtract":[ "$createdAt", ny dato(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(rørledning); lad resultater =afvent Report.aggregate(pipeline); // log raw Dato-objekter, vil stringify som UTC i JSON-log(resultater); // Jeg kan godt lide at udlæse tidsstempelværdier og lade klienten formatere resultater =results.map( d => Object.assign(d, { _id:d._id.valueOf() }) ); log(resultater); // Eller brug moment til at formatere output for locale som en streng results =results.map( d => Object.assign(d, { _id:moment.tz(d._id, locale).format() } ) ); log(resultater); } catch(e) { console.error(e); } endelig { mongoose.disconnect(); }})() 


  1. Giver Mongoose adgang til tidligere værdi af ejendom i pre('save')?

  2. dvale cache på andet niveau med Redis - vil det forbedre ydeevnen?

  3. Hvordan forbinder du til et replikasæt fra en MongoDB-skal?

  4. Sådan bruger du Spring Boot med MongoDB