Chương 07 — Shell scripting

Tới đây bạn đã quen với từng lệnh đơn lẻ. Chương này gói chúng lại thành script — file .sh chạy được, lặp lại được, share được. Mục tiêu: viết script ngắn nhưng vững, không cần chuyển sang Python cho mấy việc tự động.

1. Script đầu tiên

Một shell script chỉ là file text chứa các lệnh bạn vẫn gõ trên terminal, cộng thêm 1 dòng đầu tiên đặc biệt gọi là shebang.

# file: hello.sh
#!/usr/bin/env bash
echo "Xin chào, $(whoami)!"
echo "Hôm nay là $(date +%Y-%m-%d)"

Lưu file rồi cấp quyền chạy và gọi nó:

$ chmod +x hello.sh   # cấp quyền execute
$ ./hello.sh           # chạy bằng path tương đối
Xin chào, vietanh!
Hôm nay là 2026-05-08

Vì sao phải có ./?

Shell chỉ tự tìm lệnh trong các thư mục liệt kê ở biến $PATH. Folder hiện tại không nằm trong đó (vì lý do bảo mật) → bạn phải nói rõ "lấy file ở thư mục hiện tại" bằng ./.

Shebang: bash vs zsh vs sh

ShebangÝ nghĩaKhi nào dùng
#!/bin/shPOSIX shell — chuẩn tối thiểu, thiếu nhiều tính năngScript cần chạy trên mọi Unix (cả container Alpine)
#!/bin/bashBash ở path cố địnhLinux thì OK; macOS có bash cũ ở /bin/bash
#!/usr/bin/env bashTìm bash mới nhất trong $PATHKhuyến nghị — portable nhất
#!/usr/bin/env zshDùng zshKhi script cần tính năng riêng của zsh (hiếm khi cần)
📘 Vì sao mặc định nên là bash, không zsh?
Mặc dù macOS đặt zsh làm shell tương tác mặc định, script tự động hóa nên viết bằng bash: cú pháp ổn định hơn, có ở mọi Linux server, tài liệu phong phú. Zsh để dùng tương tác (chương 6 alias/zshrc); bash để viết script.

2. Biến & quoting

Khai báo & đọc biến

name="Việt"          # KHÔNG có space quanh dấu =
greeting="Xin chào, $name"
echo "$greeting"     # → Xin chào, Việt
⚠️ Lỗi kinh điển: space quanh dấu =
name = "Việt" SAI — bash hiểu là "chạy lệnh tên name với 2 arg ="Việt"". Phải viết liền: name="Việt".

Single quote vs double quote

LoạiHành viVí dụ
'...' (single)Literal — KHÔNG expand biếnecho '$name'$name
"..." (double)Expand biến và $(...)echo "$name"Việt
Không quoteExpand + word-splitting + glob (nguy hiểm)echo $name → vỡ khi có space

Command substitution: $(...)

Chạy 1 lệnh và thay thế bằng output của nó:

today="$(date +%Y-%m-%d)"
files="$(ls *.txt | wc -l)"
echo "Hôm nay $today, có $files file txt"

Cú pháp cũ `...` (backtick) vẫn chạy nhưng không lồng được và khó đọc — luôn ưu tiên $(...).

Default value: ${var:-default}

name="${1:-stranger}"   # nếu $1 trống → name = "stranger"
echo "Hello, $name"
Cú phápÝ nghĩa
${var:-x}Nếu var trống/chưa set → trả về x (không gán)
${var:=x}Nếu var trống → gán var=x rồi trả về
${var:?msg}Nếu var trống → in msg và exit lỗi
${var:+x}Nếu var có giá trị → trả về x
⚠️ LUÔN double-quote biến chứa path
Có file tên "my notes.txt". Gõ rm $file (không quote) → bash split thành 2 arg: mynotes.txt, lệnh xóa nhầm 2 file khác. Đúng: rm "$file". Nguyên tắc vàng: mọi biến trong script đều bọc "..." trừ khi có lý do rất rõ ràng để không quote.

3. Đối số & exit code

Biến đặc biệt

BiếnÝ nghĩa
$0Tên của script (thường là path đã gọi)
$1, $2, ..., $9Đối số thứ 1, 2, ..., 9
${10}, ${11}...Đối số ≥10 phải có ngoặc nhọn
$#Số lượng đối số
$@Tất cả đối số (mảng — khi quote: giữ nguyên từng arg)
$*Tất cả đối số gộp thành 1 chuỗi (ngăn cách bằng $IFS)
$?Exit code của lệnh vừa chạy xong
$$PID của script hiện tại

Ví dụ

# file: greet.sh
#!/usr/bin/env bash
echo "Script: $0"
echo "Số arg: $#"
echo "Arg đầu: $1"
echo "Tất cả: $@"
$ ./greet.sh hello world
Script: ./greet.sh
Số arg: 2
Arg đầu: hello
Tất cả: hello world

