Featured image of post Teknik paginasi basis data terbaik

Teknik paginasi basis data terbaik

Jelajahi teknik paginasi basis data terbaik, membandingkan kelebihan, kekurangan, dan performa antara Offset Pagination dan Cursor Pagination.

Photo by Tobias Fischer on Unsplash

👋 Pengantar

Basis data (Database) adalah salah satu pilar dari setiap aplikasi. Di situlah Anda menyimpan semua yang perlu diingat oleh aplikasi Anda, dihitung nanti, atau ditampilkan kepada pengguna lain secara online. Semuanya menyenangkan sampai basis data tumbuh dan aplikasi Anda mulai lag karena Anda mencoba mengambil dan me-render 1.000 postingan sekaligus. Nah, Anda adalah insinyur yang cerdas, bukan? Anda dengan cepat menambalnya dengan tombol “Tampilkan lebih banyak”. Beberapa minggu kemudian, Anda disajikan dengan kesalahan Timeout baru! Anda menuju ke Stack Overflow tetapi dengan cepat menyadari bahwa Ctrl dan V telah berhenti bekerja karena penggunaan yang berlebihan 🤦 Tanpa opsi lain, Anda benar-benar mulai melakukan debugging dan menyadari bahwa basis data mengembalikan lebih dari 50.000 postingan setiap kali pengguna membuka aplikasi Anda! Apa yang harus kita lakukan sekarang?

This is fine meme

Untuk mencegah skenario mengerikan ini, kita harus menyadari risikonya sejak awal karena pengembang yang siap tidak akan pernah harus mengambil risiko. Artikel ini akan mempersiapkan Anda untuk melawan masalah performa terkait basis data menggunakan offset dan cursor pagination.

“Satu ons pencegahan bernilai satu pon pengobatan.” - Benjamin Franklin

📚 Apa itu paginasi?

Paginasi adalah strategi yang digunakan ketika meminta dataset apa pun yang menampung lebih dari sekadar beberapa ratus catatan. Berkat paginasi, kita dapat membagi dataset besar kita menjadi potongan (atau halaman) yang dapat kita ambil dan tampilkan secara bertahap kepada pengguna, sehingga mengurangi beban pada basis data. Paginasi juga memecahkan banyak masalah performa baik di sisi klien maupun server! Tanpa paginasi, Anda harus memuat seluruh riwayat obrolan hanya untuk membaca pesan terbaru yang dikirimkan kepada Anda.

Hari-hari ini, paginasi hampir menjadi kebutuhan karena setiap aplikasi sangat mungkin berurusan dengan jumlah data yang besar. Data ini bisa berupa apa saja mulai dari konten buatan pengguna, konten yang ditambahkan oleh administrator atau editor, atau audit dan log yang dibuat secara otomatis. Segera setelah daftar Anda tumbuh menjadi lebih dari beberapa ribu item, basis data Anda akan memakan waktu terlalu lama untuk menyelesaikan setiap permintaan dan kecepatan serta aksesibilitas front-end Anda akan terganggu. Adapun pengguna Anda, pengalaman mereka akan terlihat seperti ini.

GIFloading

Sekarang setelah kita tahu apa itu paginasi, bagaimana kita sebenarnya menggunakannya? Dan mengapa itu perlu?

🔍 Jenis-jenis paginasi

Ada dua strategi paginasi yang banyak digunakan - offset dan cursor. Sebelum menggali lebih dalam dan mempelajari segala sesuatu tentang mereka, mari kita lihat beberapa situs web yang menggunakannya.

Pertama, mari kunjungi halaman Stargazer GitHub dan perhatikan bagaimana tab mengatakan 5,000+ dan bukan angka absolut? Juga, alih-alih nomor halaman standar, mereka menggunakan tombol Previous (Sebelumnya) dan Next (Berikutnya).

GitHub pagination

Sekarang, mari beralih ke daftar produk Amazon dan perhatikan jumlah hasil yang tepat 364, dan paginasi standar dengan semua nomor halaman yang dapat Anda klik 1 2 3 … 20.

Amazon pagination

Sangat jelas bahwa dua raksasa teknologi tidak dapat menyetujui solusi mana yang lebih baik! Mengapa? Nah, kita perlu menggunakan jawaban yang dibenci pengembang, Karena itu tergantung. Mari kita jelajahi kedua metode untuk memahami kelebihan, batasan, dan implikasi performanya.

