逐行读取文本文件的 shell 脚本

网上有很多 shell script 读文本文件的例子,但是都没有讲出故事的全部,只说了一半。举个例子,比如从一个 testfile 文件中读取如下格式的文本行:

$ vi testfile
ls      -a -l /bin |  sort
ls      -a -l /bin |  sort | wc
ls      -a -l |  grep sh | wc
ls      -a -l
ls      -a -l |       sort      |    wc

最常见的一个 line by line 读取文件内容的例子就是:

$ vi readfile
#!/bin/sh

testfile=$1
while read -r line
do
    echo $line
done < $testfile

$ chmod +x readfile
$ ./readfile testfile
ls -a -l /bin | sort
ls -a -l /bin | sort | wc
ls -a -l | grep sh | wc
ls -a -l
ls -a -l | sort | wc

这个例子的问题是读取文本行后,文本格式发生了变化,和原来 testfile 文件的内容不完全一致,空格字符自动被删除了一些。为什么会这样呢?因为 IFS,如果在 shell script 里没有明确指定 IFS 的话,IFS 会默认用来分割空格、制表、换行等,所以上面文本行里多余的空格和换行都被自动缩进了。

如果想要输出 testfile 文件原有的格式,把每行(作为整体)原封不动的打印出来怎么办?这时需要指定 IFS 变量,告诉 shell 以 "行" 为单位读取。

$ vi readfile
#!/bin/sh

IFS="
"

testfile=$1
while read -r line
do
    echo $line
done < $testfile

$ ./readfile testfile
ls      -a -l /bin |  sort
ls      -a -l /bin |  sort | wc
ls      -a -l |  grep sh | wc
ls      -a -l
ls      -a -l |       sort      |    wc     

上面两种方法的输出不是差不多吗,有什么关系呢,第一种还美观一些?关系重大,VPSee 昨天写了一个模拟 shell 的 C 程序,然后又写了一个 shell script 来测试这个 C 程序,这个 script 需要从上面的 testfile 里读取完整一行传给 C 程序,如果按照上面的两种方法会得到两种不同的输入格式,意义完全不同:
$./mypipe ls -a -l | sort | wc
$./mypipe "ls -a -l | sort | wc "
显然我要的是第2种输入,把 "ls -a -l | sort | wc " 作为整体传给我的 mypipe,来测试我的 mypipe 能不能正确识别出字符串里面的各种命令。

如果不用 IFS 的话,还有一种方法可以得到上面第二种方法的效果:

#!/bin/sh

testfile=$1
x=`wc -l $testfile |awk '{print $1}'`

i=1
while [ $i -le $x ]
do
    echo "`head -$i  $testfile | tail -1`"
    i=`expr $i + 1`
done

Linux 下给图片批量加水印

一个非盈利组织的项目负责人突发奇想想给他们网站上的每张照片加上水印,说实话那些照片都是平时活动、party 的生活照片用不着用水印那么夸张,第一次听说给生活照加水印的。没办法,谁让我们和他们有合作项目呢。还好他们服务器用的是 Linux,在 Linux 下给图片批量加水印简单多了,用 imagemagick + 一个小脚本搞定。

在 CentOS 下安装:

# yum install ImageMagick

在 Ubuntu 下安装:

$ sudo apt-get install imagemagick

先用画图工具制作好一个水印图片 watermark.jpg,然后执行 composite 命令把这个 watermark.jpg 水印加到图片 vpsee.jpg 上,-dissolve 15 是指 watermark.jpg 使用15%的透明附在原图上:

$ composite -gravity northeast -dissolve 15 watermark.jpg vpsee.jpg vpsee.jpg

要事先做个 watermark.jpg 好麻烦,有没有不用 watermark.jpg 直接加水印的方法?有,不过这种方法需要 Linux 系统上已经安装 True 字体(一般来说服务器都没有安装,不建议为了一个水印安装一个硕大的字体),以下命令把 vpsee.com 字符串加到 image.jpg 图片上:

$ mogrify -font /usr/share/fonts/truetype/thai/Purisa.ttf -pointsize 15 \
-verbose -draw "fill black text 5,23 'vpsee.com' \
fill orange text 6,24 'vpsee.com' " image.jpg

可以用下面的 shell script 对某个目录的所有图片加水印,需要注意的是处理带空白字符的文件名很麻烦,所以下面的脚本先处理空白字符,把包含空白字符的文件名用 “_” 字符替代,比如:image 1.jpg 替换成 image_1.jpg:

#!/bin/bash

echo "Image watermarking Script"
echo "http://www.vpsee.com"
echo ""

