ソースコード・リテラシーのススメ

第21回ソフトウェア・ツールズの活用[完結編]

前回は実用的なシェルスクリプトの例として、Plamo Linux用のupdatepkgコマンドの雛形を考えてみました。今回はシェルスクリプトの重要な機能である関数(サブルーチン)を使って、このコマンドに前回宿題として残しておいたバージョンチェック機能を追加することにします。

バージョン取得用関数

オープンソースソフトウェア(OSS)の世界では、早めの公開、しばしば公開(release early, release often)を標語にバージョンアップが頻繁に行われ、バージョン番号はそれらの新旧を見分ける重要な指標です。

以前にも紹介したように、バージョン番号の付け方に統一されたルールはなく、ソフトウェアプロジェクトごとに独自のルールで運用しているのが実情ですが、最近のソフトウェアプロジェクトでは ⁠.⁠(ピリオド)を区切りにした3ケタ程度の数字で、各ケタは左からメジャー番号、マイナー番号、リリース(パッチ)番号を示す程度の合意はあります。

前回紹介したようにPlamo Linuxの場合、/var/log/packages/ディレクトリにパッケージのベース名のファイルが作成され、そこにパッケージに関する情報が記録されています。

リスト1 パッケージ情報の例
PACKAGE NAME:     bash-3.2.39-i386-P1
COMPRESSED PACKAGE SIZE:     1213 K
UNCOMPRESSED PACKAGE SIZE:     2190 K
PACKAGE LOCATION: /var/adm/mount/plamo/00_base/bash-3.2.39-i386-P1.tgz  
 ...

バージョン番号はパッケージ名の一部として PACKAGE NAME: 行に記録されているので、インストール済みのパッケージのバージョン番号はこの行から取り出すことができます。バージョン番号を取り出す作業は他の処理とは独立しているので、関数として作成しておけば便利でしょう。

シェルスクリプトでは、関数は関数名の後ろに( )(丸カッコ)を付けて定義し、処理は { } ⁠波カッコ)で囲んだ部分に記述します。この関数をget_version()という名前にしてみましょう。

リスト2 バージョン番号取得スクリプト
  1  get_version() {
  2    local old_name=`cat /var/log/packages/$1 | grep "^PACKAGE NAME" | cut -f2 -d':' `  
  3    local old_vers=`echo $old_name | cut -f2 -d'-'`
  4    echo  $old_vers
  5  }

2行目と3行目にあるlocalという宣言は、指定した変数をこの関数のみで参照できるローカル変数に定義して、外部での変数名の衝突を防いでいます。

2行目にある$1は関数に与えた引数の指定で、呼び出し側からget_version bashのように関数を呼び出すと、$1は1つ目の引数であるbashに置換されcat /var/log/package/bashが実行されます。

パイプの後半ではgrepコマンドで⁠PACKAGE NAME⁠という行を取り出して、次のcutで ⁠:⁠(コロン)を区切りに2つの部分に分け、パッケージ名を含む2つめの部分を取り出します。この処理の結果を$old_nameという変数に代入しておき、3行目では、この変数からcutで⁠-⁠(ハイフン)を区切りにパッケージ名を分割し、バージョン部分である2つめの部分を $old_vers に代入し、4行目で $old_vers をechoで呼び出し元に返すようにしています。

シェルスクリプトでは、関数を実行した結果の戻り値は return で指定するか、最後に実行したコマンドの結果として返すことができます。ただし、returnで返せる値は数字に限られており、バージョン番号のような文字列を返すことはできませんので、ここではechoを実行した結果として$old_versを返すようにしました。

バージョンチェック用関数

次に、インストール済みのパッケージと新しくインストールしようとしているパッケージのバージョンを比較する処理を作成しましょう。この処理は2つのバージョン番号を受け取って比較する関数にしてみます。

リスト3 バージョンの比較スクリプト
  1  check_vers() {
  2    local old=$1
  3    local new=$2
  4
  5    for i in 1 2 3 4 5; do
  6      t1=`echo $old | cut -f$i -d'.'`  
  7      t2=`echo $new | cut -f$i -d'.'`
  8      if [ $t2 -gt $t1 ]; then
  9        return $t2
 10      fi
 11    done
 12    return 0
 13  }

この関数では5行目から11行目のループの部分で、x.y.zと表記されるバージョン番号に対し、cutコマンドを使って⁠.⁠を区切り文字-d'.'にし、左から順に各欄を取り出して-f$i新旧の比較をしています。

8行目はシェルのtest 機能([ .. ])を用いて、2番目の引数に指定したバージョン名の各欄の値$t2が、最初の引数の各欄の値$t1よりも大きいかどうか-gtを調べています。