Offset pagination

Sebagian besar situs web menggunakan offset pagination karena kesederhanaan dan betapa intuitifnya paginasi bagi pengguna. Untuk mengimplementasikan offset pagination, kita biasanya memerlukan dua informasi:

  • limit - Jumlah baris yang akan diambil dari basis data
  • offset - Jumlah baris yang harus dilewati. Offset seperti nomor halaman, tetapi dengan sedikit matematika di sekitarnya (offset = (page-1) * limit)

Untuk mendapatkan halaman pertama data kami, kami menetapkan limit ke 10 (karena kami ingin 10 item di halaman) dan offset ke 0 (karena kami ingin mulai menghitung 10 item dari item ke-0). Akibatnya, kami akan mendapatkan sepuluh baris.

Untuk mendapatkan halaman kedua, kami menjaga limit pada 10 (ini tidak berubah karena kami ingin setiap halaman berisi 10 baris) dan menetapkan offset ke 10 (mengembalikan hasil dari baris ke-10 dan seterusnya). Kami melanjutkan pendekatan ini sehingga memungkinkan pengguna akhir untuk membolak-balik hasil dan melihat semua konten mereka.

Dalam dunia SQL, kueri seperti itu akan ditulis sebagai SELECT * FROM posts OFFSET 10 LIMIT 10.

Beberapa situs web yang menerapkan offset pagination juga menunjukkan nomor halaman dari halaman terakhir. Bagaimana mereka melakukannya? Bersama hasil untuk setiap halaman, mereka juga cenderung mengembalikan atribut sum yang memberi tahu Anda berapa banyak baris yang ada secara total. Menggunakan limit, sum, dan sedikit matematika, Anda dapat menghitung nomor halaman terakhir menggunakan lastPage = ceil(sum / limit)

Senyaman fitur ini bagi pengguna, pengembang berjuang untuk menskalakan jenis paginasi ini. Melihat atribut sum, kita sudah bisa melihat bahwa bisa memakan waktu cukup lama untuk menghitung semua baris dalam basis data ke angka pastinya. Di samping itu, offset dalam basis data diimplementasikan sedemikian rupa sehingga mengulang baris untuk mengetahui berapa banyak yang harus dilewati. Itu berarti bahwa semakin tinggi offset kita, semakin lama kueri basis data kita akan berlangsung.

Kelemahan lain dari offset pagination adalah TIDAK bekerja dengan baik dengan data real-time atau data yang sering berubah. Offset mengatakan berapa banyak baris yang ingin kita lewati tetapi TIDAK memperhitungkan penghapusan baris atau pembuatan baris baru. Offset seperti itu dapat mengakibatkan menampilkan data duplikat atau beberapa data yang hilang.

Cursor pagination

Cursor adalah penerus offset, karena mereka memecahkan semua masalah yang dimiliki offset pagination - performa, data yang hilang, dan duplikasi data karena tidak bergantung pada urutan relatif baris seperti dalam kasus offset pagination. Sebaliknya, itu bergantung pada indeks yang dibuat dan dikelola oleh basis data. Untuk mengimplementasikan cursor pagination, kita akan memerlukan informasi berikut:

  • limit - Sama seperti sebelumnya, jumlah baris yang ingin kita tampilkan di satu halaman
  • cursor - ID dari elemen referensi dalam daftar. Ini bisa menjadi item pertama jika Anda meminta halaman sebelumnya dan item terakhir jika meminta halaman berikutnya.
  • cursorDirection - Jika pengguna mengklik Next atau Previous (setelah atau sebelum)

Saat meminta halaman pertama, kita tidak perlu memberikan apa pun, cukup limit 10, yang mengatakan berapa banyak baris yang ingin kita dapatkan. Akibatnya, kami mendapatkan sepuluh baris kami.

Untuk mendapatkan halaman berikutnya, kami menggunakan ID dari baris terakhir sebagai cursor dan mengatur cursorDirection ke after.

Demikian pula, jika kita ingin pergi ke halaman sebelumnya, kita menggunakan ID dari baris pertama sebagai cursor dan mengatur direction ke before.

Sebagai perbandingan, dalam dunia SQL, kita dapat menulis kueri kita sebagai SELECT * FROM posts WHERE id > 10 LIMIT 10 ORDER BY id DESC.

