Perkenalan
Teknologi digunakan
Analisis persyaratan
Model Data
Arsitektur Aplikasi
Lapisan Data
Pengontrol
Penangan aksi
Tampilan
Filter
Hasil
Aplikasi web berbasis Java untuk kegiatan TODO. Melalui aplikasi web ini, seseorang dapat membuat, membaca, dan memperbarui Todo mereka melalui browser web modern. Aplikasi ini juga mengimplementasikan AAA, yang berarti setiap pengguna memiliki akun mereka sendiri dan daftar TODO mereka dll. Pribadi untuk mereka.
Dokumen ini bukan untuk pemula. Anda harus memiliki pengetahuan yang baik tentang teknologi di bawah ini untuk memahami dokumen ini dan aplikasi terkait:
Jawa
Servlet, JSP
Apache Tomcat
Mysql
Html
Apache Netbeans IDE
Firefox
Ini sepenuhnya merupakan proyek back-end. Jadi, teknologi front-end seperti CSS, JavaScript tidak digunakan. Tujuan dari proyek ini adalah untuk secara efektif mempelajari dan memamerkan bagaimana bagian yang berbeda dari Java Servlet API bekerja bersama.
Kami akan mengembangkan aplikasi web yang dimulai dengan analisis persyaratan. Kemudian kita akan beralih ke desain basis data. Data adalah pusat dari aplikasi web apa pun. Hampir semua kasus penggunaan menangani data. Setelah model data aplikasi web siap, kami kemudian akan melanjutkan untuk merancang arsitektur aplikasi. Dalam fase ini, kita akan melihat bagaimana aplikasi kita berperilaku terhadap tindakan HTTP yang berbeda. Karena, semua tindakan yang dilakukan oleh pengguna aplikasi adalah melalui HTTP. Kami akan memikirkan semua tindakan pengguna yang mungkin dan mendefinisikannya dengan jelas. Selanjutnya, kita akan melanjutkan untuk merancang antarmuka dan kelas.
Untuk aplikasi kami, kami mulai dengan mendefinisikan apa itu TODO bagi kami. Todo adalah tugas yang harus diselesaikan. Kami membuat daftar tugas -tugas tersebut untuk membantu kami menjalani kehidupan prdosutif. Kami terus melacak daftar saat kami menyelesaikan tugas satu demi satu. Item Todo untuk kami memiliki properti di bawah:
Awalnya, tugas akan memiliki status 'TODO'. Ketika kami mulai mengerjakannya, kami mengubahnya menjadi 'sedang berlangsung'. Setelah tugas selesai, kami menandai statusnya sebagai 'selesai'.
Kami ingin aplikasi kami juga mendukung banyak pengguna. Dan setiap pengguna harus memiliki daftar pribadi mereka sendiri. Dengan demikian, pengguna tidak dapat melihat daftar todo orang lain. Pengguna harus diidentifikasi dengan nama pengguna mereka, yang merupakan alamat email mereka yang valid untuk kami. Pengguna diberikan akun pada aplikasi kami. Dengan demikian, akun memiliki properti di bawah:
Kami ingin akun 'administrator' hanya mengelola akun. Akun Administrator harus menggunakan nama pengguna 'Admin'. Pengguna Admin dapat:
Dua item terakhir layak diamati. Biasanya, diyakini bahwa pengguna dengan hak 'admin' memiliki akses ke informasi semua orang. Kami tidak menginginkannya. Juga, kami telah menentukan bahwa akun 'admin' untuk kami hanya untuk mengelola akun. Ini bukan untuk mengelola daftar pengguna TODO. Akun pengguna 'Admin' tidak sering digunakan. Ini dimaksudkan hanya untuk tujuan khusus. Untuk aplikasi kami, kami berharap satu akun pengguna juga menangani akun 'admin'. Jadi, itu akan menjadi orang yang sama yang masuk menggunakan kredensial 'admin' hanya jika diperlukan. Karena ini adalah akun pengguna yang ada menggunakan akun 'admin' hanya untuk mengelola semua akun, kami tidak ingin daftar tugas terpisah untuk akun admin. Itu tidak melayani tujuan apa pun.
Kami ingin daftar TODO selalu bertahan. Yang berarti, begitu item TODO dibuat dengan sukses oleh pengguna, itu tidak akan pernah bisa dihapus. Demikian pula, kami juga tidak ingin menghapus akun pengguna. Sebagai kesimpulan, kami tidak ingin mendukung operasi 'menghapus' dalam aplikasi kami. Dengan demikian, kami hanya mendukung Cru dari CRUD.
Karena, kami ingin aplikasi kami mempertahankan daftar TODO pribadi, kami ingin aplikasi memberikan mekanisme login dan logout. Ini disebut 'otentikasi'. Setiap pengguna, termasuk 'admin', harus terlebih dahulu mengotentikasi diri mereka sendiri. Setelah otentikasi yang berhasil, pengguna akan diarahkan ke ruang kerja mereka. Karena kami sedang mendiskusikan dua jenis pengguna (satu admin dan yang lainnya normal), kami akan memiliki dua jenis ruang kerja pada aplikasi kami. Pengguna Admin hanya akan bekerja dengan ruang kerja manajemen akun pengguna. Pengguna normal hanya akan bekerja dengan ruang kerja manajemen daftar TODO. Keduanya eksklusif. Pengguna normal tidak dapat melihat ruang kerja admin. Dan pengguna admin tidak dapat melihat ruang kerja pengguna normal. Ini disebut 'otorisasi'.
Selain persyaratan di atas, kami ingin aplikasi kami menyimpan detail login pengguna dan cap waktu logout. Melalui ini kami melacak aktivitas pengguna pada aplikasi kami. Ini bukan 'akuntansi' dari AAA, tetapi untuk aplikasi kami ini melayani tujuan menyebutnya sebagai 'akuntansi'.
Berdasarkan persyaratan yang telah kami kumpulkan sejauh ini, kami memahami bahwa kami harus menyimpan data untuk entitas aplikasi di bawah ini:
Contoh data untuk beberapa akun:
| ID Akun | Nama belakang | Nama depan | Nama Belakang | Kata sandi | Dibuat di | Status |
|---|---|---|---|---|---|---|
| 1 | admin | Administrator | Pengguna | kata sandi | 2020-05-06 17:34:04 | diaktifkan |
| 2 | [email protected] | John | Johnsson | ONEWORD | 2020-05-07 12:34:04 | dengan disabilitas |
| 3 | [email protected] | Eric | Ericsson | Twoword | 2020-05-08 13:34:04 | diaktifkan |
| 4 | [email protected] | Ana | Mary | tiga kata | 2020-05-09 11:34:04 | diaktifkan |
Kami melihat bahwa status akun diulangi di seluruh tabel. Jadi, sebagai bagian dari normalisasi basis data, lebih baik meletakkan data yang diulang pada tabel terpisah. Ada beberapa alasan bagus di belakang. Katakanlah, kami memiliki 100 pengguna. Dan kami ingin mengganti kata yang diaktifkan dan dinonaktifkan dengan 1 dan 2 masing -masing. Kami harus memodifikasi kolom status semua baris tabel. Bayangkan betapa rumitnya melakukan modifikasi seperti itu untuk sebuah meja dengan ribuan baris! Normalisasi database di Rescue, untungnya!
Setelah normalisasi, kami akan memiliki dua tabel - Account_Statuses dan akun:
Account_statuses
| PENGENAL | Status |
|---|---|
| 1 | diaktifkan |
| 2 | dengan disabilitas |
Akun
| ID Akun | Nama belakang | Nama depan | Nama Belakang | Kata sandi | ID Status |
|---|---|---|---|---|---|
| 1 | admin | Administrator | Pengguna | kata sandi | 1 |
| 2 | [email protected] | John | Johnsson | ONEWORD | 2 |
| 3 | [email protected] | Eric | Ericsson | Twoword | 1 |
| 4 | [email protected] | Ana | Mary | tiga kata | 2 |
Demikian pula, kami akan memiliki tiga tabel untuk tugas - Task_Statuses, Task_priority dan tugas:
Tugas_Statuses
| PENGENAL | Status |
|---|---|
| 1 | todo |
| 2 | sedang berlangsung |
| 3 | Selesai |
Tugas_prioritas
| PENGENAL | Prioritas |
|---|---|
| 1 | Penting & Mendesak |
| 2 | penting tapi tidak mendesak |
| 3 | tidak penting tapi mendesak |
| 4 | tidak penting dan tidak mendesak |
Tugas
| ID tugas | ID Akun | Detail | Dibuat di | Tenggat waktu | Terakhir diperbarui | ID Status | ID prioritas |
|---|---|---|---|---|---|---|---|
| 1 | 2 | Beli pensil. | 2019-05-06 17:40:03 | 2019-05-07 17:40:03 | 2 | 1 | |
| 2 | 3 | Membeli buku. | 2019-05-07 7:40:03 | 2019-05-07 17:40:03 | 2019-05-07 23:40:03 | 2 | 1 |
Akhirnya, kami juga memiliki persyaratan lain untuk menyimpan data sesi akun. Kami akan menyimpannya seperti yang ditunjukkan pada tabel di bawah ini:
Account_sessions
| ID Sesi | ID Akun | Sesi dibuat | Akhir Sesi |
|---|---|---|---|
| asd1gh | 1 | 2019-05-06 17:40:03 | 2019-05-06 18:00:03 |
Biasanya, dalam aplikasi perusahaan ID tidak disimpan sebagai bilangan bulat. Karena akan lebih mudah bagi seseorang untuk menanyakan informasi orang lain hanya dengan menggunakan bilangan bulat! Dalam aplikasi dunia nyata, ID tidak numerik tetapi alfanumerik, dengan hingga 100 karakter. Dengan demikian, sehingga tidak mungkin bagi seseorang untuk menebak ID lain!
Kami akan menyebut database kami sebagai 'TODO' di MySQL. Dan inilah model data yang dibangun berdasarkan informasi di atas:

