2 คะแนน โดย GN⁺ 2024-03-03 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เมื่อสคริปต์ Bash ทำงานต่างจากที่คาดไว้ แค่ใช้วิธี ดูคำสั่งที่ถูกเรียกใช้ตามจริง ก็ช่วยให้ไล่หาสาเหตุได้ง่ายขึ้น
  • set -x จะแสดงแต่ละบรรทัดหลังจากขยายตัวแปรแล้ว ทำให้ตรวจสอบได้ว่าสคริปต์กำลังรันคำสั่งใดจริง ๆ
  • หากรันจากบรรทัดคำสั่งด้วย bash -x script.sh จะให้ผลเหมือนกับการใส่ set -x ไว้ที่ด้านบนของ script.sh
  • เมื่อใช้ trap DEBUG ร่วมกับ read จะหยุดก่อนรันแต่ละบรรทัด เพื่อให้ตรวจสอบชื่อไฟล์ หมายเลขบรรทัด และคำสั่งถัดไปได้
  • ฟังก์ชัน die() { echo $1 >&2; exit 1; } ช่วยทำให้โฟลว์หลังคำสั่งล้มเหลวง่ายขึ้น โดย พิมพ์ข้อความไปยัง standard error แล้วจบการทำงาน

ตรวจสอบโฟลว์การทำงานด้วยสายตา

  • set -x จะแสดงบรรทัดที่สคริปต์รัน และแสดงตัวแปรเป็นค่าที่ถูกขยายแล้ว
  • สามารถใช้งานได้โดยใส่ set -x ไว้ที่ด้านบนของสคริปต์
  • สามารถเรียกพฤติกรรมเดียวกันนี้จากบรรทัดคำสั่งได้เช่นกัน
    • $ bash -x script.sh
    • ซึ่งเหมือนกับการใส่ set -x ไว้บนสุดของ script.sh

หยุดตรวจสอบทีละบรรทัด

  • trap DEBUG จะทำงานก่อนที่โค้ดแต่ละบรรทัดจะถูกเรียกใช้
  • หากใส่โค้ดด้านล่างไว้ช่วงต้นของสคริปต์ จะรอให้กด Enter ก่อนรันคำสั่งถัดไป
    • trap '(read -p "\[$BASH_SOURCE: $LINENO] $BASH_COMMAND")' DEBUG
    • read -p จะแสดงข้อความและรอให้กด Enter
    • $BASH_SOURCE คือชื่อไฟล์สคริปต์
    • $LINENO คือหมายเลขบรรทัด
    • $BASH_COMMAND คือคำสั่งที่จะถูกรันถัดไป

เมื่อเกิดความล้มเหลว ให้ทิ้งข้อความไว้แล้วจบการทำงาน

  • ฟังก์ชัน die สามารถใช้เพื่อแสดงข้อความและจบโปรแกรมเมื่อคำสั่งล้มเหลวได้
    • die() { echo $1 >&2; exit 1; }
    • เพียงต่อท้ายคำสั่งที่อาจล้มเหลว เช่น some_command || die "oh no!"
  • ฟังก์ชันนี้จะส่งข้อความไปยัง standard error และจบการทำงานด้วย exit 1