Kueri yang menggunakan cursor alih-alih offset lebih berkinerja karena kueri WHERE membantu melewati baris yang tidak diinginkan, sementara OFFSET perlu mengulanginya, menghasilkan pemindaian tabel penuh (full-table scan). Melewati baris menggunakan WHERE bisa menjadi lebih cepat jika Anda mengatur indeks yang tepat pada ID Anda. Indeks dibuat secara default dalam kasus kunci utama Anda.

Tidak hanya itu, Anda tidak perlu lagi khawatir tentang baris yang disisipkan atau dihapus. Jika Anda menggunakan offset 10, Anda akan mengharapkan tepat 10 baris ada di depan halaman Anda saat ini. Jika kondisi ini tidak terpenuhi, kueri Anda akan mengembalikan hasil yang tidak konsisten yang mengarah pada duplikasi data dan bahkan baris yang hilang. Ini dapat terjadi jika ada baris di depan halaman Anda saat ini yang dihapus atau baris baru ditambahkan. Cursor pagination memecahkan ini dengan menggunakan indeks baris terakhir yang Anda ambil dan ia tahu persis dari mana harus mulai mencari, ketika Anda meminta lebih banyak.

Tidak semuanya cerah dan indah. Cursor pagination adalah masalah yang sangat kompleks jika Anda perlu menerapkannya di backend sendiri. Untuk mengimplementasikan cursor pagination, Anda akan memerlukan klausa WHERE dan ORDER BY dalam kueri Anda. Selain itu, Anda juga akan memerlukan klausa WHERE untuk memfilter berdasarkan kondisi yang Anda butuhkan. Ini bisa menjadi sangat kompleks dengan sangat cepat dan Anda mungkin berakhir dengan kueri bersarang yang besar. Di samping itu, Anda juga perlu membuat indeks untuk semua kolom yang perlu Anda kueri.

Bagus! Kami menyingkirkan duplikat dan data yang hilang dengan beralih ke cursor pagination! Tapi kita masih punya satu masalah tersisa. Karena Anda TIDAK BOLEH mengekspos ID numerik inkremental kepada pengguna (karena alasan keamanan), Anda sekarang harus memelihara versi hash dari setiap ID. Kapan pun Anda perlu meminta basis data, Anda mengubah ID string ini menjadi ID numeriknya dengan melihat tabel yang memegang pasangan ini. Bagaimana jika baris ini hilang? Bagaimana jika Anda mengklik tombol Next, mengambil ID baris terakhir, dan meminta halaman berikutnya, tetapi basis data tidak dapat menemukan ID tersebut?

Ini adalah kondisi yang sangat jarang dan hanya terjadi jika ID baris yang akan Anda gunakan sebagai cursor baru saja dihapus. Kita dapat memecahkan masalah ini dengan mencoba baris sebelumnya atau mengambil kembali data permintaan sebelumnya untuk memperbarui baris terakhir dengan ID baru, tetapi semua itu membawa tingkat kompleksitas yang sama sekali baru, dan pengembang perlu memahami banyak konsep baru, seperti rekursi dan manajemen status yang tepat. Untungnya, layanan seperti Appwrite mengurus hal itu, jadi Anda cukup menggunakan cursor pagination sebagai fitur.

🚀 Paginasi di Appwrite

Appwrite adalah backend-as-a-service open source yang mengabstraksi semua kompleksitas yang terlibat dalam membangun aplikasi modern dengan memberi Anda satu set REST API untuk kebutuhan backend inti Anda. Appwrite menangani autentikasi dan otorisasi pengguna, basis data, penyimpanan file, fungsi cloud, webhooks, dan banyak lagi! Jika ada yang kurang, Anda dapat memperluas Appwrite menggunakan bahasa backend favorit Anda.

Appwrite Database memungkinkan Anda menyimpan data berbasis teks apa pun yang perlu dibagikan di antara pengguna Anda. Basis data Appwrite memungkinkan Anda membuat beberapa koleksi (tabel) dan menyimpan beberapa dokumen (baris) di dalamnya. Setiap koleksi memiliki atribut (kolom) yang dikonfigurasi untuk memberikan dataset Anda skema yang tepat. Anda juga dapat mengonfigurasi indeks untuk membuat kueri pencarian Anda lebih berkinerja. Saat membaca data Anda, Anda dapat menggunakan sejumlah kueri yang kuat, memfilternya, mengurutkannya, membatasi jumlah hasil, dan mempaginasi di atasnya. Dan semua ini tersedia langsung!