Kami akan mengembangkan aplikasi ini mengikuti pola Desgin MVC 2 yang terkenal dan banyak digunakan. Gambar di bawah ini menunjukkan bagaimana kami akan mengimplementasikan MVC untuk aplikasi kami: 
Aplikasi kami akan berbasis tindakan. Ketika pengguna mengirim permintaan HTTP ke aplikasi kami, kami menerjemahkannya ke tindakan yang sesuai pada aplikasi kami. Tindakan yang kami dukung dibuat, baca dan perbarui (CRU). Aplikasi kami pada dasarnya adalah data yang didorong. Ini memfasilitasi tindakan pada database. Ini membantu pengguna untuk menyimpan dan mengelola data mereka pada database jarak jauh dengan aman dan aman dengan bantuan otentikasi dan mekanisme otorisasi. Ini bertindak sebagai antarmuka berbasis HTML dan HTTP ke database.
Ketika pengguna membuat permintaan HTTP ke aplikasi kami, kami mengirim kembali data yang diminta dalam bentuk HTML. HTML mendukung tautan dan formulir untuk membantu pengguna berinteraksi dengan aplikasi web. Tautan digunakan untuk mengambil/mendapatkan (http getp) informasi, sementara formulir digunakan untuk memposting data (http pos) ke aplikasi web.
Jadi, inilah cara kami menerjemahkan permintaan HTTP untuk tindakan:
| Elemen html | Metode HTTP | Tindakan Aplikasi |
|---|---|---|
| Hyperlink | Http dapatkan | Baca detailnya |
| Membentuk | HTTP Post | Buat atau perbarui |
HTTP Get mengirimkan data sebagai parameter kueri ke URL. Sementara, HTTP Post mengirimkan data di badan permintaan HTTP. HTTP Post tidak mengungkapkan data melalui URL, sedangkan HTTP Get. Jadi, http get tidak cocok untuk mengirim kredensial login. Tidak ada yang ingin melihat nama pengguna dan kata sandi mereka ditambahkan ke URL Permintaan HTTP! Kami akan menggunakan HTTP Post untuk mengirim kredensial pengguna saat masuk.
Jadi, jauh kami telah memutuskan bagaimana kami akan menggunakan HTTP, HTML dan database. Ada konsep lain dari HTTP yang penting bagi kami untuk memahami untuk memutuskan arsitektur aplikasi berbasis HTTP kami. Itu URL - Locator Sumber Daya Seragam. Berikut adalah contoh URL aplikasi web yang disebut 'Webapp' yang di -host di server example.com:
http://www.example.com/webapp/details?id=12
Dalam contoh URL di atas, 'http' adalah protokol, www.example.com adalah nama domain atau nama server, 'Webapp' adalah konteks aplikasi yang digunakan pada server dan 'detail' adalah aplikasi yang kami kirimkan permintaan http kami. 'ID' adalah parameter kueri yang kami lewati ke 'detail' dengan nilai '12'. Parameter kueri memberi kita mekanisme untuk meneruskan parameter ke aplikasi web dan menerima konten terkait sebagai respons dari aplikasi web. Misalnya, bayangkan aplikasi cuaca yang berjalan di server web. Alih -alih memberi kami daftar laporan cuaca dari semua lokasi, kami dapat mengirim pilihan lokasi kami sebagai parameter kueri ke aplikasi web. Aplikasi kemudian akan mengirimkan detail cuaca dari pilihan lokasi kami.
Aplikasi web berjalan di server web, tidak seperti aplikasi yang dijalankan di PC kami. Aplikasi Java yang berjalan di server web dipanggil sebagai servlet. Servlets meniru aplikasi web. Mereka berlari di dalam wadah. Apache Tomcat adalah contoh populer dari wadah semacam itu. Perangkat lunak kontainer menerjemahkan permintaan dan tanggapan HTTP mentah ke dalam objek Java dan menyediakannya untuk servlet. Situs web statis melayani konten yang sama untuk setiap permintaan HTTP. Tapi, servlet dapat menghasilkan konten dinamis yang berbeda untuk setiap permintaan HTTP yang dibuat.
Aplikasi Web Todo yang kami bangun akan berisi beberapa bagian - servlets, filter, file JSP, kelas basis data, POJOS dll. Kami akan menyatukan semuanya (seperti menggabungkannya) di bawah satu konteks aplikasi (atau lingkungan aplikasi) pada wadah (Tomcat). Kami akan menyebut konteks aplikasi ini sebagai 'TODO'. Jadi, jika kami menjalankan Tomcat di PC kami di Port 8080, konteks aplikasi kami 'TODO' dapat diakses melalui URL:
http://localhost:8080/todo/
Lapisan data kami terdiri dari implementasi pola DAO dan pola pabrik di atas JDBC DataSource. Kami memilih JDBC DataSource alih -alih DriverManager karena kami ingin menuai manfaat dari pengumpulan koneksi.
Kami hanya memiliki satu servlet yang berfungsi sebagai pengontrol , yang disebut 'utama'. Permintaan HTTP pengguna adalah tindakan bagi kami. Jadi, tujuan servlet pengontrol kami adalah untuk hanya memilih tindakan yang tepat untuk permintaan HTTP yang dibuat. Servlet controller memilih penangan tindakan dan menyerahkannya atas permintaan yang dibuat oleh pengguna. Kami tidak menulis langkah eksekusi tindakan di pengontrol kami. Kami menjaganya tetap bersih dan ramping. Tujuannya adalah untuk 'memilih' penangan tindakan. Bukan untuk 'mengeksekusi' tindakan dengan sendirinya. Setelah Action Handler menjalankan tindakan yang diminta, pengontrol menerima 'langkah selanjutnya' untuk dilakukan sebagai respons dari penangan tindakan. Tugas pengontrol adalah dengan hanya memilih sumber daya yang melakukan respons yang diminta. Sebagai kesimpulan, kami menjauhkan pengontrol kami dari semua logika bisnis.
Kami akan memetakan servlet pengontrol kami ke pola /app/* . Jadi, servlet controller kami akan menangani setiap URI yang mengikuti pola /app/ .
Tindakan yang diminta oleh pengguna adalah logika bisnis untuk aplikasi kami. Penangan tindakan adalah model dalam implementasi MVC kami. Setiap tindakan harus mengimplementasikan antarmuka tindakan:
public interface Action {
/*
An action is supposed to execute and return results. ActionResponse represents the response.
*/
public abstract ActionResponse execute ( HttpServletRequest request , HttpServletResponse response )
throws Exception ;
}Suatu tindakan seharusnya mengeksekusi dan mengembalikan hasil. Kami membuat kelas khusus untuk hasil tersebut - ActionResponse. Penangan aksi dapat memilih untuk 'maju' atau 'mengarahkan'.
public class ActionResponse {
private String method ;
private String viewPath ;
public ActionResponse () {
this . method = "" ;
this . viewPath = "" ;
}
public void setMethod ( String method ) {
this . method = method ;
}
public String getMethod () {
return this . method ;
}
public void setViewPath ( String viewPath ) {
this . viewPath = viewPath ;
}
public String getViewPath () {
return this . viewPath ;
}
@ Override
public String toString () {
return this . getClass (). getName ()+ "[" + this . method + ":" + this . viewPath + "]" ;
}
}Kami menerapkan pola desain pabrik. Kami membuat kelas pabrik - ActionFactory - untuk memberi kami kelas penangan aksi yang kami butuhkan:
public class ActionFactory {
private static Map < String , Action > actions = new HashMap < String , Action >() {
{
put ( new String ( "POST/login" ), new LoginAction ());
put ( new String ( "GET/login" ), new LoginAction ());
put ( new String ( "GET/logout" ), new LogoutAction ());
put ( new String ( "GET/admin/accounts/dashboard" ), new AdminAccountsDashboardAction ());
put ( new String ( "GET/admin/accounts/new" ), new AdminNewAccountFormAction ());
put ( new String ( "POST/admin/accounts/create" ), new AdminCreateAccountAction ());
put ( new String ( "GET/admin/accounts/details" ), new AdminReadAccountDetailsAction ());
put ( new String ( "POST/admin/accounts/update" ), new AdminUpdateAccountAction ());
put ( new String ( "GET/tasks/dashboard" ), new UserTasksDashboardAction ());
put ( new String ( "GET/tasks/new" ), new UserNewTaskFormAction ());
put ( new String ( "GET/tasks/details" ), new UserReadTaskDetailsAction ());
put ( new String ( "POST/tasks/create" ), new UserCreateTaskAction ());
put ( new String ( "POST/tasks/update" ), new UserUpdateTaskAction ());
put ( new String ( "GET/users/profile" ), new UserReadProfileAction ());
put ( new String ( "POST/users/update" ), new UserUpdateProfileAction ());
}
;
};
public static Action getAction ( HttpServletRequest request ) {
Action action = actions . get ( request . getMethod () + request . getPathInfo ());
if ( action == null ) {
return new UnknownAction ();
} else {
return action ;
}
}
}Tujuan dari servlet controller kami adalah untuk:
protected void processRequest ( HttpServletRequest request , HttpServletResponse response )
throws ServletException , IOException {
Action action = ActionFactory . getAction ( request );
try {
ActionResponse actionResponse = action . execute ( request , response );
if ( actionResponse . getMethod (). equalsIgnoreCase ( "forward" )) {
System . out . println ( this . getClass (). getCanonicalName () + ":forward:" + actionResponse );
this . getServletContext (). getRequestDispatcher ( actionResponse . getViewPath ()). forward ( request , response );
} else if ( actionResponse . getMethod (). equalsIgnoreCase ( "redirect" )) {
System . out . println ( this . getClass (). getCanonicalName () + ":redirect:" + actionResponse );
if ( actionResponse . getViewPath (). equals ( request . getContextPath ())) {
response . setHeader ( "Cache-Control" , "no-cache, no-store, must-revalidate" );
response . setHeader ( "Pragma" , "no-cache" );
response . setDateHeader ( "Expires" , 0 );
}
response . sendRedirect ( actionResponse . getViewPath ());
} else if ( actionResponse . getMethod (). equalsIgnoreCase ( "error" )) {
System . out . println ( this . getClass (). getCanonicalName () + ":error:" + actionResponse );
response . sendError ( 401 );
} else {
System . out . println ( this . getClass (). getCanonicalName () + ":" + actionResponse );
response . sendRedirect ( request . getContextPath ());
}
} catch ( Exception e ) {
e . printStackTrace ();
}
} Tabel di bawah ini menunjukkan daftar permintaan HTTP yang ditanggapi aplikasi kami dan penangan tindakan terkaitnya:
| Tindakan yang dimaksudkan pengguna | Permintaan http URI | Penangan aksi |
|---|---|---|
| Kirim Kredensial Login Kosong | GET /app/login | Loginaksi |
| Kirim Kredensial Login | POST /app/login | Loginaksi |
| Dapatkan dasbor akun | GET /app/admin/accounts/dashboard | AdminAccountsDashboardAction |
| Dapatkan Formulir Akun Baru | GET /app/admin/accounts/new | AdminNewAccountformaction |
| Kirim detail akun baru | POST /app/admin/accounts/create | AdminCreateAccountaction |
| Dapatkan detail akun | GET /app/admin/accounts/details?id=xx | AdveadAccountDetailSaction |
| Perbarui detail akun | POST /app/admin/accounts/update | AdminupDateAccountAction |
| Dapatkan Dasbor Tugas | GET /app/tasks/dashboard | UsertasksdashboardAction |
| Dapatkan Formulir Tugas Baru | GET /app/tasks/new | UsernewTaskFormaction |
| Kirim detail tugas baru | POST /app/tasks/create | UserCreateTaskAction |
| Dapatkan detail tugas | GET /app/tasks/details?id=xx | UserReadTaskDetailsAction |
| Perbarui detail tugas | POST /app/tasks/update | UserupDateTaskAction |
| Dapatkan detail profil saya | GET /app/users/profile | UserReadProfileAction |
| Perbarui detail profil saya | POST /app/users/update | UserUpdateProfileAction |
| Logout | GET /app/logout | Logoutaction |
Pekerjaan penangan tindakan adalah untuk menjalankan logika bisnis dan memilih komponen tampilan yang sesuai sebagai respons terhadap permintaan yang dibuat oleh pengguna. Tabel di bawah ini menunjukkan semua penangan aksi dan komponen tampilannya:
| Penangan aksi | Lihat Komponen |
|---|---|
| Loginaksi | /WEB-INF/pages/admin/accounts/dashboard.jsp/WEB-INF/pages/tasks/dashboard.jsp |
| AdminAccountsDashboardAction | /WEB-INF/pages/admin/accounts/dashboard.jsp |
| AdminNewAccountformaction | /WEB-INF/pages/admin/accounts/newAccount.jsp |
| AdminCreateAccountaction | /WEB-INF/pages/admin/accounts/createAccountResult.jsp |
| AdveadAccountDetailSaction | /WEB-INF/pages/admin/accounts/accountDetails.jsp |
| AdminupDateAccountAction | /WEB-INF/pages/admin/accounts/updateAccountResult.jsp |
| UsertasksdashboardAction | /WEB-INF/pages/tasks/dashboard.jsp |
| UsernewTaskFormaction | /WEB-INF/pages/tasks/newTask.jsp |
| UserCreateTaskAction | /WEB-INF/pages/tasks/createTaskResult.jsp |
| UserReadTaskDetailsAction | /WEB-INF/pages/tasks/taskDetails.jsp |
| UserupDateTaskAction | /WEB-INF/pages/tasks/updateTaskResult.jsp |
| UserReadProfileAction | /WEB-INF/pages/users/viewProfile.jsp |
| UserUpdateProfileAction | /WEB-INF/pages/users/updateProfileResult.jsp |
| Tidak diketahui | /WEB-INF/pages/users/unknownAction.jsp |
Komponen tampilan membangun respons HTML yang diperlukan yang akan dikirim ke pengguna. Lihat Komponen Membaca pesan yang ditetapkan oleh Action Handler dan menunjukkannya kepada pengguna.
Kami menggunakan filter untuk mencegat permintaan HTTP yang masuk. Semua filter akan digunakan sebelum permintaan diteruskan ke servlet controller. Permintaan HTTP yang masuk akan pertama kali ditangani oleh filter otentikasi. Melalui filter ini kami memeriksa apakah pengguna sudah masuk atau tidak. Jika tidak masuk, kami mengarahkan ulang pengguna ke halaman login. Setelah berhasil melewati filter otentikasi, permintaan HTTP akan dicegat oleh dua filter lagi. Dalam filter ini kami memeriksa jalur URI dan pengguna adalah 'admin' atau pengguna normal. Jika pengguna normal mencoba mengakses jalur 'admin' URI, kami mencegah akses tersebut. Jika pengguna 'Admin' mencoba mengakses tugas URI terkait tugas, kami mencegah akses tersebut.
Hanya 'admin' yang dapat mengakses URIS yang dimulai dengan /app/admin/* dan hanya pengguna normal yang dapat mengakses URIS yang dimulai dengan /app/tasks/* . URI /app/login , /app/logout /app/users/* dapat diakses oleh keduanya.
Kami tidak mencegat tanggapan yang kami kirim.
Jadi, beginilah aplikasi kami. Saya telah menjaga UI polos untuk kesederhanaan. Tidak ada CSS atau JavaScript.










