Masalah Umum Berurusan dengan "tanggal lokal"
Jadi ada jawaban singkat untuk ini dan jawaban panjang juga. Kasus dasarnya adalah bahwa alih-alih menggunakan "operator agregasi tanggal" apa pun, Anda malah ingin dan "perlu" sebenarnya "melakukan perhitungan" pada objek tanggal sebagai gantinya. Hal utama di sini adalah menyesuaikan nilai dengan offset dari UTC untuk zona waktu lokal tertentu dan kemudian "membulatkan" ke interval yang diperlukan.
"Jawaban yang lebih panjang" dan juga masalah utama yang perlu dipertimbangkan melibatkan bahwa tanggal sering kali tunduk pada perubahan "Waktu Musim Panas" dalam offset dari UTC pada waktu yang berbeda dalam setahun. Jadi ini berarti bahwa ketika mengonversi ke "waktu lokal" untuk tujuan agregasi seperti itu, Anda benar-benar harus mempertimbangkan di mana batasan untuk perubahan tersebut ada.
Ada juga pertimbangan lain, karena apa pun yang Anda lakukan untuk "menggabungkan" pada interval tertentu, nilai output "harus" setidaknya pada awalnya keluar sebagai UTC. Ini adalah praktik yang baik karena tampilan ke "lokal" benar-benar adalah "fungsi klien", dan seperti yang dijelaskan kemudian, antarmuka klien biasanya akan memiliki cara untuk menampilkan di lokal saat ini yang akan didasarkan pada premis bahwa itu sebenarnya dimasukkan data sebagai UTC.
Menentukan Offset Lokal dan Waktu Musim Panas
Ini umumnya masalah utama yang perlu dipecahkan. Matematika umum untuk "membulatkan" tanggal ke interval adalah bagian yang sederhana, tetapi tidak ada matematika nyata yang dapat Anda terapkan untuk mengetahui kapan batas tersebut berlaku, dan aturan berubah di setiap lokal dan sering kali setiap tahun.
Jadi di sinilah "perpustakaan" masuk, dan opsi terbaik di sini menurut pendapat penulis untuk platform JavaScript adalah zona waktu-saat, yang pada dasarnya adalah "superset" dari momen.js termasuk semua fitur "zona waktu" penting yang kita inginkan untuk digunakan.
Zona Waktu Momen pada dasarnya mendefinisikan struktur seperti itu untuk setiap zona waktu lokal sebagai:
{
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
}
Dimana tentu saja objeknya banyak lebih besar sehubungan dengan untils
dan offsets
properti benar-benar direkam. Tapi itu adalah data yang perlu Anda akses untuk melihat apakah benar-benar ada perubahan offset untuk zona yang diberikan perubahan waktu musim panas.
Blok daftar kode selanjutnya inilah yang pada dasarnya kita gunakan untuk menentukan start
dan end
nilai untuk rentang yang dilintasi batas waktu musim panas, jika ada:
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 }] : []
)
,[]);
}
Melihat keseluruhan tahun 2017 untuk Australia/Sydney
lokal output dari ini adalah:
[
{
"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"
}
]
Yang pada dasarnya mengungkapkan bahwa antara urutan tanggal pertama, offset akan menjadi +11 jam kemudian berubah menjadi +10 jam antara tanggal di urutan kedua dan kemudian beralih kembali ke +11 jam untuk interval yang mencakup akhir tahun dan rentang yang ditentukan.
Logika ini kemudian perlu diterjemahkan ke dalam struktur yang akan dipahami oleh MongoDB sebagai bagian dari pipa agregasi.
Menerapkan Matematika
Prinsip matematika di sini untuk menggabungkan ke "interval tanggal pembulatan" pada dasarnya bergantung pada penggunaan nilai milidetik dari tanggal yang diwakili yang "dibulatkan" ke bawah ke angka terdekat yang mewakili "interval" yang diperlukan.
Anda pada dasarnya melakukan ini dengan menemukan "modulo" atau "sisa" dari nilai saat ini yang diterapkan pada interval yang diperlukan. Kemudian Anda "mengurangi" sisa itu dari nilai saat ini yang mengembalikan nilai pada interval terdekat.
Misalnya, mengingat tanggal saat ini:
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
Ini adalah matematika umum yang juga perlu kita terapkan dalam pipa agregasi menggunakan $subtract
dan $mod
operasi, yang merupakan ekspresi agregasi yang digunakan untuk operasi matematika yang sama seperti yang ditunjukkan di atas.
Struktur umum dari pipeline agregasi adalah:
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 } }
];
Bagian utama di sini yang perlu Anda pahami adalah konversi dari Date
objek seperti yang disimpan dalam MongoDB ke Numeric
mewakili nilai stempel waktu internal. Kita membutuhkan bentuk "numerik", dan untuk melakukan ini adalah trik matematika di mana kita mengurangi satu Tanggal BSON dari yang lain yang menghasilkan perbedaan numerik di antara mereka. Inilah tepatnya yang dilakukan pernyataan ini:
{ "$subtract": [ "$createdAt", new Date(0) ] }
Sekarang kita memiliki nilai numerik untuk ditangani, kita dapat menerapkan modulo dan menguranginya dari representasi numerik tanggal untuk "membulatkannya". Jadi representasi "lurus" dari ini adalah seperti:
{ "$subtract": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
{ "$mod": [
{ "$subtract": [ "$createdAt", new Date(0) ] },
( 1000 * 60 * 60 * 24 ) // 24 hours
]}
]}
Yang mencerminkan pendekatan matematika JavaScript yang sama seperti yang ditunjukkan sebelumnya, tetapi diterapkan pada nilai dokumen aktual dalam alur agregasi. Anda juga akan melihat "trik" lain di sana di mana kami menerapkan $add
operasi dengan representasi lain dari tanggal BSON pada zaman ( atau 0 milidetik ) di mana "penambahan" Tanggal BSON ke nilai "numerik", mengembalikan "Tanggal BSON" yang mewakili milidetik yang diberikan sebagai input.
Tentu saja pertimbangan lain dalam kode yang terdaftar itu adalah "offset" aktual dari UTC yang menyesuaikan nilai numerik untuk memastikan "pembulatan" terjadi untuk zona waktu saat ini. Ini diimplementasikan dalam fungsi berdasarkan deskripsi sebelumnya untuk menemukan di mana perbedaan offset terjadi, dan mengembalikan format yang dapat digunakan dalam ekspresi pipeline agregasi dengan membandingkan tanggal input dan mengembalikan offset yang benar.
Dengan perluasan penuh semua detail, termasuk pembuatan penanganan offset waktu "Penghematan Siang Hari" yang berbeda itu akan menjadi seperti:
[
{
"$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
}
}
]
Perluasan itu menggunakan $switch
pernyataan untuk menerapkan rentang tanggal sebagai ketentuan kapan mengembalikan nilai offset yang diberikan. Ini adalah formulir yang paling nyaman karena "branches"
argumen tidak berhubungan langsung dengan "array", yang merupakan keluaran paling nyaman dari "rentang" yang ditentukan dengan pemeriksaan untils
mewakili "titik potong" offset untuk zona waktu tertentu pada rentang tanggal kueri yang disediakan.
Dimungkinkan untuk menerapkan logika yang sama di versi MongoDB sebelumnya menggunakan implementasi "bersarang" dari $cond
sebagai gantinya, tetapi implementasinya sedikit berantakan, jadi kami hanya menggunakan metode yang paling nyaman dalam implementasi di sini.
Setelah semua kondisi tersebut diterapkan, tanggal "agregat" sebenarnya adalah tanggal yang mewakili waktu "lokal" seperti yang ditentukan oleh locale
yang disediakan . Ini sebenarnya membawa kita ke tahap akhir agregasi, dan alasan mengapa itu ada serta penanganan selanjutnya seperti yang ditunjukkan dalam daftar.
Hasil Akhir
Saya memang menyebutkan sebelumnya bahwa rekomendasi umum adalah bahwa "output" masih harus mengembalikan nilai tanggal dalam format UTC setidaknya beberapa deskripsi, dan oleh karena itu itulah yang dilakukan pipa di sini dengan terlebih dahulu mengonversi "dari" UTC ke lokal oleh menerapkan offset saat "pembulatan", tetapi kemudian angka akhir "setelah pengelompokan" disesuaikan kembali dengan offset yang sama yang berlaku untuk nilai tanggal "pembulatan".
Daftar di sini memberikan "tiga" kemungkinan keluaran yang berbeda di sini sebagai:
// 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
}
]
Satu hal yang perlu diperhatikan di sini adalah bahwa untuk "klien" seperti Angular, setiap format tersebut akan diterima oleh DatePipe-nya sendiri yang sebenarnya dapat melakukan "format lokal" untuk Anda. Tapi itu tergantung di mana data dipasok ke. Pustaka "Bagus" akan mengetahui penggunaan tanggal UTC di lokal saat ini. Jika tidak demikian, Anda mungkin perlu "memperkuat" diri sendiri.
Tapi ini adalah hal yang sederhana, dan Anda mendapatkan dukungan maksimal untuk ini dengan menggunakan perpustakaan yang pada dasarnya mendasarkan manipulasi output dari "nilai UTC yang diberikan".
Hal utama di sini adalah "memahami apa yang Anda lakukan" ketika Anda menanyakan hal seperti menggabungkan ke zona waktu lokal. Proses seperti itu harus mempertimbangkan:
-
Data dapat dan sering dilihat dari perspektif orang dalam zona waktu yang berbeda.
-
Data umumnya disediakan oleh orang-orang di zona waktu yang berbeda. Dikombinasikan dengan poin 1, inilah mengapa kami menyimpan di UTC.
-
Zona waktu sering kali mengalami perubahan "offset" dari "Waktu Musim Panas" di banyak zona waktu dunia, dan Anda harus memperhitungkannya saat menganalisis dan memproses data.
-
Terlepas dari interval agregasi, output "seharusnya" sebenarnya tetap dalam UTC, meskipun disesuaikan untuk agregat pada interval sesuai dengan lokal yang disediakan. Ini membuat presentasi didelegasikan ke fungsi "klien", sebagaimana mestinya.
Selama Anda mengingat hal-hal tersebut dan menerapkannya seperti yang ditunjukkan oleh daftar di sini, maka Anda melakukan semua hal yang benar untuk menangani agregasi tanggal dan bahkan penyimpanan umum sehubungan dengan lokal tertentu.
Jadi Anda "seharusnya" melakukan ini, dan apa yang "tidak boleh" Anda lakukan adalah menyerah dan hanya menyimpan "tanggal lokal" sebagai string. Seperti yang dijelaskan, itu akan menjadi pendekatan yang sangat salah dan tidak menyebabkan apa-apa selain masalah lebih lanjut untuk aplikasi Anda.
CATATAN :Satu topik yang tidak saya sentuh sama sekali di sini adalah menggabungkan ke "bulan" ( atau memang "tahun" ) selang. "Bulan" adalah anomali matematika di seluruh proses karena jumlah hari selalu bervariasi dan dengan demikian memerlukan serangkaian logika lain untuk diterapkan. Menggambarkan itu saja setidaknya selama posting ini, dan karena itu akan menjadi subjek lain. Untuk menit, jam, dan hari umum yang merupakan kasus umum, matematika di sini "cukup baik" untuk kasus tersebut.
Daftar Lengkap
Ini berfungsi sebagai "demonstrasi" untuk bermain-main. Ini menggunakan fungsi yang diperlukan untuk mengekstrak tanggal dan nilai offset untuk dimasukkan dan menjalankan pipa agregasi di atas data yang disediakan.
Anda dapat mengubah apa pun di sini, tetapi mungkin akan dimulai dengan locale
dan interval
parameter, dan kemudian mungkin menambahkan data yang berbeda dan start
yang berbeda dan end
tanggal untuk kueri. Tetapi sisa kode tidak perlu diubah hanya untuk membuat perubahan pada salah satu nilai tersebut, dan karena itu dapat menunjukkan menggunakan interval yang berbeda ( seperti 1 hour
seperti yang ditanyakan dalam pertanyaan ) dan lokal yang berbeda.
Misalnya, setelah memberikan data yang valid yang sebenarnya memerlukan agregasi pada "interval 1 jam" maka baris dalam daftar akan diubah sebagai:
const interval = moment.duration(1,'hour').asMilliseconds();
Untuk menentukan nilai milidetik untuk interval agregasi seperti yang dipersyaratkan oleh operasi agregasi yang dilakukan pada tanggal.
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();
}
})()