Apa yang membuat Appwrite Database lebih baik adalah dukungan paginasi Appwrite, karena kami mendukung offset dan cursor pagination! Mari kita bayangkan kita memiliki koleksi dengan ID articles, kita bisa mendapatkan dokumen dari koleksi ini dengan offset atau cursor pagination:

// Setup
import { Appwrite, Query } from "appwrite";
const sdk = new Appwrite();

sdk
    .setEndpoint('https://demo.appwrite.io/v1') // Your API Endpoint
    .setProject('articles-demo') // Your project ID
;

// Offset pagination
sdk.database.listDocuments(
    'articles', // Collection ID
    [ Query.equal('status', 'published') ], // Filters
    10, // Limit
    500, // Offset, amount of documents to skip
).then((response) => {
    console.log(response);
});

// Cursor pagination
sdk.database.listDocuments(
    'articles', // Collection ID
    [ Query.equal('status', 'published') ], // Filters
    10, // Limit
    undefined, // Not using offset
    '61d6eb2281fce3650c2c' // ID of document I want to paginate after
).then((response) => {
    console.log(response);
});

Pertama, kita mengimpor pustaka Appwrite SDK dan mengatur instance yang terhubung ke instance Appwrite tertentu dan proyek tertentu. Kemudian, kita mencantumkan 10 dokumen menggunakan offset pagination sambil memiliki filter untuk hanya menampilkan dokumen yang diterbitkan. Tepat setelahnya, kita menulis kueri daftar dokumen yang sama persis, tetapi kali ini menggunakan cursor alih-alih offset pagination.

📊 Tolok Ukur (Benchmarks)

Kami telah menggunakan kata performa cukup sering dalam artikel ini tanpa memberikan angka nyata, jadi mari kita buat tolok ukur bersama! Kami akan menggunakan Appwrite sebagai server backend kami karena mendukung offset dan cursor pagination dan Node.JS untuk menulis skrip tolok ukur. Lagipula, Javascript cukup mudah diikuti.

Anda dapat menemukan kode sumber lengkap sebagai repositori GitHub.

Pertama, kita atur Appwrite, daftarkan pengguna, buat proyek, dan buat koleksi bernama posts dengan izin tingkat koleksi dan izin baca diatur ke role:all. Untuk mempelajari lebih lanjut tentang proses ini, kunjungi dokumentasi Appwrite. Kita sekarang seharusnya sudah siap menggunakan Appwrite.

Kita belum bisa melakukan tolok ukur, karena basis data kita kosong! Mari kita isi tabel kita dengan beberapa data. Kita menggunakan skrip berikut untuk memuat data ke dalam basis data MariadDB kita dan bersiap untuk tolok ukur.

const config = {};
// Don't forget to fill config variable with secret information

console.log("🤖 Connecting to database ...");

const connection = await mysql.createConnection({
    host: config.mariadbHost,
    port: config.mariadbPost,
    user: config.mariadbUser,
    password: config.mariadbPassword,
    database: `appwrite`,
});

const promises = [];

console.log("🤖 Database connection established");
console.log("🤖 Preparing database queries ...");

let index = 1;
for(let i = 0; i < 100; i++) {
    const queryValues = [];

    for(let l = 0; l < 10000; l++) {
        queryValues.push(`('id${index}', '[]', '[]')`);
        index++;
    }

    const query = `INSERT INTO _project_${config.projectId}_collection_posts (_uid, _read, _write) VALUES ${queryValues.join(", ")}`;
    promises.push(connection.execute(query));
}

console.log("🤖 Pushing data. Get ready, this will take quite some time ...");

await Promise.all(promises);

console.error(`🌟 Successfully finished`);

Kami menggunakan dua lapisan for loop untuk meningkatkan kecepatan skrip. For loop pertama membuat eksekusi kueri yang perlu ditunggu, dan loop kedua membuat kueri panjang yang menampung beberapa permintaan penyisipan. Idealnya, kami ingin semuanya dalam satu permintaan, tetapi itu tidak mungkin karena konfigurasi MySQL, jadi kami membaginya menjadi 100 permintaan.

