MongoDB
 sql >> Baza danych >  >> NoSQL >> MongoDB

Grupuj według daty z lokalną strefą czasową w MongoDB

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ć:

  1. Dane mogą być i często są przeglądane z perspektywy osób w różnych strefach czasowych.

  2. Dane są zazwyczaj dostarczane przez osoby w różnych strefach czasowych. W połączeniu z punktem 1, dlatego przechowujemy w UTC.

  3. 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.

  4. 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();
  }
})()


  1. Redis
  2.   
  3. MongoDB
  4.   
  5. Memcached
  6.   
  7. HBase
  8.   
  9. CouchDB
  1. Uzyskiwanie listy unikalnych obiektów osadzonych/zagnieżdżonych w dokumencie MongoDB

  2. 2 sposoby na uzyskanie rozmiaru dokumentu w MongoDB

  3. Jak ustawić funkcjęKompatybilnośćWersja w MongoDB

  4. Przewodnik programisty po zestawach replik MongoDB

  5. MongoDB w 2018 r. - rok podsumowany