Ogólny problem radzenia sobie z „lokalnymi datami”
Jest więc krótka odpowiedź na to i długa odpowiedź. Podstawowym przypadkiem jest to, że zamiast używać któregokolwiek z „operatorów agregacji dat”, zamiast tego chcesz i „musisz” faktycznie „wykonać matematykę” na obiektach daty. Najważniejszą rzeczą jest tutaj dostosowanie wartości o przesunięcie względem czasu UTC dla danej lokalnej strefy czasowej, a następnie „zaokrąglenie” do wymaganego interwału.
„Dużo dłuższa odpowiedź”, a także główny problem do rozważenia, polega na tym, że daty często podlegają zmianom „czasu letniego” w przesunięciu względem UTC w różnych porach roku. Oznacza to, że podczas konwersji na „czas lokalny” dla takich celów agregacji, naprawdę powinieneś rozważyć, gdzie istnieją granice dla takich zmian.
Istnieje również inna uwaga, ponieważ bez względu na to, co zrobisz, aby „agregować” w danym przedziale, wartości wyjściowe „powinny” przynajmniej początkowo pojawić się jako UTC. Jest to dobra praktyka, ponieważ wyświetlanie do „locale” naprawdę jest „funkcją klienta”, a jak opisano później, interfejsy klienta będą zwykle wyświetlać w aktualnych ustawieniach regionalnych, które będą oparte na założeniu, że w rzeczywistości były karmione dane jako UTC.
Określanie przesunięcia regionalnego i czasu letniego
Jest to na ogół główny problem, który należy rozwiązać. Ogólna matematyka „zaokrąglania” daty do przedziału to prosta część, ale nie ma prawdziwej matematyki, którą można zastosować, aby wiedzieć, kiedy takie granice mają zastosowanie, a zasady zmieniają się w każdym miejscu i często co roku.
W tym miejscu pojawia się „biblioteka”, a najlepszą opcją według autorów dla platformy JavaScript jest moment-timezone, który jest w zasadzie „nadzbiorem” moment.js zawierającym wszystkie ważne funkcje „timezeone”, których potrzebujemy do użycia.
Strefa czasowa Moment zasadniczo definiuje taką strukturę dla każdej strefy czasowej lokalizacji, jak:
{
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
}
Gdzie oczywiście obiektów jest dużo większe w stosunku do untils
i offsets
właściwości faktycznie zarejestrowane. Ale to są dane, do których musisz uzyskać dostęp, aby sprawdzić, czy rzeczywiście nastąpiła zmiana przesunięcia dla strefy, biorąc pod uwagę zmiany czasu letniego.
Ten blok późniejszej listy kodów jest tym, czego zasadniczo używamy do określenia danego start
i end
wartość dla zakresu, w którym granice czasu letniego są przekraczane, jeśli takie istnieją:
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 }] : []
)
,[]);
}
Patrząc na cały rok 2017 dla Australia/Sydney
locale wyjściem tego byłoby:
[
{
"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 zasadniczo pokazuje, że między pierwszą sekwencją dat przesunięcie wyniesie +11 godzin, następnie zmieni się na +10 godzin między datami w drugiej sekwencji, a następnie przełączy się z powrotem na +11 godzin dla przedziału obejmującego koniec roku i określony zakres.
Ta logika musi następnie zostać przetłumaczona na strukturę, która będzie rozumiana przez MongoDB jako część potoku agregacji.
Stosowanie matematyki
Zasada matematyczna agregacji do dowolnego „zaokrąglonego przedziału dat” zasadniczo opiera się na użyciu wartości milisekund reprezentowanej daty, która jest „zaokrąglana” w dół do najbliższej liczby reprezentującej wymagany „przedział”.
Zasadniczo robisz to, znajdując „modulo” lub „reszta” bieżącej wartości zastosowanej do wymaganego interwału. Następnie „odejmujesz” tę resztę od bieżącej wartości, która zwraca wartość w najbliższym przedziale.
Na przykład, biorąc pod uwagę aktualną datę:
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
Jest to ogólna matematyka, którą musimy również zastosować w potoku agregacji za pomocą $subtract
i $mod
operacje, które są wyrażeniami agregacji używanymi w tych samych operacjach matematycznych pokazanych powyżej.
Ogólna struktura potoku agregacji to:
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 } }
];
Główne części, które musisz zrozumieć, to konwersja z Date
obiekt przechowywany w MongoDB do Numeric
reprezentujący wartość wewnętrznego znacznika czasu. Potrzebujemy formy „numerycznej”, a do tego jest sztuczka matematyczna, w której odejmujemy jedną datę BSON od drugiej, co daje różnicę liczbową między nimi. To jest dokładnie to, co robi to stwierdzenie:
{ "$subtract": [ "$createdAt", new Date(0) ] }
Teraz mamy do czynienia z wartością liczbową, możemy zastosować modulo i odjąć ją od liczbowej reprezentacji daty, aby ją „zaokrąglić”. Tak więc „prosta” reprezentacja tego jest taka:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
Który odzwierciedla to samo podejście matematyczne JavaScript, jak pokazano wcześniej, ale zastosowane do rzeczywistych wartości dokumentu w potoku agregacji. Zauważysz również inną „sztuczkę”, w której stosujemy $add
operacja z inną reprezentacją daty BSON z epoki (lub 0 milisekund), gdzie „dodanie” Daty BSON do wartości „numerycznej” zwraca „Datę BSON” reprezentującą milisekundy, które zostały podane jako dane wejściowe.
Oczywiście innym czynnikiem w wymienionym kodzie jest faktyczne „przesunięcie” względem czasu UTC, które dostosowuje wartości liczbowe w celu zapewnienia „zaokrąglenia” dla bieżącej strefy czasowej. Jest to zaimplementowane w funkcji opartej na wcześniejszym opisie znajdowania, gdzie występują różne przesunięcia, i zwraca format nadający się do użytku w wyrażeniu potoku agregacji przez porównanie dat wejściowych i zwrócenie prawidłowego przesunięcia.
Przy pełnym rozszerzeniu wszystkich szczegółów, w tym generowaniu obsługi tych różnych przesunięć czasu „Daylight Savings”, wyglądałoby to następująco:
[
{
"$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
}
}
]
To rozszerzenie używa $switch
oświadczenie w celu zastosowania zakresów dat jako warunków, kiedy zwrócić podane wartości przesunięcia. Jest to najwygodniejsza forma, ponieważ "branches"
argument odpowiada bezpośrednio "tablicy", która jest najwygodniejszym wynikiem "zakresów" określonych przez badanie untils
reprezentujące przesunięcie „punktów cięcia” dla danej strefy czasowej w podanym zakresie dat zapytania.
Możliwe jest zastosowanie tej samej logiki we wcześniejszych wersjach MongoDB przy użyciu "zagnieżdżonej" implementacji $cond
zamiast tego, ale implementacja jest trochę bardziej kłopotliwa, więc używamy tutaj po prostu najwygodniejszej metody implementacji.
Po zastosowaniu wszystkich tych warunków daty „zagregowane” są w rzeczywistości datami reprezentującymi czas „lokalny” zdefiniowany przez dostarczony locale
. To faktycznie prowadzi nas do ostatecznego etapu agregacji i przyczyny, dla którego się tam znajduje, a także do późniejszej obsługi, jak pokazano na liście.
Wyniki końcowe
Wspomniałem wcześniej, że ogólne zalecenie jest takie, że „wyjście” powinno nadal zwracać wartości dat w formacie UTC przynajmniej z pewnym opisem, a zatem dokładnie to robi tutaj potok, najpierw konwertując „z” czasu UTC na lokalny przez zastosowanie przesunięcia podczas „zaokrąglania”, ale wtedy końcowe liczby „po grupowaniu” są ponownie dostosowywane o to samo przesunięcie, które stosuje się do „zaokrąglonych” wartości dat.
Lista tutaj daje "trzy" różne możliwości wyjścia tutaj, jak:
// 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
}
]
Warto zwrócić uwagę na to, że w przypadku „klienta”, takiego jak Angular, każdy z tych formatów byłby akceptowany przez swój własny DatePipe, który w rzeczywistości może wykonać „format lokalny”. Ale to zależy od tego, dokąd dostarczane są dane. „Dobre” biblioteki będą świadome używania daty UTC w obecnej lokalizacji. Jeśli tak nie jest, być może trzeba będzie się „zaostrzyć”.
Ale jest to prosta rzecz, a największe wsparcie uzyskujesz, używając biblioteki, która zasadniczo opiera manipulację danymi wyjściowymi na „podanej wartości UTC”.
Najważniejszą rzeczą tutaj jest "zrozumienie tego, co robisz", gdy pytasz o coś takiego, jak agregowanie do lokalnej strefy czasowej. Taki proces powinien uwzględniać:
-
Dane mogą być i często są przeglądane z perspektywy osób w różnych strefach czasowych.
-
Dane są zazwyczaj dostarczane przez osoby w różnych strefach czasowych. W połączeniu z punktem 1, dlatego przechowujemy w UTC.
-
Strefy czasowe często podlegają zmianie „przesunięcia” w stosunku do „czasu letniego” w wielu strefach czasowych na świecie i należy to uwzględnić podczas analizy i przetwarzania danych.
-
Niezależnie od interwałów agregacji, dane wyjściowe „powinny” w rzeczywistości pozostać w UTC, aczkolwiek dostosowane do agregacji w interwałach zgodnie z podanymi ustawieniami regionalnymi. To pozostawia prezentację do delegowania do funkcji „klienta”, tak jak powinno.
Tak długo, jak pamiętasz o tych rzeczach i stosujesz je tak, jak pokazuje poniższa lista, robisz wszystkie właściwe rzeczy, aby poradzić sobie z agregacją dat, a nawet ogólnym przechowywaniem w odniesieniu do danego języka.
Więc „powinieneś” to robić, a to, czego „nie powinieneś” robić, to poddawać się i po prostu przechowywać „datę lokalną” jako ciąg. Jak opisano, byłoby to bardzo niepoprawne podejście i powoduje tylko dalsze problemy dla Twojej aplikacji.
UWAGA :Jedynym tematem, którego w ogóle nie poruszam, jest agregowanie do „miesiąca” (lub rzeczywiście „roku”) interwał. „Miesiące” są matematyczną anomalią w całym procesie, ponieważ liczba dni zawsze się zmienia i dlatego wymaga zupełnie innego zestawu logiki, aby można było zastosować. Samo opisanie tego jest co najmniej tak długie, jak ten post, a zatem byłby to inny temat. W przypadku ogólnych minut, godzin i dni, co jest powszechnym przypadkiem, matematyka tutaj jest „wystarczająco dobra” dla tych przypadków.
Pełna lista
Służy to jako „demonstracja”, przy której można majstrować. Wykorzystuje wymaganą funkcję do wyodrębnienia dat przesunięcia i wartości, które mają zostać uwzględnione, i uruchamia potok agregacji na dostarczonych danych.
Możesz tutaj zmienić wszystko, ale prawdopodobnie zaczniesz od locale
i interval
parametry, a następnie może dodać inne dane i różne start
i end
daty zapytania. Ale reszta kodu nie musi być zmieniana, aby po prostu dokonać zmian w którejkolwiek z tych wartości, i dlatego może zademonstrować przy użyciu różnych interwałów (takich jak 1 hour
jak zadano w pytaniu ) i w różnych lokalizacjach.
Na przykład po dostarczeniu prawidłowych danych, które faktycznie wymagałyby agregacji w „interwale 1 godziny”, wiersz na liście zmieni się na:
const interval = moment.duration(1,'hour').asMilliseconds();
W celu zdefiniowania wartości milisekund dla interwału agregacji wymaganego przez operacje agregacji wykonywane w datach.
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();
}
})()