Kami memiliki 1 juta dokumen yang disisipkan dalam waktu kurang dari satu menit, dan kami siap untuk memulai tolok ukur kami. Kami akan menggunakan pustaka pengujian beban k6 untuk demo ini.

Mari kita tolok ukur offset pagination yang terkenal dan banyak digunakan terlebih dahulu. Selama setiap skenario pengujian, kami mencoba mengambil halaman dengan 10 dokumen, dari berbagai bagian dataset kami. Kami akan mulai dengan offset 0 dan terus hingga offset 900k dengan kelipatan 100k. Tolok ukur ditulis sedemikian rupa, sehingga hanya membuat satu permintaan pada satu waktu agar seakurat mungkin. Kami juga akan menjalankan tolok ukur yang sama sepuluh kali dan mengukur waktu respons rata-rata untuk memastikan signifikansi statistik. Kami akan menggunakan klien HTTP k6 untuk membuat permintaan ke REST API Appwrite.

// script_offset.sh

import http from 'k6/http';

// Before running, make sure to run setup.js
export const options = {
    iterations: 10,
    summaryTimeUnit: "ms",
    summaryTrendStats: ["avg"]
};

const config = JSON.parse(open("config.json"));

export default function () {
    http.get(`${config.endpoint}/database/collections/posts/documents?offset=${__ENV.OFFSET}&limit=10`, {
        headers: {
            'content-type': 'application/json',
            'X-Appwrite-Project': config.projectId
        }
    });
}

Untuk menjalankan tolok ukur dengan konfigurasi offset yang berbeda dan menyimpan output dalam file CSV, saya membuat skrip bash sederhana. Skrip ini mengeksekusi k6 sepuluh kali, dengan konfigurasi offset yang berbeda setiap kali. Output akan diberikan sebagai output konsol.

#!/bin/bash
# benchmark_offset.sh

k6 -e OFFSET=0 run script.js
k6 -e OFFSET=100000 run script.js
k6 -e OFFSET=200000 run script.js
k6 -e OFFSET=300000 run script.js
k6 -e OFFSET=400000 run script.js
k6 -e OFFSET=500000 run script.js
k6 -e OFFSET=600000 run script.js
k6 -e OFFSET=700000 run script.js
k6 -e OFFSET=800000 run script.js
k6 -e OFFSET=900000 run script.js

Dalam satu menit, semua tolok ukur telah selesai dan memberi saya waktu respons rata-rata untuk setiap konfigurasi offset. Hasilnya sesuai harapan tetapi tidak memuaskan sama sekali.

Offset pagination (ms)
0% offset 3.73
10% offset 52.39
20% offset 96.83
30% offset 144.13
40% offset 216.06
50% offset 257.71
60% offset 313.06
70% offset 371.03
80% offset 424.63
90% offset 482.71

Graph with offset pagination

Seperti yang bisa kita lihat, offset 0 cukup cepat, merespons dalam waktu kurang dari 4ms. Lompatan pertama kami adalah ke offset 100k, dan perubahannya drastis, meningkatkan waktu respons menjadi 52ms. Dengan setiap peningkatan offset, durasinya naik, menghasilkan hampir 500ms untuk mendapatkan sepuluh dokumen setelah offset 900k dokumen. Itu gila!

Sekarang mari kita perbarui skrip kita untuk menggunakan cursor pagination. Kami akan memperbarui skrip kami untuk menggunakan cursor alih-alih offset dan memperbarui skrip bash kami untuk memberikan cursor (ID dokumen) alih-alih nomor offset.

// script_cursor.js
import http from 'k6/http';

// Before running, make sure to run setup.js
export const options = {
    iterations: 10,
    summaryTimeUnit: "ms",
    summaryTrendStats: ["avg"]
};

const config = JSON.parse(open("config.json"));

export default function () {
    http.get(`${config.endpoint}/database/collections/posts/documents?cursor=${__ENV.CURSOR}&cursorDirection=after&limit=10`, {
        headers: {
            'content-type': 'application/json',
            'X-Appwrite-Project': config.projectId
        }
    });
}
#!/bin/bash
# benchmark_cursor.sh

