分类目录归档:shell

shell前边的连字符含义

在一个脚本里,要获取其父shell时,使用了下面的方式:

#!/bin/bash 
ps -ocomm= -p $(ps -oppid= $$)

它在某些环境下,父shell会显示为 “/usr/local/bin/zsh” 或者 “bash” ,而某些环境下却会显示为”-bash”或”-zsh”;这个开头多出来的连字符是怎么回事?查了一下,原来是表示的是”login shell”。

linux下可以在”/etc/passwd”里看到用户的login shell,而在mac下要确认当前用户的login shell,要通过下面的命令:

$ dscl . read /users/$USER UserShell
UserShell: /bin/bash

在mac下,当打开终端程序(Terminal.app)时,终端shell是login进程的子进程(不管你配置那种command):

$ pstree
 ...
 |-+= 01272 hongjiang /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
 | \-+= 01275 root login -pf hongjiang
 |   \-+= 01276 hongjiang -bash
 ...

$ ps -ef | grep login
0  1275  1272   0  2:05PM ttys000    0:00.05 login -pf hongjiang 

# 把Terminal的启动命令修改为zsh也一样

 |-+= 01988 hongjiang /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
     | \-+= 01991 root login -pf hongjiang /bin/zsh  |   \-+= 01992 hongjiang -zsh

而在mac的 iTerm.app 下,不管如果你配置的command是”Login shell”还是修改为其他shell,启动后shell没有再挂在login进程下:

$ pstree
 |-+= 00574 hongjiang /Volumes/Data/program/iTerm.app/Contents/MacOS/iTerm
 | \-+= 02177 hongjiang /usr/local/bin/zsh  

但在iTerm下如果使用的是“Login shell”显示的名称前边却是有连字符的,而其他shell则没有

$ ps $$
  PID   TT  STAT      TIME COMMAND
 2220 s001  Ss     0:00.01 -/bin/bash

# 修改启动shell为zsh

➜  ps $$
  PID   TT  STAT      TIME COMMAND
 2524 s000  Ss     0:00.16 /usr/local/bin/zsh

要想在iTerm下保持跟Terminal一致也用login来启动,应该在配置里修改启动命令为:

login -pf $username /usr/local/bin/zsh   

在linux下,从tty登录shell也是一样由login进程启动的

$ ps -ef | grep bash
 hongjia+  2241   467  0 14:09 tty1     00:00:00 -bash

$ pstree 
systemd─┬─NetworkManager─┬─2*[dhclient]
    │                └─3*[{NetworkManager}]
    ├─...
    ├─login───bash
    ...   

$ ps -ef | grep login
root       467  0.0  0.2  84584  2328 ?        Ss   14:08   0:00 login -- hongjiang    

使用su命令切换到一个用户shell下,默认情况这个shell并不是”login shell”不会去执行/etc/profile和home目录下相关配置:

$ sudo su hongjiang

$ ps -ef | grep $$
hongjia+  2796  2795  0 14:57 pts/0    00:00:00 bash

要以”login shell”方式启动,需要对su指定一个参数“-“

$ sudo su - hongjiang

$ ps -ef | grep $$
hongjia+  3188  3187  0 15:35 pts/0    00:00:00 -bash

从bash文档里可以看到,要以”login shell”方式启动一个shell,要么第一个参数给一个特定的连字符“-”,要么显式的对bash设定”–login”参数。

maven wrapper script

这个脚本用于简化mvn的操作,在mac/bash, linux/bash, cygwin/bash下测试过

#!/bin/bash - 

TOP_PID=$$
PROG_NAME=$0
ACTION=$1

function get_children() {
  ps -ef | grep $$ | grep -v grep | awk '$3=='"$$"'{print $2}' | xargs
}

trap "kill $(get_children) >/dev/null 2>&1; exit 1" SIGINT SIGTERM

function usage() {
  echo "Usage: $PROG_NAME {verify|compile|run|debug|package|eclipse|get-src|dep-tree}"
  exit 1;
}