Exit code: 0 = success

Mọi lệnh khi kết thúc đều trả về 1 số nguyên 0–255. Convention cứng trên Unix:

$ ls /etc
... (danh sách file)
$ echo $?
0

$ ls /khong-ton-tai
ls: /khong-ton-tai: No such file or directory
$ echo $?
1

Trong script, exit N kết thúc sớm với code N:

#!/usr/bin/env bash
if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <name>" >&2   # in lỗi ra stderr
  exit 1
fi
echo "Hello, $1"
exit 0

4. Điều kiện & test

Cú pháp if

if [[ -f "$file" ]]; then
  echo "File tồn tại"
elif [[ -d "$file" ]]; then
  echo "Là folder"
else
  echo "Không tồn tại"
fi

[ ] vs [[ ]] — khác biệt quan trọng

 [ ] (test)[[ ]] (bash builtin)
Có ởMọi POSIX shell (sh, dash...)Bash, zsh, ksh
Word-splitting biến chưa quoteCó — vỡ khi biến có spaceKhông — an toàn hơn
So sánh chuỗi=== hoặc =; còn match pattern: [[ "$f" == *.txt ]]
RegexKhôngCó: [[ "$s" =~ ^[0-9]+$ ]]
Logic AND/OR-a, -o (deprecated)&&, ||
💡 Khuyến nghị
Trong script bash → luôn dùng [[ ]]. An toàn hơn, dễ đọc hơn, hỗ trợ regex. Chỉ dùng [ ] khi shebang là #!/bin/sh (cần chạy POSIX thuần).

Test operators thường dùng

OperatorĐúng khi
-f pathLà file thường (regular file) và tồn tại
-d pathLà folder và tồn tại
-e pathTồn tại (kiểu gì cũng được)
-r/-w/-x pathCó quyền đọc/ghi/chạy
-s pathFile tồn tại và không rỗng
-z "$s"Chuỗi rỗng
-n "$s"Chuỗi không rỗng
"$a" == "$b"Chuỗi bằng nhau
"$a" != "$b"Chuỗi khác nhau
$x -eq $ySố bằng nhau
$x -ne $ySố khác nhau
$x -lt $y / -leNhỏ hơn / nhỏ hơn hoặc bằng
$x -gt $y / -geLớn hơn / lớn hơn hoặc bằng

Shortcut &&||

Đã biết từ Chương 1: A && B = chạy B nếu A thành công; A || B = chạy B nếu A thất bại. Trong script, dùng để viết guard ngắn gọn:

[[ -d "$dir" ]] || mkdir -p "$dir"   # tạo nếu chưa có
[[ -f "$f" ]] && echo "OK" || echo "missing"

5. Vòng lặp

for trên list cố định

for i in 1 2 3; do
  echo "Lần $i"
done

for name in alice bob charlie; do
  echo "Hello, $name"
done

for trên glob (mạnh nhất)

# in tên + số dòng cho mọi file .txt
for f in *.txt; do
  lines="$(wc -l < "$f")"
  echo "$f: $lines lines"
done

Ví dụ thực: rename hàng loạt .txt.md

for f in *.txt; do
  mv "$f" "${f%.txt}.md"
done

