Program Bagian 3 bekerja mulus selama masukannya masuk akal — tetapi pengguna nyata mengetik selesai abc, memberi judul kosong, atau menyunting tugas.json sampai rusak tanpa sengaja. Pada bagian ini kita memperkokoh program dengan dua lapis pertahanan: validasi input yang menolak data buruk sebelum menyentuh penyimpanan, dan error boundary yang menerjemahkan seluruh kegagalan menjadi pesan ramah plus exit code 1. Hasilnya, program gagal dengan cara yang terkendali dan bisa dipercaya pipeline shell.
Validasi Input: Tolak Sebelum Menyentuh Data #
Semua pemeriksaan judul kini terpusat di bacaJudul(). Fungsi ini memakai fitur PHP 8 bernama throw expression — throw tidak lagi harus berdiri sendiri sebagai pernyataan, melainkan bisa menjadi bagian dari ekspresi seperti sisi kanan ??:
function bacaJudul(array $argv): string
{
$judul = $argv[2] ?? throw new InvalidArgumentException(
'judul tugas wajib diisi. Contoh: php main.php tambah "Belajar PHP"'
);
$judul = trim($judul);
if ($judul === '') {
throw new InvalidArgumentException('judul tugas tidak boleh kosong.');
}
return $judul;
}Mari kita telaah alurnya:
- Baris
$argv[2] ?? throw ...mengambil argumen judul; bila argumen hilang, ekspresi??langsung melemparInvalidArgumentExceptiontanpa perluiftersendiri. trim($judul)memangkas whitespace di kedua ujung, sehingga judul" "(hanya spasi) dianggap kosong dan ditolak di pemeriksaan berikutnya.- Nilai balik hanya terjadi bila judul lolos kedua pemeriksaan — pemanggil (
perintahTambah()) yakin menerima judul bersih dan tidak perlu memvalidasi ulang.
Perhatikan pula bahwa validasi berjalan sebelum muatTugas() dan simpanTugas() dipanggil. Masukan yang buruk ditolak di ambang pintu, jadi tidak pernah menyentuh berkas penyimpanan:
<?php
require_once __DIR__ . '/main.php';
$berkas = sys_get_temp_dir() . '/tugas-validasi-demo.json';
if (is_file($berkas)) {
unlink($berkas);
}
$kode = jalankan(['main.php', 'tambah', ' '], $berkas);
echo "exit code: $kode\n";
var_dump(is_file($berkas));
if (is_file($berkas)) {
unlink($berkas);
}Output yang Dihasilkan:
Error: judul tugas tidak boleh kosong.
exit code: 1
bool(false)Berkas tidak pernah dibuat karena eksekusi berhenti di lapisan validasi — prinsip fail fast yang menjaga data tetap bersih.
Menolak ID yang Bukan Angka #
Pada Bagian 3, ID 'abc' dicast menjadi 0 lalu dilaporkan sebagai “ID 0 tidak ditemukan” — membingungkan. Sekarang bacaId() memakai ctype_digit() yang memeriksa apakah seluruh karakter argumen adalah digit 0-9, ditambah syarat nilainya minimal 1:
<?php
foreach (['1', '42', 'abc', '3.5', '', '07'] as $input) {
$status = ctype_digit($input) ? 'angka' : 'bukan angka';
printf("%-6s => %s\n", "'{$input}'", $status);
}Output yang Dihasilkan:
'1' => angka
'42' => angka
'abc' => bukan angka
'3.5' => bukan angka
'' => bukan angka
'07' => angkaPerhatikan dua kasus menarik: '3.5' ditolak meski tampak numerik (titik bukan digit), dan '07' diterima lalu dinormalkan oleh cast (int) menjadi 7. Mari kita lihat efeknya pada perintah sungguhan:
<?php
require_once __DIR__ . '/main.php';
$berkas = sys_get_temp_dir() . '/tugas-id-demo.json';
if (is_file($berkas)) {
unlink($berkas);
}
jalankan(['main.php', 'tambah', 'Belajar PHP dasar'], $berkas);
$kode = jalankan(['main.php', 'selesai', 'abc'], $berkas);
echo "exit code: $kode\n";
unlink($berkas);Output yang Dihasilkan:
Tugas ditambahkan: [1] Belajar PHP dasar
Daftar Tugas:
1. [ ] Belajar PHP dasar
Error: ID harus berupa angka, diterima: abc
exit code: 1json_validate: Periksa Sebelum Mendekode #
Sejak PHP 8.3 tersedia json_validate() yang memeriksa keabsahan sintaks JSON tanpa membangun array hasilnya — lebih murah daripada json_decode() penuh ketika kita hanya ingin tahu valid atau tidak. Pada storage.php, fungsi ini menjadi saringan pertama sebelum pendekodean:
<?php
$valid = '[{"id":1,"judul":"Belajar PHP dasar","selesai":false}]';
$rusak = '[{"id":1,"judul":"Belajar PHP dasar"';
var_dump(json_validate($valid));
var_dump(json_validate($rusak));Output yang Dihasilkan:
bool(true)
bool(false)String kedua kehilangan penutup ], sehingga json_validate() mengembalikan false — dan muatTugas() kita memakai hasil itu untuk melempar exception alih-alih membiarkan json_decode() mengembalikan null yang diam-diam menjadi daftar kosong. Penyembunyian kegagalan semacam itulah yang membuat data hilang tanpa jejak.
Error Boundary di jalankan() #
Semua exception kini digiring ke satu tempat: blok try/catch di jalankan(). Pola ini biasa disebut error boundary — satu gerbang keluar untuk semua kegagalan yang wajar bagi pengguna CLI:
function jalankan(array $argv, string $berkas = BERKAS_TUGAS): int
{
try {
return jalankanPerintah($argv, $berkas);
} catch (RuntimeException | InvalidArgumentException | JsonException $e) {
printf("Error: %s\n", $e->getMessage());
return KELUAR_GAGAL;
}
}Ada tiga jenis exception yang ditangkap: RuntimeException untuk kegagalan runtime seperti berkas rusak atau ID tak ditemukan, InvalidArgumentException untuk masukan yang salah format, dan JsonException yang muncul bila json_encode() gagal (misalnya judul mengandung byte UTF-8 rusak). Union type pada catch membuat ketiganya mendapat perlakuan yang sama: pesan dicetak dengan awalan Error: dan program keluar dengan kode 1.
Kasus paling ekstrem — berkas tugas.json yang disunting tangan sampai rusak — kini menghasilkan laporan yang jujur:
<?php
require_once __DIR__ . '/main.php';
$berkas = sys_get_temp_dir() . '/tugas-rusak-demo.json';
file_put_contents($berkas, '[{"id":1,"judul":"Belajar PHP dasar"');
$kode = jalankan(['main.php', 'list'], $berkas);
echo "exit code: $kode\n";
unlink($berkas);Output yang Dihasilkan:
Error: berkas tugas rusak: JSON tidak valid.
exit code: 1Tanpa lapisan ini, json_decode() akan mengembalikan null, program menampilkan “Belum ada tugas.”, dan pengguna salah mengira datanya memang kosong padahal berkasnya rusak.
Menjalankan Program dan Memeriksa Exit Code #
Mari kita buktikan di terminal. Perintah pertama berhasil, empat berikutnya gagal dengan alasan yang masing-masing jelas:
$ php main.php tambah "Belajar PHP dasar"Tugas ditambahkan: [1] Belajar PHP dasar
Daftar Tugas:
1. [ ] Belajar PHP dasar$ php main.php tambah ""
$ echo $?Error: judul tugas tidak boleh kosong.
1$ php main.php selesai abc
$ php main.php hapus 99
$ echo $?Error: ID harus berupa angka, diterima: abc
Error: tugas dengan ID 99 tidak ditemukan.
1$ echo '[{"id":1,"judul" ' > tugas.json
$ php main.php list
$ echo $?
$ rm tugas.jsonError: berkas tugas rusak: JSON tidak valid.
1Apa yang Berubah dari Bagian 3 #
muatTugas() dan simpanTugas() kini melempar exception alih-alih mengembalikan nilai sinyal ([] atau false), sehingga kegagalan tidak mungkin tertukar dengan data kosong. Fungsi tandaiSelesai() dan hapusTugas() ikut berubah dari pola balik-null menjadi melempar exception — semua jalur gagal kini mengalir ke satu error boundary. Validasi judul dan ID pindah ke bacaJudul()/bacaId() yang berdiri sebelum operasi penyimpanan, dan ID non-angka tidak lagi tercast diam-diam menjadi 0.
Ringkasan #
Ketangguhan program CLI dibangun dari tiga kebiasaan: validasi masukan segera di gerbang (throw expression membuatnya ringkas), pemeriksaan keabsahan data eksternal dengan json_validate() sebelum dipercaya, dan satu error boundary yang menerjemahkan exception menjadi pesan ramah plus exit code 1. Struktur perintah, operasi array, dan format penyimpanan dari bagian-bagian sebelumnya tidak berubah — kita murni menambah ketangguhan. Dengan fondasi ini, kita siap melakukan rombakan terakhir: menyusun ulang seluruh logika menjadi class yang dapat diuji dengan PHPUnit pada Bagian 5.
Referensi #
- json_validate — pemeriksaan sintaks JSON sejak PHP 8.3.
- Errors and Error Handling — model error dan exception di PHP.
- Using PHP from the command line — exit code dan interaksi CLI dengan shell.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
main.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Pengelola Daftar Tugas — Bagian 4: Validasi dan Error Handling.
*
* Program kini menolak masukan yang buruk sebelum menyentuh
* penyimpanan: judul wajib berisi, ID wajib angka >= 1, dan
* berkas JSON yang rusak dilaporkan dengan pesan yang jelas.
* Seluruh kegagalan mengalir ke satu error boundary di jalankan().
*/
require_once __DIR__ . '/storage.php';
const KELUAR_OK = 0;
const KELUAR_GAGAL = 1;
function perintahBantuan(): int
{
echo <<<TEKS
Pengelola Daftar Tugas — manajemen daftar tugas pribadi
Penggunaan:
php main.php Tampilkan bantuan ini
php main.php list Tampilkan daftar tugas
php main.php tambah <judul> Tambah tugas baru
php main.php selesai <id> Tandai tugas sebagai selesai
php main.php hapus <id> Hapus tugas
Contoh:
php main.php tambah "Belajar PHP dasar"
php main.php selesai 1
php main.php hapus 2
TEKS;
return KELUAR_OK;
}
// ---------------------------------------------------------------------------
// Operasi murni atas array tugas — tanpa I/O, mudah diuji terpisah.
// ---------------------------------------------------------------------------
/** Bentuk satu tugas baru dengan status belum selesai. */
function tugasBaru(int $id, string $judul): array
{
return ['id' => $id, 'judul' => $judul, 'selesai' => false];
}
/** Hitung ID untuk tugas berikutnya: selalu terbesar + 1. */
function idBerikutnya(array $tugas): int
{
$terbesar = 0;
foreach ($tugas as $t) {
$terbesar = max($terbesar, $t['id']);
}
return $terbesar + 1;
}
/** Kembalikan daftar tugas baru yang sudah berisi tugas tambahan. */
function tambahTugas(array $tugas, string $judul): array
{
$tugas[] = tugasBaru(idBerikutnya($tugas), $judul);
return $tugas;
}
/** Cari satu tugas berdasarkan ID; null bila tidak ada. */
function cariTugas(array $tugas, int $id): ?array
{
foreach ($tugas as $t) {
if ($t['id'] === $id) {
return $t;
}
}
return null;
}
/** Kembalikan daftar baru dengan status selesai true; lempar bila ID tidak ada. */
function tandaiSelesai(array $tugas, int $id): array
{
foreach ($tugas as $i => $t) {
if ($t['id'] === $id) {
$tugas[$i]['selesai'] = true;
return $tugas;
}
}
throw new RuntimeException("tugas dengan ID {$id} tidak ditemukan.");
}
/** Kembalikan daftar baru tanpa tugas tersebut; lempar bila ID tidak ada. */
function hapusTugas(array $tugas, int $id): array
{
foreach ($tugas as $i => $t) {
if ($t['id'] === $id) {
array_splice($tugas, $i, 1);
return $tugas;
}
}
throw new RuntimeException("tugas dengan ID {$id} tidak ditemukan.");
}
/** Cetak daftar tugas ke layar dengan format centang. */
function tampilkanTugas(array $tugas): void
{
if ($tugas === []) {
echo "Belum ada tugas.\n";
return;
}
echo "Daftar Tugas:\n";
foreach ($tugas as $t) {
printf(" %d. [%s] %s\n", $t['id'], $t['selesai'] ? 'x' : ' ', $t['judul']);
}
}
// ---------------------------------------------------------------------------
// Validasi input — salah format langsung ditolak sebelum menyentuh berkas.
// ---------------------------------------------------------------------------
/**
* Ambil dan validasi judul dari argumen perintah.
*
* Argumen hilang atau hanya berisi whitespace membuat fungsi ini
* melempar InvalidArgumentException.
*/
function bacaJudul(array $argv): string
{
$judul = $argv[2] ?? throw new InvalidArgumentException(
'judul tugas wajib diisi. Contoh: php main.php tambah "Belajar PHP"'
);
$judul = trim($judul);
if ($judul === '') {
throw new InvalidArgumentException('judul tugas tidak boleh kosong.');
}
return $judul;
}
/**
* Ambil dan validasi ID dari argumen perintah.
*
* Argumen wajib berupa digit dan bernilai >= 1; selain itu fungsi
* ini melempar InvalidArgumentException.
*/
function bacaId(array $argv): int
{
$input = $argv[2] ?? throw new InvalidArgumentException(
'ID tugas wajib diisi. Contoh: php main.php selesai 1'
);
if (!ctype_digit($input) || (int) $input < 1) {
throw new InvalidArgumentException("ID harus berupa angka, diterima: {$input}");
}
return (int) $input;
}
// ---------------------------------------------------------------------------
// Perintah CLI — tiap fungsi memuat, mengubah, lalu menyimpan.
// ---------------------------------------------------------------------------
function perintahList(string $berkas): int
{
tampilkanTugas(muatTugas($berkas));
return KELUAR_OK;
}
function perintahTambah(array $argv, string $berkas): int
{
$judul = bacaJudul($argv);
$tugas = muatTugas($berkas);
$tugas = tambahTugas($tugas, $judul);
simpanTugas($tugas, $berkas);
$baru = $tugas[array_key_last($tugas)];
printf("Tugas ditambahkan: [%d] %s\n\n", $baru['id'], $baru['judul']);
tampilkanTugas($tugas);
return KELUAR_OK;
}
function perintahSelesai(array $argv, string $berkas): int
{
$id = bacaId($argv);
$tugas = muatTugas($berkas);
$hasil = tandaiSelesai($tugas, $id);
simpanTugas($hasil, $berkas);
$terkait = cariTugas($hasil, $id);
printf("Tugas ditandai selesai: [%d] %s\n\n", $terkait['id'], $terkait['judul']);
tampilkanTugas($hasil);
return KELUAR_OK;
}
function perintahHapus(array $argv, string $berkas): int
{
$id = bacaId($argv);
$tugas = muatTugas($berkas);
$terhapus = cariTugas($tugas, $id);
$hasil = hapusTugas($tugas, $id);
simpanTugas($hasil, $berkas);
printf("Tugas dihapus: [%d] %s\n\n", $terhapus['id'], $terhapus['judul']);
tampilkanTugas($hasil);
return KELUAR_OK;
}
function perintahTidakDikenal(string $perintah): int
{
printf("Error: perintah tidak dikenal: %s\n\n", $perintah);
perintahBantuan();
return KELUAR_GAGAL;
}
/**
* Titik masuk program sekaligus error boundary: seluruh exception
* yang wajar bagi pengguna CLI ditangkap di satu tempat, dicetak
* sebagai pesan ramah, dan diterjemahkan menjadi exit code 1.
*/
function jalankan(array $argv, string $berkas = BERKAS_TUGAS): int
{
try {
return jalankanPerintah($argv, $berkas);
} catch (RuntimeException | InvalidArgumentException | JsonException $e) {
printf("Error: %s\n", $e->getMessage());
return KELUAR_GAGAL;
}
}
function jalankanPerintah(array $argv, string $berkas): int
{
$perintah = $argv[1] ?? '';
if ($perintah === '') {
return perintahBantuan();
}
return match ($perintah) {
'list' => perintahList($berkas),
'tambah' => perintahTambah($argv, $berkas),
'selesai' => perintahSelesai($argv, $berkas),
'hapus' => perintahHapus($argv, $berkas),
default => perintahTidakDikenal($perintah),
};
}
// Jalankan hanya bila dieksekusi langsung sebagai skrip CLI,
// bukan saat di-include dari berkas lain (blok contoh, pengujian).
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
exit(jalankan($argv));
}
storage.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Penyimpanan daftar tugas dalam berkas JSON — Bagian 4.
*
* Berbeda dari Bagian 3, kedua fungsi ini kini melempar
* RuntimeException ketika berkas tidak dapat dibaca atau
* isinya bukan JSON valid — kegagalan tidak lagi disembunyikan
* sebagai daftar kosong.
*/
const BERKAS_TUGAS = __DIR__ . '/tugas.json';
/**
* Muat seluruh tugas dari berkas JSON.
*
* Berkas yang belum ada tetap dianggap daftar kosong — itu
* kondisi normal untuk pemakaian pertama. Selain itu, setiap
* kegagalan dibaca atau didekode menjadi exception.
*/
function muatTugas(string $berkas = BERKAS_TUGAS): array
{
if (!is_file($berkas)) {
return [];
}
$isi = file_get_contents($berkas);
if ($isi === false) {
throw new RuntimeException("gagal membaca berkas tugas: {$berkas}");
}
if (!json_validate($isi)) {
throw new RuntimeException('berkas tugas rusak: JSON tidak valid.');
}
$tugas = json_decode($isi, true);
if (!is_array($tugas)) {
throw new RuntimeException('berkas tugas rusak: format tidak dikenal.');
}
return $tugas;
}
/**
* Simpan seluruh tugas ke berkas JSON.
*
* JSON_THROW_ON_ERROR mengubah kegagalan encoding menjadi
* JsonException, sementara kegagalan penulisan berkas menjadi
* RuntimeException — keduanya tertangkap error boundary di main.php.
*/
function simpanTugas(array $tugas, string $berkas = BERKAS_TUGAS): void
{
$json = json_encode(
$tugas,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);
if (file_put_contents($berkas, $json . "\n", LOCK_EX) === false) {
throw new RuntimeException("gagal menulis berkas tugas: {$berkas}");
}
}