k6 -e CURSOR=id1 run script_cursor.js
k6 -e CURSOR=id100000 run script_cursor.js
k6 -e CURSOR=id200000 run script_cursor.js
k6 -e CURSOR=id300000 run script_cursor.js
k6 -e CURSOR=id400000 run script_cursor.js
k6 -e CURSOR=id500000 run script_cursor.js
k6 -e CURSOR=id600000 run script_cursor.js
k6 -e CURSOR=id700000 run script_cursor.js
k6 -e CURSOR=id800000 run script_cursor.js
k6 -e CURSOR=id900000 run script_cursor.js

Setelah menjalankan skrip, kita sudah bisa tahu bahwa ada peningkatan performa karena ada perbedaan nyata dalam waktu respons. Kami telah memasukkan hasilnya ke dalam tabel untuk membandingkan kedua metode paginasi ini secara berdampingan.

Offset pagination (ms) Cursor pagination (ms)
0% offset 3.73 6.27
10% offset 52.39 4.07
20% offset 96.83 5.15
30% offset 144.13 5.29
40% offset 216.06 6.65
50% offset 257.71 7.26
60% offset 313.06 4.61
70% offset 371.03 6.00
80% offset 424.63 5.60
90% offset 482.71 5.05

Graph with offset and cursor pagination

Wow! Cursor pagination keren! Grafik menunjukkan bahwa cursor pagination TIDAK PEDULI tentang ukuran offset, dan setiap kueri sama berkinerjanya dengan yang pertama atau terakhir. Dapatkah Anda membayangkan berapa banyak kerugian yang dapat ditimbulkan dengan memuat halaman terakhir dari daftar besar berulang kali? 😬

Jika Anda tertarik untuk menjalankan tes di mesin Anda sendiri, Anda dapat menemukan kode sumber lengkap sebagai repositori GitHub. Repositori tersebut mencakup README.md yang menjelaskan seluruh proses instalasi dan menjalankan skrip.

👨‍🎓 Ringkasan

Offset pagination menawarkan metode paginasi yang terkenal di mana Anda dapat melihat nomor halaman dan mengkliknya. Metode intuitif ini hadir dengan banyak kerugian, seperti performa mengerikan dengan offset tinggi dan kemungkinan duplikasi data dan data yang hilang.

Cursor pagination memecahkan semua masalah ini dan menghadirkan sistem paginasi andal yang cepat dan dapat menangani data real-time (sering berubah). Kelemahan dari cursor pagination adalah TIDAK MENAMPILKAN nomor halaman, kompleksitasnya untuk diimplementasikan, dan serangkaian tantangan baru untuk diatasi, seperti ID cursor yang hilang.

Mari kita kembali ke pertanyaan awal kita, mengapa GitHub menggunakan cursor pagination, tetapi Amazon memutuskan untuk menggunakan offset pagination? Performa tidak selalu menjadi kuncinya… Pengalaman pengguna jauh lebih berharga daripada berapa banyak server yang harus dibayar bisnis Anda.

Saya percaya Amazon memutuskan untuk menggunakan offset karena itu meningkatkan UX, tetapi itu adalah topik untuk penelitian lain. Kita sudah bisa melihat bahwa jika kita mengunjungi amazon.com dan mencari pulpen, dikatakan ada tepat 10 000 hasil, tetapi Anda hanya dapat mengunjungi tujuh halaman pertama (350 hasil).

Pertama, ada lebih dari 10rb hasil, tetapi Amazon membatasinya. Kedua, Anda bisa mengunjungi tujuh halaman pertama. Jika Anda mencoba mengunjungi halaman 8, itu menunjukkan kesalahan 404. Seperti yang bisa kita lihat, Amazon sadar akan performa offset pagination tetapi tetap memutuskan untuk mempertahankannya karena basis pengguna mereka lebih suka melihat nomor halaman. Mereka harus menyertakan beberapa batasan, tetapi siapa yang pergi ke halaman 100 hasil pencarian? 🤷

Apakah Anda tahu apa yang lebih baik daripada membaca tentang paginasi? Mencobanya! Saya mendorong Anda untuk mencoba kedua metode karena yang terbaik adalah mendapatkan pengalaman langsung. Menyiapkan Appwrite membutuhkan kurang dari beberapa menit, dan Anda dapat mulai bermain dengan kedua metode paginasi. Jika Anda memiliki pertanyaan, Anda juga dapat menghubungi kami di server Discord kami.

Reference

All rights reserved,未經允許不得隨意轉載
Dibangun dengan Hugo
Tema Stack dirancang oleh Jimmy