从JumpServer安装脚本看Shell编程:一个运维人员的实战解析
最近在部署JumpServer的时候,仔细研究了一下它的安装脚本,发现这个脚本写得还挺有意思的。作为一个天天和Shell打交道的运维,我觉得这个脚本很适合用来讲解Shell编程的一些实用技巧。
说实话,很多人觉得Shell脚本就是简单的命令堆砌,但实际上一个好的Shell脚本包含了很多编程思想和最佳实践。今天就通过这个JumpServer的jmsctl.sh脚本,来聊聊Shell编程中那些值得学习的地方。
脚本结构设计:模块化思维很重要
看这个脚本的整体结构,我第一眼就被它的组织方式吸引了。作者把整个脚本分成了几个清晰的部分:
#!/usr/bin/env bash
export SHELLOPTS
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
cd "${PROJECT_DIR}" || exit 1
. "${PROJECT_DIR}/scripts/utils.sh"
这几行代码看似简单,但包含了很多门道。#!/usr/bin/env bash
这个shebang比直接写#!/bin/bash
要好,因为它会在PATH中查找bash,兼容性更强。
export SHELLOPTS
这行我之前还真没怎么用过,查了一下发现它是用来导出Shell选项的,这样在调用子脚本时能保持一致的行为。
获取脚本所在目录的那行代码写得很巧妙:
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
${BASH_SOURCE[0]}
比$0
更可靠,特别是在脚本被source的时候。&>/dev/null
把错误输出也重定向了,避免在某些环境下出现奇怪的错误信息。
参数处理:简单但实用
action=${1-}
target=${2-}
args=("$@")
这种参数处理方式很实用。${1-}
的写法比直接用$1
安全,当没有参数时不会报错。把所有参数存到数组里也是个好习惯,后面需要传递给其他命令时很方便。
我以前写脚本经常直接用$1
、$2
,但在一些边界情况下容易出问题。现在我也开始用这种写法了。
配置文件检查:防御性编程
function check_config_file() {
if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "$(gettext 'Configuration file not found'): ${CONFIG_FILE}"
echo "$(gettext 'If you are upgrading from v1.5.x, please copy the config.txt To') ${CONFIG_FILE}"
return 3
fi
# ...
}
这个函数体现了防御性编程的思想。不仅检查配置文件是否存在,还给出了具体的解决建议。返回值用3而不是1,这样可以区分不同类型的错误。
软链接的处理也很细致:
if [[ -f .env ]]; then
if ! ls -l .env | grep "${CONFIG_FILE}" &>/dev/null; then
echo ".env $(gettext 'There is a problem with the soft connection, Please update it again')"
rm -f .env
fi
fi
检查软链接是否正确指向目标文件,如果不对就重新创建。这种处理方式在实际运维中很有用,避免了因为软链接问题导致的各种奇怪错误。
国际化支持:细节决定成败
脚本中大量使用了$(gettext '...')
来支持多语言,这个细节让我印象深刻。虽然很多人写脚本时不会考虑国际化,但对于要分发给全球用户的开源项目来说,这确实很重要。
echo "$(gettext 'JumpServer Deployment Management Script')"
gettext是GNU的国际化工具,通过它可以根据系统语言环境显示对应的文本。虽然在内部项目中可能用不到,但这种思维方式值得学习。
帮助信息:用户体验很关键
function usage() {
echo "$(gettext 'JumpServer Deployment Management Script')"
echo
echo "Usage: "
echo " ./jmsctl.sh [COMMAND] [ARGS...]"
echo " ./jmsctl.sh --help"
echo
echo "Installation Commands: "
echo " install $(gettext 'Install JumpServer')"
echo " upgrade $(gettext 'Upgrade JumpServer')"
# ...
}
这个帮助函数写得很规范,命令分类清晰,每个命令都有说明。我见过太多脚本要么没有帮助信息,要么帮助信息写得很随意。一个好的帮助信息能大大提升用户体验。
服务管理:Docker Compose的封装
function start() {
${EXE} up -d
}
function stop() {
if [[ -n "${target}" ]]; then
${EXE} stop "${target}" && ${EXE} rm -f "${target}"
return
fi
${EXE} down -v
}
这里的设计很巧妙,${EXE}
变量存储的是docker-compose的完整命令行,包括配置文件路径等参数。这样在各个函数中就可以直接使用,避免了重复代码。
stop函数还考虑了单个服务和全部服务的不同处理方式,这种灵活性在实际使用中很有价值。
错误处理:让脚本更健壮
cd "${PROJECT_DIR}" || exit 1
这种写法比单独的cd命令要安全得多。如果目录切换失败,脚本会立即退出,避免在错误的目录下执行后续命令。
pre_check || return 3
在主要操作前进行预检查,失败时返回特定的错误码。这样调用者可以根据返回值判断具体的错误类型。
版本检查:自动化运维的体现
function check_update() {
current_version=$(get_current_version)
latest_version=$(get_latest_version)
if [[ "${current_version}" == "${latest_version}" ]]; then
echo_green "$(gettext 'The current version is up to date'): ${latest_version}"
echo
return
fi
# ...
}
自动检查版本更新的功能很实用,特别是对于需要经常更新的系统。这种自动化的思维在运维工作中很重要。
平台兼容性:考虑周全
if [[ "${OS}" == 'Darwin' ]]; then
echo
echo "$(gettext 'Unsupported Operating System Error')"
echo "$(gettext 'macOS installer please see'): https://github.com/jumpserver/Dockerfile"
exit 0
fi
检查操作系统类型并给出相应的提示,这种处理方式很专业。不是简单地报错退出,而是告诉用户应该怎么办。
Case语句:清晰的命令分发
case "${action}" in
install)
bash "${SCRIPT_DIR}/4_install_jumpserver.sh"
;;
upgrade)
bash "${SCRIPT_DIR}/7_upgrade.sh" "$target"
;;
# ...
esac
用case语句来处理不同的命令,比一堆if-elif要清晰得多。每个命令对应一个具体的操作,有些调用其他脚本,有些执行内部函数,分工明确。
日志查看:实用的调试功能
tail)
if [[ -z "${target}" ]]; then
${EXE} logs --tail 100 -f
else
docker_name=$(service_to_docker_name "${target}")
docker logs -f "${docker_name}" --tail 100
fi
;;
这个tail命令的实现很贴心,既可以查看所有服务的日志,也可以查看单个服务的日志。在调试问题时这个功能特别有用。
一些值得学习的编程技巧
看完这个脚本,我总结了几个值得学习的地方:
变量命名要有意义。像PROJECT_DIR
、CONFIG_FILE
这样的变量名一看就知道是什么,比dir
、file
要好得多。
善用数组。args=("$@")
把所有参数存到数组里,后面使用时很方便。
返回值要有意义。不同的错误情况返回不同的值,这样调用者可以做相应的处理。
输出重定向要合理。&>/dev/null
、2>/dev/null
这些用法要根据实际需要选择。
字符串比较用双括号。[[ ]]
比[ ]
功能更强,支持模式匹配等高级特性。
说到这里,我想起之前写过一个类似的部署脚本,当时图省事很多地方都没考虑周全,后来在生产环境中遇到了不少问题。现在回头看,确实应该多花点时间在错误处理和边界情况上。
实际应用中的思考
这个脚本还有一个很好的地方就是它的扩展性。通过raw
命令可以直接执行docker-compose的原生命令:
raw)
${EXE} "${args[@]:1}"
;;
这样既提供了便捷的封装命令,又保留了原生命令的灵活性。这种设计思路在很多场景下都适用。
另外,脚本中的模块化设计也很值得借鉴。主脚本负责参数解析和命令分发,具体的功能实现放在单独的脚本文件中。这样既保持了主脚本的简洁,又便于维护和测试。
不过这个脚本也不是完美的,比如错误信息的处理还可以更统一一些,某些函数的命名可能不够直观。但总体来说,这是一个很好的Shell脚本实践案例。
源码
#!/usr/bin/env bash
#
export SHELLOPTS
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
cd "${PROJECT_DIR}" || exit 1
. "${PROJECT_DIR}/scripts/utils.sh"
action=${1-}
target=${2-}
args=("$@")
function check_config_file() {
if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "$(gettext 'Configuration file not found'): ${CONFIG_FILE}"
echo "$(gettext 'If you are upgrading from v1.5.x, please copy the config.txt To') ${CONFIG_FILE}"
return 3
fi
if [[ -f .env ]]; then
if ! ls -l .env | grep "${CONFIG_FILE}" &>/dev/null; then
echo ".env $(gettext 'There is a problem with the soft connection, Please update it again')"
rm -f .env
fi
fi
if [[ ! -f ".env" ]]; then
ln -s "${CONFIG_FILE}" .env
fi
if [[ ! -f "./compose/.env" ]]; then
ln -s "${CONFIG_FILE}" ./compose/.env
fi
}
function pre_check() {
check_config_file || return 3
}
function usage() {
echo "$(gettext 'JumpServer Deployment Management Script')"
echo
echo "Usage: "
echo " ./jmsctl.sh [COMMAND] [ARGS...]"
echo " ./jmsctl.sh --help"
echo
echo "Installation Commands: "
echo " install $(gettext 'Install JumpServer')"
echo " upgrade $(gettext 'Upgrade JumpServer')"
echo
echo "Management Commands: "
echo " config $(gettext 'Configuration Tools')"
echo " start $(gettext 'Start JumpServer')"
echo " stop $(gettext 'Stop JumpServer')"
echo " restart $(gettext 'Restart JumpServer')"
echo " status $(gettext 'Check JumpServer')"
echo " down $(gettext 'Offline JumpServer')"
echo " uninstall $(gettext 'Uninstall JumpServer')"
echo
echo "More Commands: "
echo " load_image $(gettext 'Loading docker image')"
echo " backup_db $(gettext 'Backup database')"
echo " restore_db [file] $(gettext 'Data recovery through database backup file')"
echo " raw $(gettext 'Execute the original docker compose command')"
echo " tail [service] $(gettext 'View log')"
echo
}
function service_to_docker_name() {
service=$1
if [[ "${service:0:3}" != "jms" ]]; then
service=jms_${service}
fi
echo "${service}"
}
EXE=""
function start() {
${EXE} up -d
}
function stop() {
if [[ -n "${target}" ]]; then
${EXE} stop "${target}" && ${EXE} rm -f "${target}"
return
fi
${EXE} down -v
}
function close() {
if [[ -n "${target}" ]]; then
${EXE} stop "${target}"
return
fi
services=$(get_docker_compose_services ignore_db)
for i in ${services}; do
${EXE} stop "${i}"
done
}
function pull() {
if [[ -n "${target}" ]]; then
${EXE} pull "${target}"
return
fi
${EXE} pull
}
function restart() {
stop
echo -e "\n"
start
}
function check_update() {
current_version=$(get_current_version)
latest_version=$(get_latest_version)
if [[ "${current_version}" == "${latest_version}" ]]; then
echo_green "$(gettext 'The current version is up to date'): ${latest_version}"
echo
return
fi
if [[ -n "${latest_version}" ]] && [[ ${latest_version} =~ v.* ]]; then
echo -e "\033[32m$(gettext 'The latest version is'): ${latest_version}\033[0m"
else
exit 1
fi
}
function video-worker() {
EXE=$(get_video_worker_cmd_line)
if [[ ! "${EXE}" ]]; then
return
fi
if [[ "${target}" == "start" ]]; then
${EXE} up -d
fi
if [[ "${target}" == "stop" ]]; then
${EXE} down -v
fi
if [[ "${target}" == "restart" ]]; then
${EXE} down -v
${EXE} up -d
fi
if [[ "${target}" == "status" ]]; then
${EXE} ps
fi
}
function main() {
if [[ "${OS}" == 'Darwin' ]]; then
echo
echo "$(gettext 'Unsupported Operating System Error')"
echo "$(gettext 'macOS installer please see'): https://github.com/jumpserver/Dockerfile"
exit 0
fi
if [[ "${OS}" =~ MINGW.* ]]; then
echo
echo "$(gettext 'Unsupported Operating System Error')"
echo "$(gettext 'Windows installer please see'): https://github.com/jumpserver/Dockerfile"
exit 0
fi
if [[ "${action}" == "help" || "${action}" == "h" || "${action}" == "-h" || "${action}" == "--help" ]]; then
echo ""
elif [[ "${action}" == "install" || "${action}" == "config" || "${action}" == "reconfig" ]]; then
echo ""
else
pre_check || return 3
EXE=$(get_docker_compose_cmd_line)
fi
case "${action}" in
install)
bash "${SCRIPT_DIR}/4_install_jumpserver.sh"
;;
upgrade)
bash "${SCRIPT_DIR}/7_upgrade.sh" "$target"
;;
check_update)
check_update
;;
config)
bash "${SCRIPT_DIR}/config.sh" "$target"
;;
reconfig)
${EXE} down -v
bash "${SCRIPT_DIR}/1_config_jumpserver.sh"
;;
start)
start
;;
restart)
restart
;;
stop)
stop
;;
pull)
pull
;;
close)
close
;;
status)
${EXE} ps
;;
down)
if [[ -z "${target}" ]]; then
${EXE} down -v
else
${EXE} stop "${target}" && ${EXE} rm -f "${target}"
fi
;;
uninstall)
bash "${SCRIPT_DIR}/8_uninstall.sh"
;;
backup_db)
bash "${SCRIPT_DIR}/5_db_backup.sh"
;;
restore_db)
bash "${SCRIPT_DIR}/6_db_restore.sh" "$target"
;;
load_image)
bash "${SCRIPT_DIR}/3_load_images.sh"
;;
pull_images)
pull_images
;;
cmd)
echo "${EXE}"
;;
tail)
if [[ -z "${target}" ]]; then
${EXE} logs --tail 100 -f
else
docker_name=$(service_to_docker_name "${target}")
docker logs -f "${docker_name}" --tail 100
fi
;;
show_services)
get_docker_compose_services
;;
init_db)
perform_db_migrations
;;
video-worker)
video-worker
;;
raw)
${EXE} "${args[@]:1}"
;;
version)
get_current_version
;;
help)
usage
;;
--help)
usage
;;
-h)
usage
;;
*)
echo "No such command: ${action}"
usage
;;
esac
}
main "$@"
写在最后
通过分析这个JumpServer的安装脚本,我们可以看到一个好的Shell脚本应该具备的特质:结构清晰、错误处理完善、用户体验友好、扩展性良好。
Shell编程虽然看起来简单,但要写好并不容易。需要考虑各种边界情况,处理不同的错误场景,还要保证代码的可读性和可维护性。
我觉得每个运维人员都应该认真对待Shell脚本的编写,毕竟这是我们日常工作中最常用的工具之一。一个好的脚本不仅能提高工作效率,还能减少出错的可能性。
希望这次的分析对大家有所帮助。如果你也有类似的脚本编写经验或者问题,欢迎在评论区交流讨论。
如果这篇文章对你有帮助,别忘了点赞转发支持一下!想了解更多运维实战经验和技术干货,记得关注微信公众号@运维躬行录,领取学习大礼包!!!我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习,共同进步!
公众号:运维躬行录
个人博客:躬行笔记