function get_mvn_cmd() {
  if [[ "$OSTYPE" == *cygwin* ]];then
    ppid=$( ps -ef -p $$ | awk 'NR==2{print $3}' )
    user_shell=$( ps -p $ppid | awk 'NR==2{print $8}' )
    #has some trouble with cygwin, while Ctrl-c cannot terminal
    set -m
  else
    ppid=$( ps -oppid= $$ )
    user_shell=$( ps -ocomm= -p $ppid )
  fi

  mvn=$( $user_shell -ic "alias mvn" 2>/dev/null | cut -d'=' -f2 | sed "s/'//g" )

  if [ -z "$mvn" ];then
    $user_shell -ic "which mvn" >/dev/null
    if [ $? -eq 0 ];then
      mvn=$( $user_shell -ic "which mvn" | head -1 )
    fi
  fi

  if [ -z "$mvn" ]; then 
    echo "mvn command not found" >&2
    kill -s TERM $TOP_PID
  else
    echo $mvn
  fi
}

MVN=$( get_mvn_cmd )

function mvn_verify() {
  $MVN verify $@
}

function mvn_compile() {
  $MVN clean compile $@
}

function mvn_run() {
  $MVN scala:run $@
}

function mvn_debug() {
  $MVN scala:run -Dlauncher=debug $@
}

function classpath_check() {
  if [ $# -eq 0 ];then
    echo "please enter classpath dir"
    exit 1
  fi

  if [ ! -d "$1" ]; then 
    echo "not a directory"
    exit 2
  fi

  tmpfile="/tmp/.cp$(date +%s)"
  tmphash="/tmp/.hash$(date +%s)"
  verbose="/tmp/cp-verbose.log"

  declare -a files=(`find "$1" -name "*.jar"`)
  for ((i=0; i < ${#files[@]}; i++)); do
    jarName=`basename ${files[$i]}`
    list=`unzip -l ${files[$i]} | awk -v fn=$jarName '/\.class$/{print $NF,fn}'`
    size=`echo "$list" | wc -l`
    echo $jarName $size >> $tmphash
    echo "$list" 
  done | sort | awk 'NF{
      a[$1]++;m[$1]=m[$1]","$2}END{for(i in a) if(a[i] > 1) print i,substr(m[i],2)
  }' > $tmpfile

  awk '{print $2}' $tmpfile | 
  awk -F',' '{i=1;for(;i<=NF;i++) for(j=i+1;j<=NF;j++) print $i,$j}' | 
  sort | uniq -c | sort -nrk1 | while read line; do
    dup=${line%% *} 
    jars=${line#* }
    jar1=${jars% *}
    jar2=${jars#* }
    len_jar1=`grep -F "$jar1" $tmphash | grep ^"$jar1" | awk '{print $2}'`
    len_jar2=`grep -F "$jar2" $tmphash | grep ^"$jar2" | awk '{print $2}'`
    len=$(($len_jar1 > $len_jar2 ? $len_jar1 : $len_jar2))
    per=$(echo "scale=2; $dup/$len" | bc -l)
    echo ${per/./} $dup $jar1 $jar2 
  done | sort -nr -k1 -k2 | 
  awk 'NR==1{print "Similarity DuplicateClasses File1 File2"}{print "%"$0}'| column -t  

  sort $tmpfile | awk '{print $1,"\n\t\t",$2}' > $verbose
  echo "See $verbose for more details."

  rm -f $tmpfile
  rm -f $tmphash
}

function mvn_package() {
  $MVN clean package -Dmaven.javadoc.skip -Dmaven.test.skip $@
  if [ $? -eq 0 ];then
    echo ""
    classpath_check ./target/lib
  fi
}

function mvn_src() {
  $MVN dependency:sources
}

function mvn_dep_tree() {
  $MVN dependency:tree -Dverbose
}

function mvn_eclipse() {
  $MVN eclipse:eclipse
}

case "$ACTION" in
  verify)
    shift && mvn_verify $@
  ;;
  compile)
    shift && mvn_compile $@
  ;;
  run)
    shift && mvn_run $@
  ;;
  debug)
    shift && mvn_debug $@
  ;;
  package)
    shift && mvn_package $@
  ;;
  get-src)
    mvn_src
  ;;
  dep-tree)
    mvn_dep_tree
  ;;
  eclipse)
    mvn_eclipse
  ;;
  *)
    usage
  ;;
esac

tmux下显示ssh目标host的问题

当在tmux下ssh到远程机器时,希望显示远程host,google后找到了一段对ssh封装过的函数:

ssh() {
    if [ "$(ps -p $(ps -p $$ -o ppid=) -o comm=)" = "tmux" ]; then
        tmux rename-window "$*"
        command ssh "$@"
        tmux set-window-option automatic-rename "on" 1>/dev/null
    else
        command ssh "$@"
    fi
}