if [ $# -ne 2 ]
then
    echo "usage: ./watermark watermark.jpg picture_directory"
    echo ""
        exit
fi

MARK=$1
PICDIR=$2
for each in $PICDIR/*{.jpg,.jpeg,.png,.JPG,.JPEG,PNG}
do
    mv "$each" `echo $each | tr ' ' '_'`;
    composite -gravity northeast -dissolve 15.3 $MARK $each $each 2> /dev/null
    echo "$each: done!"
done
exit 0

imagemagick 的功能很强大,把上面脚本中的 composite 一行换成下面这行就成了批量给图片改大小了:

$ convert -resize 400 old_image.jpg new_image.jpg

如果想直接把原图改小,用新图片覆盖原图片的话:

$ convert -resize 400 image.jpg image.jpg

简单调试 Bash 脚本

用 Bash 写的脚本也可以进行调试,和 Python,Perl 等解释型语言一样。新建一个名为 servinfo 的脚本并增加可执行权限:

$ vi servinfo

#!/bin/bash

echo "Hostname: $(hostname)"
echo "Date: $(date)"
echo "Kernel: $(uname -mrs)"

$ chmod +x servinfo

用 bash -x 来调试上述脚本,Bash 先打印出每行脚本,再打印出每行脚本的执行结果:

$ bash -x servinfo
++ hostname
+ echo 'Hostname: vpsee'
Hostname: vpsee
++ date
+ echo 'Date: Thu Sep  3 19:33:48 SAST 2009'
Date: Thu Sep  3 19:33:48 SAST 2009
++ uname -mrs
+ echo 'Kernel: Linux 2.6.18-128.4.1.el5 i686'
Kernel: Linux 2.6.18-128.4.1.el5 i686

如果想同时打印行号的话,可以在脚本开头加上:

export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '

执行结果为:

$ bash -x servinfo
+ export 'PS4=+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
+ PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
++4:5:: hostname
+4:5:: echo 'Hostname: vpsee'
Hostname: vpsee
++4:6:: date
+4:6:: echo 'Date: Thu Sep  3 19:42:06 SAST 2009'
Date: Thu Sep  3 19:42:06 SAST 2009
++4:7:: uname -mrs
+4:7:: echo 'Kernel: Linux 2.6.18-128.4.1.el5 i686'
Kernel: Linux 2.6.18-128.4.1.el5 i686

如果只想调试其中几行脚本的话可以用 set -x 和 set +x 把要调试的部分包含进来:

#!/bin/bash

echo "Hostname: $(hostname)"
set -x
echo "Date: $(date)"
set +x
echo "Kernel: $(uname -mrs)"

这个时候可以直接运行脚本,不需要执行 bash -x 了:

$ ./servinfo
Hostname: vpsee
++ date
+ echo 'Date: Thu Sep  3 19:46:53 SAST 2009'
Date: Thu Sep  3 19:46:53 SAST 2009
+ set +x
Kernel: Linux 2.6.18-128.4.1.el5 i686

如果要调试一个非常复杂的 Bash 脚本的话,建议用专门的调试工具,比如:bashdb

用 Shell 脚本访问 MySQL 数据库

下午写了一个简单的 bash 脚本,用来测试程序,输入一个测试用例文件,输出没有通过测试的用例和结果,然后把结果保存到数据库里。如何在 bash 脚本里直接访问数据库呢?既然在 shell 里可以直接用 mysql 命令操作数据库,那么在 shell script 里也应该可以通过调用 mysql 来操作数据库。比如用下面的 bash shell 脚本查询数据库:

Bash

#!/bin/bash

mysql -uvpsee -ppassword test < < EOFMYSQL
select * from test_mark;
EOFMYSQL

如果需要复杂的数据库操作的话不建议用 shell 脚本,用 Perl/Python/PHP 操作数据库很方便,分别通过 Perl DBI/Python MySQLdb/PHP MySQL Module 接口来操作数据库。这里再给出这三种不同语言连接、查询数据库的简单例子(为了简单和减少篇幅删除一些不必要的代码):

Perl

#!/usr/bin/perl
use DBI;

$db = DBI->connect('dbi:mysql:test', 'vpsee', 'password');
$query = "select * from test_mark";
$cursor = $db->prepare($query);
$cursor->execute;
while (@row = $cursor->fetchrow_array) {
        print "@row\n";
}

Python


继续阅读 »

Kill 某个用户的所有进程

在一台100多人使用的 SUN 服务器上经常发现有人滥用资源,平时用用就算了,到了交作业的时候100多号人同时登录使用,服务器明显迟缓,特别是实验室用的是瘦客户机,没硬盘,SUN 客户端需要从服务器装载系统镜像,而且编译、运行程序都要在服务器上完成。如果发现某个用户运行很多进程,并且进程还有子进程,怎么能方便的找出全部进程并 kill 掉呢?

ps -ef | grep ^username | cut -c 10-15 | xargs kill -9

把全部进程打印出来根据用户名过滤后找出全部进程号,然后逐行 kill 掉。xargs 就是用来把 cut 后的输出逐个以空白符分割后输给 kill。注意上面的 grep ^username 不能缺 ^,^username 表示从一行开始匹配 username,比如就可以避免匹配到 sshd: username@pts/0。不过尽管加了 ^,上面的代码仍然有个小 bug,如果恰好有个进程名和用户名完全一样怎么办?可以用 ps -u 找出所有与 username 相关的进程,然后 grep -v 过滤掉 PID 只剩下进程信息,然后逐行 kill 掉,如下:

ps -u username | grep -v PID | cut -c 0-5 | xargs kill -9

不过上面的命令还有个小问题就是如果 cut 的时候不小心 cut 多了或者 cut 少了怎么办?可以用 awk 过滤一列信息出来:

ps -u username | grep -v PID | awk '{print$1}'| xargs kill -9

上面的命令也可以在 Linux 上运行,不过在 Linux 下可以用更简单的 killall,Solaris 上没有 killall:

killall -u username

一个小小的命令行反反复复改了多次,更别说上百万行的代码了,写代码太容易引入 bug 了,这就是为什么测试这么重要的原因,我觉得程序员应该用50%的时间写代码,50%的时间测代码;测试员也应该用50%的时间测代码,50%的时间写工具来自动测试代码。

Shell 的 IFS 变量

今天把一个 shell script 从 Linux 移植到 Solaris 时遇到一些小问题:

args=`tail -n 1 $file | head -1`

tail 的用法有点不一样。Solaris 下的 tail:tail -1 $file

IFS=”

javac $1
sort_program=`echo $1|sed ‘s/\.[^.]*$//’`
args=”2 1 3″
java $sort_program $args

上面的 script 编译一个 java 排序程序,然后用给定参数 2 1 3 运行,排序后输出 1 2 3。java 运行上面脚本时报错:

Exception in thread “main” java.lang.NumberFormatException: For input string: “2 1 3”

显然 java 把 “2 1 3” 字符串当作了参数,应该是 2 1 3,怎么会这样呢?echo $args 显示 args 的值的确是 2 1 3。调试了半天发现这个 shell script 开头有个 IFS,不知道什么时候加上去的,上面的那句 IFS 导致以新行切分文件时将 “2 1 3” 作为整体发给 java,而不是单独将 2 1 3 作为参数传给 java,所以去掉 IFS 语句就可以了。IFS 是个很有用的变量,默认下用来分割空格、制表、换行等,也可以用来分割指定字符,比如把 www:vpsee:com:8080 分割成 www vpsee com 8080 就可以用 IFS:

bash-3.00$ $line=www:vpsee:com:8080
bash-3.00$ $IFS=':'
bash-3.00$ $for i in $line; do  echo $i; done
www
vpsee
com
8080

上面 java 例子中的 `echo $1|sed ‘s/\.[^.]*$//’` 用来过滤掉后缀名,比如:编译 javac HelloWorld.java 需要 .java 后缀名,但是运行 java HelloWorld 就不需要带上 .class 后缀名。 下面的 shell script 得到一个文件名后打印出其不包含后缀名的文件名:

student=$1
student_title=`echo $1|sed 's/\.[^.]*$//'`
echo $student_title

丢失 root 的默认 shell

今天犯了一个愚蠢的错误,修改 /etc/passwd 文件把 root 的默认 csh shell(/bin/csh)改成 bash shell(/bin/bash),退出后 root 就 su 不进去了,提示找不到 /bin/bash。

% su root
Password:
su: /bin/bash: No such file or directory

看了提示信息才回过神来,刚在 FreeBSD上 装的 bash shell 的路径是 /usr/local/bin/bash,不是 /bin/bash,我想当然的以为 FreeBSD 会把 bash 装到 /bin/bash。

在 Linux 下遇到这种问题可以这样解决,su 时指定一个 shell 登陆:

$ su --shell=/bin/sh -

或者

$ su -s /bin/sh

在 FreeBSD,Solaris,AIX,以及达到 C2 安全标准的 Unix 上面遇到这种问题就只能重装系统了,让 VPS 管理员重新 load 一个操作系统。或者如果管理员能 physical access 你的服务器的话,应该可以用 cdrom 启动你的系统后恢复,不过太麻烦了,还要给管理员 root 密码,没有重要数据丢失的话不如重新 load 一下。看看FreeBSD 对安全性的要求:

继续阅读 »