1 ความคิดเห็น

 
GN⁺ 2024-03-03
ความคิดเห็นจาก Hacker News
  • ใน ZFSBootMenu มีการใช้ ฟังก์ชันที่ทำเอง ดี ๆ อยู่หลายตัวเพื่อช่วยในการดีบัก
    มีฟังก์ชันล็อก zdebug แทรกอยู่ทั่วโค้ด: https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme...
    ถ้าเปิด debug logging แล้วกด Ctrl-T จากเมนูหลัก จะเห็นหน้าจอแบบนี้: https://i.imgur.com/Ge75zkP.png
    นอกจากนี้ยังมี การทำ profiling แบบ flamegraph ที่เปิดได้ด้วย https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme... และถ้านำข้อมูลที่ dump ผ่านพอร์ตอนุกรมมาประกอบกลับ ก็สร้างกราฟแบบ https://raw.githubusercontent.com/zbm-dev/zfsbootmenu/master... ได้
    Bash ยืดหยุ่นกว่าที่คิด
  • เวลาใช้ set -x การตั้ง PS4='+ ${BASH_SOURCE:-}:${FUNCNAME[0]:-}:L${LINENO:-}: ' มีประโยชน์มาก
    แบบนี้จะแสดง ชื่อไฟล์ ชื่อฟังก์ชัน และเลขบรรทัด ช่วยได้ไม่น้อยเวลาต้องดีบักสคริปต์ Bash ขนาดใหญ่
  • แนะนำ shellcheck ด้วย แม้มันจะจับปัญหาให้โดยตรงไม่ได้ ก็ยังช่วยบอก ปัญหาที่อาจเกิดขึ้น ได้
    อีกอย่าง แนะนำให้เขียนสคริปต์ใหม่ด้วยภาษาอื่นด้วย ที่บริษัทกำลังเปลี่ยนสคริปต์ Bash ไปเป็น Rust อยู่ แม้ต้นทุนในการเริ่มต้นจะสูง แต่โค้ดที่ได้ดูแลง่ายกว่าและน่าเชื่อถือกว่ามาก
    Bash ยังเหมาะกับสคริปต์เร็ว ๆ อยู่ แต่พอเกินราว 100 บรรทัด ก็คุ้มที่จะใช้ภาษาที่ให้การรับประกันที่แข็งแรงกว่า
    • เห็นด้วย แต่เรื่องนี้ควรไปพูดกับฝั่ง วิศวกรรม CI/CD และ pipeline YAML ด้วย
  • ถ้าใช้ exit code แบบนี้ จะปรับปรุงการดีบักได้อีก
    die() เป็นฟังก์ชันช่วยที่พิมพ์ข้อความผิดพลาดไปยัง standard error แล้วออกด้วยรหัสข้อผิดพลาดที่กำหนด
    exit code และฟังก์ชันช่วยสำหรับ shell script เพิ่มเติมอยู่ที่นี่: https://github.com/SixArm/unix-shell-script-kit/blob/main/un...
    • เป็นรายการที่ดี และผู้ใช้ที่ชำนาญก็น่าจะมีฟังก์ชันช่วยของตัวเองกันอยู่แล้ว
      แต่ปรัชญาของ die ตัวนั้นยังน่าเสียดายนิดหน่อย ฟังก์ชัน die โดยพื้นฐานควรส่งต่อ exit code ของคำสั่งที่ล้มเหลว และไม่ควรซ่อน error output ของคำสั่งนั้น
      ถ้าในสคริปต์ใหญ่ ๆ ผมอยากกำหนดความหมายให้ความล้มเหลวของคำสั่งเอง ก็คงใช้ die ที่เฉพาะทางกว่าแยกต่างหาก die ของผมประมาณว่าเป็น __errex "$?" "${LINENO}" "$0" เพื่อพิมพ์ข้อผิดพลาดร้ายแรง เลขบรรทัด ชื่อสคริปต์ และข้อความ แล้วจบด้วย exit code นั้น
  • ถ้าใช้ฟังก์ชัน Bash เยอะ ๆ ก็ทำ stack trace แบบหนึ่งได้
    ตัวอย่าง implementation หนึ่งอยู่ที่นี่: https://github.com/TritonDataCenter/sdc-headnode/blob/master...
    • ยังมี implementation ของ stack trace อีกแบบ: https://github.com/runag/runag/blob/main/lib/fail.sh
      ใช้แบบ some-command || fail "message" แล้วเมื่อ some-command คืนค่าสถานะการจบที่ไม่ใช่ 0 ก็จะสร้าง stack trace และออกจาก shell
      ถ้าอยากสร้าง stack trace ในฟังก์ชันแล้ว return กลับไป ใช้แบบ some-command || softfail "message" || return $? ได้
  • สงสัยว่าเหตุผลที่ Bash ยังเป็นภาษา shell scripting โดยพฤตินัยอยู่ มีอย่างอื่นนอกจาก แรงเฉื่อยจาก legacy ไหม
    มันทำสิ่งที่ต้องทำได้ก็จริง แต่เทอะทะและไวยากรณ์ก็แย่มาก พอสคริปต์โตหรือซับซ้อนถึงระดับหนึ่ง มันก็เหมือนบังคับให้ย้ายไปใช้ภาษาจริงจัง บางทีนี่อาจเป็นการออกแบบโดยตั้งใจก็ได้
    • จริงที่ปริมาณการใช้งานแบบ legacy เป็นส่วนใหญ่ของความนิยม
      ถ้าเป็น distro สมัยใหม่ ก็มักมี Bash เวอร์ชันค่อนข้างใหม่ และถ้าไม่ได้จะใช้ของอย่าง array ก็แทบไม่ต้องใส่ใจเวอร์ชันมากนัก
      เสน่ห์ของ Bash อยู่ที่ตำแหน่งของมันระหว่างภาษาและเครื่องมืออื่น ๆ มันเหมาะมากสำหรับผูกเครื่องมือต่าง ๆ เข้าด้วยกัน และอยู่ใกล้ระบบปฏิบัติการพอให้ใช้งานสะดวก อีกทั้งไม่ต้องติดตั้งไลบรารีเหมือน Python
      มักได้ยินว่าถ้าสคริปต์ซับซ้อนกว่านี้ให้ย้ายไปภาษาอย่าง Python แต่ก็เป็นการเพิ่มชั้นความซับซ้อนที่ในระยะยาวอาจไม่ได้ช่วย สคริปต์ Bash ที่เขียนไว้เมื่อ 20 ปีก่อนยังรันได้ดีอยู่ แต่โปรแกรม Python เมื่อ 20 ปีก่อนมีโอกาสสูงที่จะเจอปัญหาเรื่องเวอร์ชัน
    • การเขียนสคริปต์ Bourne shell นั้น ดีพอแล้ว จนแทบแทนที่ไม่ได้
      rc ของ Plan 9 สะอาดกว่านิดหน่อย แต่แค่ “คล้ายกันแต่สะอาดกว่า” ไม่ทำให้ใครย้ายตาม ตอนนี้ก็สามารถติดตั้งตัวที่คล้ายกันแต่ดีกว่าได้ทันทีจาก https://pkgsrc.se/shells แต่เราก็ยังไม่ใช้กัน และวิธีรันให้คนอื่นก็จะไม่เหมือนเดิมด้วย
      ถ้าจะทดแทนเทคโนโลยีที่ตั้งหลักได้แล้ว ต้องดีกว่าหลายเท่าในด้านสำคัญ ๆ Plan 9 เองก็ดีกว่าสาย UNIX แต่ก็ยังไม่ดีพอจะทดแทน
      การสร้างอะไรสักอย่างที่ดีพอจะมาแทนช่องเฉพาะของ Bourne shell scripting นั้นยาก ก่อนจะดีถึงขั้นนั้น มันก็จะข้ามไปอยู่ใน niche ทางนิเวศหรือ problem space ของภาษาสคริปต์จริง ๆ อย่าง Perl, Python, Ruby ไปแล้ว
      ใน problem space แคบ ๆ local optimum จะดูดอากาศไปหมด ทำให้คู่แข่งที่ใกล้เคียงกับ global optimum ในเชิงทฤษฎีเกิดขึ้นได้ยาก
    • ผมคิดว่าจริง ๆ แล้วเป็นเพราะ legacy และแรงเฉื่อย นั่นแหละ