这段函数在我的mac上运行效果正常,如下图

但当我在一台linux上运行时,ctrl-d退出远程ssh时,会一直卡在退出状态,不管ctrl-c或什么操作也无法回到原bash,如下图

此时只通过tmux的kill-window的操作方式来关掉这个窗口,而在mac下是没有这个问题的,分析了一下,发现在我的mac上,tmux被oh-my-zsh给alias成了一个_zsh_tmux_plugin_run函数:

➜  which ssh
ssh () {
    if [ "$(ps -p $(ps -p $$ -o ppid=) -o comm=)" = "tmux" ]
    then
        _zsh_tmux_plugin_run rename-window "$*"
        command ssh "$@"
        _zsh_tmux_plugin_run set-window-option automatic-rename "on" 1>/dev/null
    else
        command ssh "$@"
    fi
}

_zsh_tmux_plugin_run又做了一些环境变量的判断和处理,猜测可能是因为这些环境变量的差异所导致,先去掉tmux set-window-option automatic-rename "on" 这句最后对标准输出的重定向,看看输出什么信息,结果在修改之后却可以正常了:

还不太清楚为何这个重定向导致了ssh退出后没法回到原shell下,先记录下这个现象。

bash的一般性诊断

如果脚本执行的时候报“找不到文件或目录”,但有没有提示任何文件或名录名称,比如:

$ ./service.sh
: No such file or directory

这种情况下,很可能是脚本里的特殊字符引起的,可以通过开启bash的debug选项来查看,为了输出行号可以设置PS4环境变量:

$ export PS4='+{$LINENO:${FUNCNAME[0]}} ' && bash -x service.sh
set: usage: set [--abefhkmnptuvxBCHP] [-o option-name] [arg ...]
+{3:} $'\r'
: command not found
+{23:} $'\r'
: command not found:
...

可以看到提示在底3行和23行出现了windows下的换行符\r导致脚本没能正确识别。通过cat -A可以看到行尾的windows换行符^M$

解决方法:

$ sed -i 's/\r//g' service.sh

bash的几个细节

1) 使用 /bin/bash 还是 /usr/bin/env bash

bash脚本的开头,通常声明为#!/bin/bash,但一些严谨一些(为了更好的跨平台)的脚本里,声明为#!/usr/bin/env bash

并不是所有的操作系统上bash都是/bin路径下的,有些unix(比如bsd系列),bash不是它们的默认shell,安装路径可能是在/usr/local/bin/bash,所以通过env方式在path下选择bash兼容性更好一些。不过,如果确定脚本只是为linux和mac使用,也无所谓。

2) 标准输入使用”-” ,还是”/dev/fd/0″

相对于使用”-“表示标准输入,”/dev/fd/0″的一个好处是,在脚本里更明确一些,因为有时很难区分”-“到底代表标准输入,还是命令的参数。

/dev/fd/0  标准输入 
/dev/fd/1  标准输出 
/dev/fd/2  标准错误 

3) 获取当前目录

有好几种方式可以获取当前运行的脚本所在的目录,最严谨的一种方式是:

CUR_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd -P )"

4) set -e 要不要设置

有些情况下,我们需要判断上一条命令是否执行成功,比如kill -0 pid检查某个进程是否存在,这个时候如果设置了set -e,导致进程不存在的kill -0执行失败脚本退出。如果进程不存在的也有一个分支逻辑的话,那个分支就不可能执行到了,这种情况下显然不能使用set -e

5) exit与fork

exit通常是当某个执行失败或条件不符合时希望真个脚本退出时使用的,但需要注意的一点是,当使用”$()”执行一个函数时,是fork方式,这个函数里定义的exit是让fork的那个进程退出,而不是当前脚本进程,需要当前脚本退出的话,一个变通的方式是定义个类似exit的方法给最顶层的进程发信号:

TOP_PID=$$

trap "exit 1" TERM

quit() {
  local msg=$1
  [ ! -z "$msg" ] && echo $msg >&2
  kill -s TERM $TOP_PID
}

foo() {
    command || quit 
}

result=$(foo)

bash的陷阱(2): 函数返回值问题

一个脚本里遇到的,这个函数的写法是:

inner_stop(){
  local pid=$( get_service_pid )
  [ ! -z "$pid" ] && kill $pid
}

在调用完这个函数的地方需要检查一下它的执行结果,通过 $? 来判断,结果当前一句pid为空时,inner_stop 总会返回1。本来的意图是,判断当pid存在才执行kill,否则pid为空直接返回成功,而不是返回错误。这里忘了bash函数的返回值是上最后一条语句的执行结果 [ xxx ] 测试语句不满足时返回1

在返回值这点上要注意,不同于其他语言,bash属于弱类型语言,并且返回值总是最后一个命令的执行结果,if condition; then doSomething; fi 语句即可以返回if condition 的执行结果,也可以返回doSomething的结果。(注意这里说的结果是指return value,不是标准输出的内容)

#!/bin/bash

foo() {
  [ ! -z "$x" ] && echo "x not empty"
}

foo || echo "foo function return $?"

上面脚本执行时,如果没有全局声明过x变量,foo函数的返回总是1(错误),需要对另一个分支显式的return 0才行。

bash的陷阱(1): 逻辑或碰到local修饰符的情况

bash的函数如果有返回值,并要赋值给一个变量,跟普通的命令调用没有差异,都是: v=$(func) 的方式。需要注意的是,这里执行了fork操作,如果函数里有调用exit的话,只是让子进程退出,当前进程并不会退出。如果想要让当前进程也退出,可以使用set -e或在函数调用结束的时候再判断一次,比如:

v=$(func) || exit $?

如果函数调用失败,当前进程也退出。

今天遇到一个跟这个相关的陷阱,但并不是fork的问题,而是bash语法解析的问题,先看看我当时的写法,脚本简化后:

$ cat a.sh
#!/bin/bash

foo() {
    echo "error" && exit 1
}

bar() {
    local r=$( foo ) || exit $?
    echo $r # 执行了这里
}

bar

如上,函数bar以fork的方式调用foo,我们预期是foo返回值不为0时,bar函数也退出。所以上面bar函数里最后的echo $r不应该被执行到。但实际运行的时候发现这段代码确实可以执行到bar函数的最后一行。

后来,去掉了local修饰符,执行符合预期了:

bar() {
    r=$( foo ) || exit $?
    echo $r # 不会执行到这里
}

看来是 r=$( foo ) || exit $? 里的逻辑或||被解析器作为等号后边两个命令的逻辑运算;而前边加了local修饰符之后,逻辑或||被当成表达式local r=$(foo) 与 命令 exit 之间的逻辑运算。

vpn脚本的改进

感谢巨石的验证和建议,之前小看了云梯vpn的规模,以为他们只有那么几台服务器,所以在脚本里把域名写死了;后来发现不同用户的服务域名还是不一样的,改进了一下脚本,不局限于云梯的vpn,适用所有的vpn;代码在这里

安装方式:

$ mkdir -p ~/bin
$ curl -s http://hongjiang.info/vpn.sh > ~/bin/vpn

选项:

$ vpn
Usage: /Users/hongjiang/bin/vpn {list|ping|optimal|conn|close|status}

自动连接:

$ vpn conn
ok, jp3.vpnplease.com connected.

可以指定连接协议:

$ vpn conn L2TP #或PPTP
ok, jp2.vpnplease.com connected.

断开连接:

$ vpn close
ok, jp3.vpnplease.com disconnected.

查看vpn列表(名称:地址):

$ vpn list
VPN (PPTP): jp2.vpnplease.com
jp3.vpnplease.com: jp3.vpnplease.com
us1.vpnplease.com: us1.vpnplease.com
...

查看所有vpn的状态:

$ vpn status

* (Disconnected)   2599A4CB-7D76-4878-8FAF-7496E5F6C58F PPP --> L2TP       "us2.vpnplease.com"              [PPP:L2TP]
* (Disconnected)   57A3276C-505F-4E13-B402-0CB6EC5C9AD7 PPP --> PPTP       "VPN (PPTP)"                     [PPP:PPTP]
* (Connected)      90784ECA-DCFA-4197-B2E5-FBE84AFB9C00 PPP --> L2TP       "jp3.vpnplease.com"              [PPP:L2TP]
...     

获取最优的服务名称(协议参数可选):

$ vpn optimal pptp  #或 l2tp
VPN (PPTP)

测试连接速度:

$ vpn ping
jp2.vpnplease.com  52.190
jp1.vpnplease.com  53.681
sg2.vpnplease.com  171.482
...

选择网速最快的vpn进行连接的脚本

下午github上下载速度只有几K,用了代理也不到10K,后来发现所用的vpn服务器选择了一个慢的。写了这段脚本,用来从可用的vpn选出最快的那个。脚本只适用于mac,并且前提是你已创建好了所有可选的vpn,如图:

脚本内容:

#!/bin/bash

PROG_NAME=$0
ACTION=$1

DOMAIN="vpnplease.com"
declare -a OPTION=("jp" "us" "sg")

usage() {
  echo "Usage: $PROG_NAME {test|conn|close|status}"
  exit 1
}

vpn_test() {

  for c in "${OPTION[@]}";do
    for i in `seq 1 3`; do
      proxy="$c$i.$DOMAIN"
      ping -c3 $proxy 2>/dev/null | tail -1 | cut -d'=' -f2 | cut -d'/' -f2 | xargs -I {} echo "$proxy " {} 2>/dev/null &
    done
  done

  children=$(jobs -p | xargs)
  trap 'kill $children >/dev/null 2>&1' SIGINT SIGTERM
  wait
}

get_fastest() {
  vpn_test 2>/dev/null | sort -nk2 | head -1 | awk '{print $1}'
}

vpn_status() {
  for c in "${OPTION[@]}";do
    for i in `seq 1 3`; do
      proxy="$c$i.$DOMAIN"
      scutil --nc status $proxy 2>/dev/null | head -1 | xargs -I {} echo "$proxy " {}
    done
  done
}

vpn_connect() {
  local name=$1

/usr/bin/env osascript <<-EOF
  tell application "System Events"
    tell current location of network preferences
      set VPN to service "$name" -- your VPN name here
      if exists VPN then connect VPN
      repeat while (current configuration of VPN is not connected)
        delay 1
      end repeat
    end tell
  end tell
EOF
}

vpn_disconnect() {
  local name=$(vpn_status | awk '$2~/Connected/{print $1}')
  [ -z $name ] && return 0

/usr/bin/env osascript <<-EOF
  tell application "System Events"
    tell current location of network preferences
      set VPN to service "$name" -- your VPN name here
      if exists VPN then disconnect VPN
    end tell
  end tell
  return
EOF
}

case "$ACTION" in
    test)
        vpn_test
    ;;
    conn)
        vpn_connect $(get_fastest)
    ;;
    close)
        vpn_disconnect
    ;;
    status)
        vpn_status
    ;;
    *)
        usage
    ;;
esac

使用方式:

1) 自动选择最优的vpn进行连接

$ vpn conn

2) 查看连接状态

$ vpn status

jp1.vpnplease.com  Connected
jp2.vpnplease.com  Disconnected
jp3.vpnplease.com  Disconnected
us1.vpnplease.com  Disconnected
us2.vpnplease.com  Disconnected
us3.vpnplease.com  Disconnected
sg1.vpnplease.com  Disconnected
sg2.vpnplease.com  Disconnected

3) 测试网速,时间最小的最优

$ vpn test

jp2.vpnplease.com  76.314
jp1.vpnplease.com  75.793
jp3.vpnplease.com  136.161
sg1.vpnplease.com  169.494
us3.vpnplease.com  242.685
us2.vpnplease.com  249.032
us1.vpnplease.com  266.427
sg2.vpnplease.com  168.750

4) 断开连接

$ vpn close 

用strings命令查看kafka-log内容

kafka的log内容格式还不没怎么了解,想快速浏览消息内容的话,除了使用它自带的kafka-console-consumer.sh脚本,还可以直接去看log文件本身,不过内容里有部分二进制字符,通过命令看的话会有乱码。

strings 命令可以过滤掉二进制编码,但默认它也会过滤掉中文字符,只留有英文字符。要用它的-e S参数可以同时过滤出中文或英文字符,但仍会包含了小部分的二进制编码,可以在通过iconv去掉一下,能大致看到消息内容:

$ cat log-strings.sh
#!/bin/bash

PROG_NAME=$0
LOG_FILE=$1

if [ -z "$LOG_FILE" ];then
  echo "Usage: $PROG_NAME logfile"
  exit 1
fi

strings -e S "$LOG_FILE" | iconv -c -f "UTF-8" -t "UTF-8"