x.y.zと表記されるバージョン番号のうち、どこか1つでも$t2の方が$t1よりも大きければ、2番目の引数で指定した$newの方が$oldよりも新しいと判断できるので、ループ処理を打ち切って、その大きかった数字を9行目のreturn $t2で返しています。

一方、バージョン番号の各欄を全て調べても最初の引数($old)の方が大きかった場合は、12行目で0を返すようにしました。このようにしておけば、呼び出し側で戻り値を調べて0以外の数字が帰ってくれば、2番目の引数に与えたバージョンの方が新しく、0が戻れば最初の引数のバージョンの方が新しいと判断できるでしょう。

なお、先にバージョン番号は「3ケタ程度の数字」と述べましたが、Linuxカーネル(2.6.27.10)やFirefox(2.0.0.18)のように4ケタを使っているソフトウェアも存在するため、念のため5ケタ分をチェックするようにしています。

改訂版スクリプト

それでは、今回作った関数を前回のスクリプトに組み込んでみましょう。

リスト4 バージョンチェックスクリプト
  1  #!/bin/sh
  2  
  3  get_version() {
  4    local old_name=`cat /var/log/packages/$1 | grep "^PACKAGE NAME" | cut -f2 -d':' `  
  5    local old_vers=`echo $old_name | cut -f2 -d'-'`
  6    echo  $old_vers
  7  }
  8  
  9  check_vers() {
 10    local old=$1
 11    local new=$2
 12  
 13    for i in 1 2 3 4 5; do
 14      t1=`echo $old | cut -f$i -d'.'`
 15      t2=`echo $new | cut -f$i -d'.'`
 16      if [ $t2 -gt $t1 ]; then
 17        return $t2
 18      fi
 19    done
 20    return 0
 21  }
 22    
  23  if [ $#  = 0 ]; then 
 24      echo "usage: $0 package(s)" 
 25      exit
 26  fi
 27  for tmppkg in $* ; do
 28    pkg=`basename $tmppkg`    
 29    base=`echo $pkg | cut -f1 -d'-'`
 30    chk=`ls /var/log/packages | grep "^$base$" `
 31    if [ "$chk.x" != ".x" ]; then
 32      vers=`echo $pkg | cut -f2 -d'-'`
 33      old_vers=`get_version $base`
 34      check_vers $old_vers $vers
 35      ver_test=$?
 36      if [ $ver_test != "0" ]; then
 37        echo "removepkg $base"
 38      else
 39        echo "same or newer vesion($chk-$old_vers) has been installed."
 40        echo "stop installation for $tmppkg"    
 41        continue
 42      fi
 43    fi
 44    echo "installpkg $tmppkg"
 45  done

3行目から21行目が今回作成した関数です。シェルスクリプトの関数は実行される前に定義しておかないといけないので、スクリプトの先頭部分に置いています。

33行目と34、35行目が今回作成した関数を呼び出している部分です。単純に実行するだけならば関数名に引数を付けて get_version $base などととすればいいのですが、33行目では関数を実行した結果を $old_vers という変数に代入したいので、関数を呼び出す部分を `(バッククォート)で括って、実行結果を $old_vers に代入するようにしています。

一方、34,35行目は関数が return で返す値を受けとる例です。34行目では check_vers という関数に$old_vers, $vers という引数を与えて実行します。関数からreturnで返される値は$?というシェルの特殊変数に収められるので、35行目で$?の値を$ver_testという変数に代入しています。

36行目からは$ver_testの値を調べて、0でなければ新しくインストールしようとしているパッケージの方が新しいので古いバージョンを削除します(38行⁠⁠。一方、$ver_testが0ならば、すでにインストールされているパッケージの方が新しくインストールしようとしているパッケージよりも新しいので、エラーメッセージを出してインストールを中止します(39、40行⁠⁠。

オプション解析機能

いくつかのパッケージで動作テストしてみると、たいていは上記のコードでうまく行くのですが、openssl-0.9.8i等、バージョン番号に数字以外のアルファベット等が付いている場合、バージョンチェックが正しく動作しないようです。

原因は-gtに代表されるシェルスクリプトの比較演算子が整数の大小を見分ける機能しか持たないことで、アルファベット等の文字列を比較しようとするとエラーになってしまいます。また、比較部分を文字列の比較ができるsortを使っても書き直したとしても、1.2rc1と1.2のどちらが新しいバージョンかを見分けることはできないでしょう。

バージョン番号に数字以外が含まれた場合の処理を考えるのは面白いテーマになりそうですが、条件が多様すぎて簡単には扱えそうにありません。そこで今回はバージョンチェックを無視して強制的にインストールするオプションを追加する機能を入れておきましょう。

リスト5 オプション引数の処理
  1  for opt in  $*
  2  do
  3    case $opt in
  4    -f)
  5      force_flag=1 ; shift ;;
  6    -h)
  7      usage ;;
  8    esac
  9  done

この処理は、オプションとして-fと-hを受け付け、-fならば強制インストール用のフラグ($force_flag)を立て、-hならばusageという関数を実行する、というものです。

1行目の $* は引数全てを意味し、すべての引数を1つずつ$optに格納して、$optが-fならば 5行目の処理を、-hならば7行目の処理を行います。5行目でフラグを立てた後のshiftは引数の並びを一つ左にシフトして使用済みの引数(-f)を除くための処理です。

完成版スクリプト

オプション解析機能を追加し、ヘルプメッセージの表示機能も関数化した完成版のコードはこうなりました。

リスト6 完成版のインストールスクリプト
  1  #!/bin/sh
  2  
  3  get_version() {
  4    local old_name=`cat /var/log/packages/$1 | grep "^PACKAGE NAME" | cut -f2 -d':' `
  5    local old_vers=`echo $old_name | cut -f2 -d'-'`
  6    echo  $old_vers
  7  }
  8  
  9  check_vers() {
 10    local old=$1
 11    local new=$2
 12  
 13    for i in 1 2 3 4 5; do
 14      t1=`echo $old | cut -f$i -d'.'`
 15      t2=`echo $new | cut -f$i -d'.'`
 16      if [ $t2 -gt $t1 ]; then
 17        return $t2
 18      fi
 19    done
 20    return 0
 21  }
 22  
 23  usage() {
 24    echo "usage: $0 [-f] [-h] package(s)"
 25    echo "    -f force install mode(without version check)"
 26    echo "    -h help(this message)"
 27    exit
 28  }
 29  
 30  for opt in  $* 
 31  do
 32    case $opt in
 33    -f)
 34      force_flag=1 ; shift ;;
 35    -h)
 36      usage ;;
 37    esac
 38  done
 39  
 40  if [ $# = 0 ]; then
 41    usage
 42  fi
 43  
 44  for tmppkg in $* ; do
 45    pkg=`basename $tmppkg`
 46    base=`echo $pkg | cut -f1 -d'-'`
 47    vers=`echo $pkg | cut -f2 -d'-'`
 48    chk=`ls /var/log/packages | grep "^$base$" `
 49    if [ "$chk.x" != ".x" ]; then
 50      if [ "$force_flag.x" != ".x" ]; then
 51        /sbin/removepkg $base
 52      else
 53        old_vers=`get_version $base`
 54        check_vers $old_vers $vers
 55        ver_test=$?
 56        if [ $ver_test != "0" ]; then
 57          /sbin/removepkg $base
 58        else
 59          echo "same or newer vesion($chk-$old_vers) has been installed."
 60          echo "installation stopped for $tmppkg"  
 61          continue
 62        fi
 63      fi
 64    fi
 65    /sbin/installpkg $tmppkg
 66  done

23から28行目が、以前はスクリプトの先頭部分に置いていたメッセージ表示機能を関数化したusageです。この関数は呼びだされると指定したメッセージを表示してスクリプトを終了(exit)させます。

30から38行目がオプションの解析部分で、オプションはパッケージ名よりも先に処理する必要があるためこの部分に置いています。

40から42行目は-fオプションを除いても引数(パッケージ名)が残っているかをチェックし、残っていなければコマンドの使い方が間違っているので、usageを呼び出してメッセージを出力、終了します。

パッケージをインストールする44行目からのループでは、50行目に$force_flagをチェックするif文を追加して、$force_flagに値が入っていれば、53行目からのバージョンチェックを行わず、古いパッケージをアンインストールするようにしました。

数字以外が含まれた際のバージョンチェックやパッケージごとの強制インストールの有無など、もう少しイジりたい気もしますが、あまり複雑にすると見直した時に自分でもわからなくなりがちなので(苦笑⁠⁠、今回のupdatepkgコマンドはこれくらいで完成ということにしておきましょう。

今回はシェルスクリプトを作っていく過程を順を追って紹介しました。たいていのソースコードは、今回の例のように、中心となる機能を作った上で付加的な機能やさまざまなチェック機能を追加しながら成長していきます。そのようなソースコードを読む際には、頭から丁寧に読んでいくよりも、まず全体をざっと流し読みして中心となる部分を見つけた上で、そこからどのような処理が呼び出されていくのかを追ってゆく方がわかりやすくなります。

本連載で紹介してきた文書の読み方同様、ソースコードもメリハリを付けて読むのがコツと言えるでしょう。

おすすめ記事

記事・ニュース一覧