แม้ว่าฟีเจอร์ที่เพิ่มเข้ามาใหม่ ๆ จะวางทับบน sh/Bash ได้ค่อนข้างดี แต่ท้ายที่สุด shell scripting ก็เป็นเพียงเครื่องมือเพื่อบรรลุเป้าหมาย และควรวิวัฒนาการช้ากว่าภาษาโปรแกรมทั่วไปมาก
คุณลักษณะสำคัญของ Bash/sh คือมันมีลักษณะต้านเอนโทรปี แทบไม่มีการพัฒนาหรือวิวัฒนาการ จึงมีโอกาสน้อยที่จะปวดหัวเพราะ dependency หรือฟีเจอร์ใหม่ ๆ และสิ่งที่ใช้ได้เมื่อ 20 ปีก่อนก็ยังคงอยู่เป็นเครื่องมือพื้นฐานต่อไป
ด้วยการออกแบบเช่นนั้น มันจึงเป็นระบบที่ต่อต้านการเปลี่ยนแปลง และเมื่อชนขีดจำกัด ก็สร้างแรงจูงใจให้ผู้คนออกไปใช้อย่างอื่นนอกเหนือจากมัน

  • ไม่แน่ใจว่านั่นคือ Bash จริงหรือไม่
    สคริปต์ส่วนใหญ่ของ FreeBSD เขียนมาสำหรับ sh และ sh เป็นส่วนหนึ่งของมาตรฐาน POSIX จึงรู้สึกว่ารองรับได้กว้างกว่ามาก ส่วน Bash มองว่าแค่ค่อนข้างได้รับความนิยมเท่านั้น
  • จุดใหญ่คือมันมีอยู่ทุกที่
    แต่ Bash แย่มากจนผมทำยูทิลิตีที่ย่อ namespace ไว้จำนวนมากเพื่อจะใช้สคริปต์ Groovy แทน สามารถพัฒนาด้วย IDE ได้ ระบบไลบรารีก็ปลอดภัย และ Groovy ก็ขัดเกลาความไม่สะดวกของ Java ไปเกือบหมด จึงดีกว่ามาก
  • มี ดีบักเกอร์จริงสไตล์ gdb ที่ค่อนข้างทรงพลังด้วย: https://bashdb.sourceforge.net/
  • ขอโปรโมตเรื่องที่เกี่ยวข้องนิดหนึ่ง เมื่อก่อนผมเคยทำ ดีบักเกอร์สำหรับ Bash pipeline ที่เก็บผลลัพธ์ระหว่างทางไว้
    แม้จะมีข้อจำกัดบางอย่าง แต่โดยทั่วไปอาจมีประโยชน์: https://github.com/ketancmaheshwari/pd
  • เทคนิค die() นั้นดี แต่ Bash มีคุณสมบัติน่ารำคาญอยู่อย่างหนึ่ง คือถ้าพยายาม exit ภายใน subshell จะจบแค่ subshell เท่านั้น ส่วนที่เหลือของสคริปต์ยังคงรันต่อ
    ตัวอย่างเช่น ถ้าเรียก die ภายใน pipeline อย่าง cat myfile | while read line; do ... die "Found match" ... done บรรทัด echo "I don't want this line" ที่ตามมาจะยังคงถูกพิมพ์ออกมา
    หลายกรณีหลีกเลี่ยง subshell ได้ และในตัวอย่างนี้ shellcheck ชี้เรื่อง UUOC ได้ถูกต้อง และเมื่อแก้ตรงนั้น ปัญหา die ใน subshell ก็จะหายไปด้วย
    แต่บางครั้งก็หลีกเลี่ยง subshell ไม่ได้ หรือถ้าจะหลีกเลี่ยง สคริปต์จะซับซ้อนเกินไป ในกรณีนั้นสามารถเก็บ PID ไว้ตอนเริ่มสคริปต์ด้วย MYPID=$$ แล้วฆ่ามันด้วยแบบ die() { echo "$1" >&2; kill -9 $MYPID; exit 1; } ได้
    แน่นอนว่าวิธีนี้ก็มีข้อแลกเปลี่ยน วิธีฆ่าแบบนี้ค่อนข้างหยาบ และไม่รู้ทำไม แต่ก็ไม่ได้น่าเชื่อถือเต็มที่ด้วย
    • แค่เพิ่ม set -e เข้าไป เมื่อ subshell จบด้วยโค้ดข้อผิดพลาดที่ไม่ใช่ 0 สคริปต์ก็จะจบตามไปด้วย
      นึกเหตุผลไม่ค่อยออกว่าทำไมสคริปต์ shell ใด ๆ ถึงควรละ set -e ไว้
    • ถ้าฆ่า PID แบบนั้น อาจทำให้เกิด zombie process ได้หรือเปล่า?
  • ที่ส่วนบนสุดของสคริปต์ Bash ผมใส่ set -euxo pipefail ไว้เสมอ
    แม้จะทำให้การทดสอบเงื่อนไขยากขึ้นบ้าง แต่โดยเฉพาะแค่ pipefail ตัวเดียวก็คุ้มค่าหลายครั้งแล้ว
    • https://mywiki.wooledge.org/BashPitfalls#set_-euo_pipefail
    • สามารถเปิดใช้กับหลายบรรทัดแล้วปิดด้วย set +x ได้
      ถ้าเปิดไว้ตลอดจะน่าเบื่อพอสมควร
    • เป็นการตั้งค่าที่เหมือนผู้ช่วยชีวิต
      แต่ -x ผมจะเก็บไว้จนกว่าจะต้องดูเอาต์พุตดีบักรก ๆ ทั้งหมดจริง ๆ