แม้ว่าวิศวกรซอฟต์แวร์จะใช้บรรทัดคำสั่งอย่างสม่ำเสมอสำหรับหลายด้านของการพัฒนา อาร์เรย์ (arrays) น่าจะเป็นหนึ่งในคุณสมบัติที่ค่อนข้างซ่อนเร้นของบรรทัดคำสั่ง (แม้แต่ไม่ซ่อนเร้นเท่ากับ regex operator =~) แต่พ้นจากความซ่อนเร้นและไวยากรณ์ที่น่าสงสัย Bash arrays สามารถมีพลังมากได้
รอ แตะทำไม?
การเขียนเกี่ยวกับ Bash เป็นเรื่องท้าทาย เพราะมันง่ายมากที่บทความจะกลายเป็น manual ที่เน้นความแปลกของไวยากรณ์ รับรองว่เจตนาของบทความนี้คือหลีกเลี่ยงการให้คุณ RTFM (Read The Fine Manual)
ตัวอย่างจริง (ที่มีประโยชน์จริง)
เพื่อให้ถึงจุดประสงค์นั้น มาพิจารณาสถานการณ์จริงและว่า Bash สามารถช่วยได้อย่างไร: คุณนำทีมงานใหม่ที่บริษัทเพื่อประเมียและเพิ่มประสิทธิภาพ runtime ของ internal data pipeline ของคุณ ขั้นตอนแรก คุณต้องการทำ parameter sweep เพื่อประเมินว่า pipeline ใช้ threads ได้ดีแค่ไหน เพื่อความเรียบง่าย เราจะจัดการ pipeline เป็น black box C++ ที่คอมไพล์แล้ว โดยพารามิเตอร์เดียวที่เราสามารถปรับได้คือจำนวน threads ที่สำรองไว้สำหรับการประมวลผลข้อมูล: ./pipeline --threads 4
พื้นฐาน
สิ่งแรกที่เราจะทำคือ กำหนดอาร์เรย์ที่มีค่าของพารามิเตอร์ --threads ที่เราต้องการทดสอบ:
allThreads=(1 2 4 8 16 32 64 128)
ในตัวอย่างนี้ สมาชิกทั้งหมดเป็นตัวเลข แต่ไม่จำเป็นต้องเป็นเช่นนั้น – อาร์เรย์ใน Bash สามารถมีทั้งตัวเลขและสตริงได้ เช่น myArray=(1 2 "three" 4 "five") เป็นนิพจน์ที่ถูกต้อง และเหมือนกับตัวแปร Bash อื่นๆ ให้แน่ใจว่าไม่มีช่องว่างรอบเครื่องหมาย equals sign มิฉะนั้น Bash จะถือว่าชื่อตัวแปรเป็นโปรแกรมที่จะ execute และ = เป็นพารามิเตอร์แรกของมัน!
ตอนนี้เรา initialize อาร์เรย์แล้ว มาดึงสมาชิกบางส่วนมาใช้งาน คุณจะสังเกตว่าการทำ echo $allThreads จะแสดงผลแค่สมาชิกแรกเท่านั้น
เพื่อเข้าใจว่าทำไม เรามาย้อนกลับมาดูว่าเรามักจะ output ตัวแปรใน Bash อย่างไร พิจารณาสถานการณ์ต่อไปนี้:
type="article" echo "Found 42 $type"
สมมติว่าตัวแปร $type ถูกให้มาเป็น singular noun และเราต้องการเพิ่ม s ท้ายประโยค เราไม่สามารถเพิ่ม s ให้ $type ได้ เพราะจะกลายเป็นตัวแปรอื่นคือ $types และแม้เราจะใช้ code contortions อย่าง echo "Found 42 "$type"s" วิธีที่ดีที่สุดคือใช้ curly braces: echo "Found 42 ${type}s" ซึ่งช่วยให้เราบอก Bash ได้ว่าชื่อตัวแปรเริ่มและจบที่ไหน (น่าสนใจที่ syntax นี้เหมือนกับที่ใช้ใน JavaScript/ES6 สำหรับ inject ตัวแปรและนิพจน์ใน template literals)
ดังนั้น แม้ตัวแปร Bash ปกติไม่ต้องการ curly brackets พวกมัน จำเป็น สำหรับอาร์เรย์ ซึ่งทำให้เราสามารถระบุดัชนีเพื่อเข้าถึงได้ เช่น echo ${allThreads[1]} จะคืนค่าสมาชิกที่สองของอาร์เรย์ การไม่ใส่ brackets เช่น echo $allThreads[1] ทำให้ Bash ถือว่า [1] เป็นสตริงและพิมพ์มันออกมาเป็นอย่างนั้น
ใช่ อาร์เรย์ Bash มีไวยากรณ์แปลกๆ แต่พอใจว่ามัน zero-indexed ไม่เหมือนบางภาษา (มองที่คุณ R)
การวนลูปผ่านอาร์เรย์
แม้ในตัวอย่างข้างต้นเราใช้ integer indices ในอาร์เรย์ของเรา มาพิจารณาสองโอกาสที่กรณีนั้นจะไม่ใช่เช่นนั้น: ก่อนอื่น หากเราต้องการสมาชิกที่ $i-th ของอาร์เรย์ โดยที่ $i เป็นตัวแปรที่มีดัชนีที่สนใจ เราสามารถดึงสมาชิกนั้นได้โดยใช้: echo ${allThreads[$i]} ที่สอง เพื่อ output สมาชิกทั้งหมดของอาร์เรย์ เราแทนที่ดัชนีตัวเลขด้วยสัญลักษณ์ @ (คุณคิดถึง @ ว่าเป็น all ได้): echo ${allThreads[@]}
การวนลูปผ่านสมาชิกอาร์เรย์
ด้วยความคิดนั้นในใจ มาวนลูปผ่าน $allThreads และ launch pipeline สำหรับแต่ละค่าของ --threads:
for t in ${allThreads[@]}; do
./pipeline --threads $t
done
การวนลูปผ่านดัชนีอาร์เรย์
ต่อไป มาพิจารณาวิธีที่แตกต่างเล็กน้อย แทนที่จะวนลูปผ่าน สมาชิก ของอาร์เรย์ เราสามารถวนลูปผ่าน ดัชนี ของอาร์เรย์ได้:
for i in ${!allThreads[@]}; do
./pipeline --threads ${allThreads[$i]}
done
มาแยกส่วน: ตามที่เห็นข้างต้น ${allThreads[@]} แทนสมาชิกทั้งหมดในอาร์เรย์ของเรา การเพิ่มเครื่องหมาย exclamation mark ทำให้กลายเป็น ${!allThreads[@]} จะคืนรายการของดัชนีอาร์เรย์ทั้งหมด (ในกรณีของเรา 0 ถึง 7) กล่าวอีกนัยหนึ่ง ลูป for กำลังวนผ่านดัชนีทั้งหมด $i และอ่านสมาชิก $i-th จาก $allThreads เพื่อตั้งค่าพารามิเตอร์ --threads
สิ่งนี้ดูรุนแรงกว่าสำหรับตา ดังนั้นคุณอาจสงสัยว่าทำไมผมถึงนำเสนอมัน ก็เพราะมีครั้งที่คุณต้องรู้ทั้งดัชนีและค่าภายในลูป เช่น หากคุณต้องการข้ามสมาชิกแรกของอาร์เรย์ การใช้ดัชนีช่วยประหยัดคุณจากการสร้างตัวแปรเพิ่มเติมที่คุณต้อง increment ภายในลูป
การเติมค่าลงอาร์เรย์
จนตอนนี้ เราสามารถ launch pipeline สำหรับแต่ละ --threads ที่สนใจได้แล้ว ตอนนี้ สมมติว่า output ของ pipeline ของเราคือ runtime ในวินาที เราต้องการ capture output นั้นในแต่ละ iteration และบันทึกไว้ในอีกอาร์เรย์หนึ่ง เพื่อให้เราสามารถจัดการกับมันได้ที่ท้ายสุด
ไวยากรณ์ที่มีประโยชน์บางอย่าง
แตะก่อนที่จะลงลึกในโค้ด เราต้องแนะนำไวยากรณ์เพิ่มเติม ก่อนอื่น เราต้องสามารถดึง output ของคำสั่ง Bash ได้ ให้ใช้ syntax ต่อไปนี้: output=$( ./my_script.sh ) ซึ่งจะเก็บ output ของคำสั่งของเราไว้ในตัวแปร $output
ไวยากรณ์ที่สองที่เราต้องการคือวิธี append ค่าที่เราเพิ่งดึงมาเข้าไปในอาร์เรย์ ไวยากรณ์ในการทำนั้นจะดูคุ้นเคย:
myArray+=( "newElement1" "newElement2" )
Parameter sweep
รวมทุกอย่างเข้าด้วยกัน นี่คือสคริปต์ของเราสำหรับ launch parameter sweep:
allThreads=(1 2 4 8 16 32 64 128)
allRuntimes=()
for t in ${allThreads[@]}; do
runtime=$(./pipeline --threads $t)
allRuntimes+=( $runtime )
done
โวอิลา!
อะไรอีกบ้าง?
ในบทความนี้ เราครอบคลุมสถานการณ์การใช้อาร์เรย์สำหรับ parameter sweeps แต่ผมสัญญาว่ามีเหตุผลอื่นอีกมากที่จะใช้ Bash arrays – นี่คือตัวอย่างอีกสองอย่าง
Log alerting
ในสถานการณ์นี้ แอปของคุณถูกแบ่งเป็นโมดูล แต่ละตัวมี log file ของตัวเอง เราสามารถเขียน cron job script เพื่อส่งอีเมลแจ้งเตือนคนที่เหมาะสมเมื่อมีสัญญาณปัญหาในโมดูลบางโมดูล:
# List of logs and who should be notified of issues
logPaths=("api.log" "auth.log" "jenkins.log" "data.log")
logEmails=("jay@email" "emma@email" "jon@email" "sophia@email")
# Look for signs of trouble in each log
for i in ${!logPaths[@]};
do
log=${logPaths[$i]}
stakeholder=${logEmails[$i]}
numErrors=$( tail -n 100 "$log" | grep "ERROR" | wc -l )
# Warn stakeholders if recently saw > 5 errors
if [[ "$numErrors" -gt 5 ]];
then
emailRecipient="$stakeholder"
emailSubject="WARNING: ${log} showing unusual levels of errors"
emailBody="${numErrors} errors found in log ${log}"
echo "$emailBody" | mailx -s "$emailSubject" "$emailRecipient"
fi
done
API queries
สมมติว่าคุณต้องการสร้าง analytics เกี่ยวกับผู้ใช้งานที่ comment มากที่สุดบนโพสต์ Medium ของคุณ เนื่องจากเราไม่มีการเข้าถึง database โดยตรง SQL จึงไม่ออกเป็นทางเลือก แต่เราสามารถใช้ APIs!
เพื่อหลีกเลี่ยงการเจาะลึกเกี่ยวกับ API authentication และ tokens เราจะใช้ JSONPlaceholder บริการทดสอบ API สาธารณะ เป็น endpoint ของเรา เมื่อเรา query แต่ละโพสต์และดึงอีเมลของทุกคนที่ comment เราสามารถ append อีเมลเหล่านั้นเข้าไปในอาร์เรย์ผลลัพธ์ของเรา:
endpoint="https://jsonplaceholder.typicode.com/comments"
allEmails=()
# Query first 10 posts
for postId in {1..10};
do
# Make API call to fetch emails of this posts's commenters
response=$(curl "${endpoint}?postId=${postId}")
# Use jq to parse the JSON response into an array
allEmails+=( $( jq '.[].email' <<< "$response" ) )
done
สังเกตที่นี่ว่าผมใช้เครื่องมือ jq เพื่อ parse JSON จากบรรทัดคำสั่ง ไวยากรณ์ของ jq อยู่นอกขอบเขตของบทความนี้ แต่ผมแนะนำอย่างยิ่งให้คุณดูเพิ่มเติม
เช่นคุณนึกภาพได้ มีสถานการณ์นับไม่ถ้วนที่การใช้ Bash arrays สามารถช่วยได้ และผมหวังว่าตัวอย่างที่ रूपรेखาไว้ในบทความนี้จะให้คุณมีอะไรคิดต่อไป หากคุณมีตัวอย่างอื่นจากงานของคุณเอง โปรดแชร์ในความคิดเห็นด้านล่าง
แต่รอ ไม่มีแค่นี้!
เนื่องจากเราครอบคลุมไวยากรณ์อาร์เรย์ค่อนข้างมากในบทความนี้ นี่คือสรุปสิ่งที่เราครอบคลุม พร้อมกับทริคขั้นสูงเพิ่มเติมที่เราไม่ได้ครอบคลุม:
| Syntax | Result |
|---|---|
arr=() |
สร้างอาร์เรย์ว่าง |
arr=(1 2 3) |
Initialize อาร์เรย์ |
${arr[2]} |
ดึงสมาชิกที่สาม |
${arr[@]} |
ดึงสมาชิกทั้งหมด |
${!arr[@]} |
ดึงดัชนีอาร์เรย์ |
${#arr[@]} |
คำนวณขนาดอาร์เรย์ |
arr[0]=3 |
เขียนทับสมาชิกแรก |
arr+=(4) |
Append ค่า |
str=$(ls) |
บันทึก output ของ ls เป็นสตริง |
arr=( $(ls) ) |
บันทึก output ของ ls เป็นอาร์เรย์ของไฟล์ |
${arr[@]:s:n} |
ดึง n สมาชิก starting at index s |
ความคิดสุดท้าย
ตามที่เราค้นพบ Bash arrays แน่ใจว่ามีไวยากรณ์แปลกๆ แต่ผมหวังว่าบทความนี้จะ convince คุณว่าพวกมันมีพลังมาก เมื่อคุณจับ syntax แล้ว คุณจะพบว่าตัวเองใช้ Bash arrays บ่อยขึ้น
Bash หรือ Python?
ซึ่งนำไปสู่คำถาม: เมื่อไหร่ควรใช้ Bash arrays แทนภาษาสคริปต์อื่นๆ เช่น Python?
สำหรับผม ทุกอย่างลดทอนลงที่ dependencies – หากคุณสามารถแก้ปัญหาที่มีอยู่ได้โดยใช้เพียงการเรียกเครื่องมือบรรทัดคำสั่ง คุณควรใช้ Bash แต่สำหรับเวลาที่สคริปต์ของคุณเป็นส่วนหนึ่งของโปรเจกต์ Python ใหญ่กว่า คุณควรใช้ Python
ตัวอย่างเช่น เรอาจจะใช้ Python implement parameter sweep แต่เราจะสุดท้ายแค่เขียน wrapper รอบ Bash:
import subprocess
all_threads = [1, 2, 4, 8, 16, 32, 64, 128]
all_runtimes = []
# Launch pipeline on each number of threads
for t in all_threads:
cmd = './pipeline --threads {}'.format(t)
# Use the subprocess module to fetch the return output
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True)
output = p.communicate()[0]
all_runtimes.append(output)
เนื่องจากไม่มีทางหลีกเลี่ยงบรรทัดคำสั่งในตัวอย่างนี้ การใช้ Bash โดยตรงจึงเหมาะสมกว่า
เวลาสำหรับการโฆษณาตัวเองโดยไม่แอ้มอ้วย
บทความนี้ขึ้นอยู่กับการพูดที่ผมทำที่ OSCON โดยผมเสนอ live-coding workshop You Don't Know Bash ไม่มีสไลด์ ไม่มี clickers – แค่ผมและผู้ชม พิมพ์ไปที่บรรทัดคำสั่ง สำรวจโลกอันแปลกประหลาดของ Bash