${f%.txt} = "lấy $f rồi cắt đuôi .txt ở cuối". Đây là parameter expansion — bash có rất nhiều dạng khác (%, %%, #, ##, /...).

for ((...)) kiểu C

for ((i=1; i<=5; i++)); do
  echo "i = $i"
done

while đọc từng dòng file

while read -r line; do
  echo "Đọc: $line"
done < urls.txt
⚠️ Đừng for line in $(cat file)
Đó là anti-pattern — nó split theo $IFS (mặc định: space + tab + newline) → vỡ với dòng có space. Đúng: while read -r line; do ... done < file.

6. Hàm + set -euo pipefail

Định nghĩa hàm

# 2 cú pháp đều hợp lệ — chọn 1, dùng nhất quán
greet() {
  echo "Hello, $1"
}

function add() {
  echo $(($1 + $2))
}

greet "Việt"           # Hello, Việt
result="$(add 3 5)"   # 8

Trong hàm: $1, $2... là arg của hàm (không phải arg script). return N trả exit code (0–255), không trả giá trị — muốn trả giá trị thì echo rồi capture bằng $(...).

Biến local

count() {
  local n="$1"          # local: chỉ tồn tại trong hàm
  echo "Count: $n"
}

Best practice: mở đầu mọi script bằng set -euo pipefail

#!/usr/bin/env bash
set -euo pipefail
# ... script bắt đầu từ đây
CờTác dụng
-e (errexit)Fail fast: nếu bất kỳ lệnh nào trả exit ≠ 0, script dừng ngay. Mặc định bash chạy tiếp như chưa có gì xảy ra — rất nguy hiểm.
-u (nounset)Đụng tới biến chưa được set → báo lỗi và dừng. Ngăn lỗi typo (vd $HOEM) lặng lẽ thành chuỗi rỗng.
-o pipefailTrong A | B | C, mặc định bash chỉ check exit code của C. Có pipefail: nếu bất kỳ lệnh nào trong pipe fail → cả pipe fail. Cần thiết khi dùng set -e.
💡 Vì sao set -euo pipefail gần như bắt buộc
Mặc định bash tha thứ quá nhiều: lệnh fail vẫn chạy tiếp, biến typo trả về rỗng, pipe ẩn lỗi. Hậu quả: script chạy "thành công" nhưng không làm gì, hoặc xóa nhầm vì biến rỗng. Một dòng set -euo pipefail biến bash từ "ngôn ngữ giấy" thành "ngôn ngữ có kỷ luật". Đặt ngay sau shebang.

Bonus: trap dọn dẹp

Khi script bị Ctrl+C giữa chừng hoặc thoát do lỗi, bạn vẫn muốn xóa file tạm:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT   # chạy khi script kết thúc (kiểu nào cũng vậy)

echo "data" > "$tmp"
# ... xử lý $tmp ...
# khi script thoát: trap tự gọi rm -f $tmp — không leak file tạm

EXIT là pseudo-signal chạy mọi trường hợp thoát. Có thể trap riêng INT (Ctrl+C), TERM (kill), ERR (lỗi).

7. Thử ngay trong terminal giả lập

Mở terminal thật, tạo file demo.sh rồi thử các bước:

  1. Tạo file: touch demo.sh && chmod +x demo.sh
  2. Mở bằng editor (nano demo.sh hoặc code demo.sh), paste:
    #!/usr/bin/env bash
    set -euo pipefail
    
    name="${1:-bạn}"
    echo "Xin chào, $name!"
    echo "Số arg nhận được: $#"
  3. Chạy: ./demo.sh rồi ./demo.sh Việt → so sánh output.
  4. Thử biến chưa set: thêm dòng echo "$undefined_var" → script fail vì -u.
  5. Tự viết 1 vòng for f in *.sh; do echo "$f"; done trong script.
you@mac — chuong7 practice
you@mac ~ $

8. Bài tập về nhà

Mỗi script nhớ: shebang #!/usr/bin/env bash + set -euo pipefail + chmod +x.

  1. hello.sh — nhận 1 arg là tên, in "Hello, <tên>". Nếu không có arg, in usage và exit 1.
  2. backup.sh — nhận 1 arg là path folder, tạo file folder-YYYYMMDD-HHMMSS.tar.gz chứa folder đó. Gợi ý: tar -czf "name.tar.gz" "$1"; timestamp lấy từ $(date +%Y%m%d-%H%M%S).
  3. count-lines.sh — đếm tổng số dòng code trong project: tham số là folder + extension (vd ./count-lines.sh ~/myproj js). Dùng find + wc -l. Bonus: in từng file kèm số dòng, sắp xếp giảm dần.
  4. check-url.sh — nhận 1 URL, dùng curl -s -o /dev/null -w "%{http_code}" "$url" để lấy HTTP status. In "OK" nếu code 200, "DOWN" ngược lại. Exit code phản ánh kết quả (0 nếu sống, 1 nếu chết).
  5. rename-ext.sh — đổi đuôi hàng loạt. Vd ./rename-ext.sh txt md đổi mọi *.txt trong folder hiện tại sang .md.
  6. greet-all.sh — nhận nhiều arg, in lời chào cho từng người trên 1 dòng riêng. Sau đó in tổng số người. Dùng for x in "$@".
  7. safe-rm.sh — wrapper an toàn cho rm: hỏi y/N trước khi xóa, log file đã xóa vào ~/.safe-rm.log. Gợi ý: read -r answer.
  8. disk-watch.sh — kiểm tra ổ cứng còn dưới 10% trống thì in cảnh báo. Dùng df -h / + awk (Chương 5).
  9. (thử thách) deploy-mock.sh — mô phỏng deploy: tạo file tạm bằng mktemp, viết log vào đó, dùng trap để LUÔN xóa file tạm khi thoát (kể cả khi Ctrl+C giữa chừng). Test bằng cách thêm sleep 10 rồi nhấn Ctrl+C.

9. Quiz — Kiểm tra kiến thức

10. Tổng kết

Chương 8 chúng ta sẽ học Git & SSH — version control và remote access. Có script đã rồi, bạn sẽ thấy git hooks và deploy script tự nhiên hơn nhiều.

Trước ← Chương 06: Shell config & Aliases Tiếp theo Chương 08: Git & SSH →