自炊データのリーダーを作った

自炊した本類のリーダーを普段いろいろ試すのだけど、どうも機能的に歯がゆいところが多かったので、自分が欲しい機能だけのものを作ってみた。


python2系(開発は2.7 Win32)で、PILライブラリとpygameライブラリが入っている環境で動くものができた。


これで見られるデータは、下の三種類。

  • ディレクトリ下の画像(ファイル名順)
  • zip(cbz)ファイルの中の画像(ファイル名順)
  • PDFの中の画像(ファイル中の出現順)


スクリプト最終部近くのbookname変数を適当に変更してから実行する、といった感じに使う。ダイアログでファイル等を指定させたり、コマンドライン引数から見たいデータを受け取るようにすることは難しくないが、ここでは特に実装を示さない。


フルスクリーンで動かすか、ウィンドウで動かすかはスクリプトを直接変更して調整する。main関数のあたりを探せばわかるはず。


操作はキーボードからのみ。
[↓] 次の見開き
[↑] 前の見開き
[←] 見開き半分前進(主に、見開きがズレているとき用)
[→] 見開き半分後退(主に、見開きがズレているとき用)
[f] 右開きか左開きかをスイッチ(左開きモードだと、[←][→]キーの機能も逆転する)
[q] 表示品質のトグル(低品質モードだと、ページめくりが早くなる。頭出しに便利)
[ESC] 終了


Jpeg画像は、コントラストをすこし強めて表示している。自炊した画像って、ちょっと薄めなことがあるので。PILモジュールのPIL.ImageEnhance.Contrastで簡単にできる。


ページのアスペクト比は特に調整せず、見開き状態で画面いっぱいに拡大している。ちょっとくらい見た目が変わっても、縦長の本ならたいてい問題ないので、今のところはこのまま。


スクリプト内で特に難しいことはしていないが、PDFファイルから直接画像データを抜き出して表示させられるようになったのがちょっと面白いところ。


PDFファイルの中を見てみると、stream で始まって endstream で終わるまでが実際の画像データ(など)であることがわかる。たとえばJpegなら、ここを直接バイナリレベルで抜き出してやればよい。Jpegであることは、直前のオブジェクト記述部(っていうのかな)で /Image かつ /DCTDecode と示されていることで判断できる。


/Image かつ /FlateDecode だった場合は、生のビットマップデータが圧縮されているってこと。pythonなら zlib.decompressで展開できる。PILライブラリは展開後のこのデータを画像データとして読み込むことができる(PIL.Image.frombuffer)ので、利用した。なぜか白黒反転状態なので、適宜直す。ビットマップは1ビット色であると勝手に前提している。(自分が持っている自炊系PDFではみんなこれだったし。)


stream部以外は、PDFは一行ずつ読めるデータの集まりである。だから最初はファイルのreadlineで十分対応できると思ったのだけど、PDFを作ったり編集した環境によって、改行コードに"\x0D"や"\x0A"や"\x0D\x0A"が混在していて、動作結果が不安定だった。これらはlinesgen_crlfっていう関数をひとつ作って対応した。


画像データ以外は全部無視してPDFを扱っているので、ほとんどのPDFはまともに表示することができない(っていうか、たぶん落ちる)。ScanSnapなんかを使って作った、自炊系データだけがそれなりに見られる。


PDFから無劣化で画像を取り出す方法がわかったので、これはいろいろ役に立つね。二値ビットマップも、抽出してPNGファイルとかに書き出すことが簡単になった。

※追記・・・最近のモノクロスキャンは、FlateDecodeフィルタじゃなくてCCITTFaxDecodeを使うようになってるみたい。たぶん、Group4という高圧縮アルゴリズムを使うため。この形式だと今は扱えないなあ。

# coding: cp932

import zlib
import zipfile
import glob
from StringIO import StringIO
from PIL import Image, ImageOps, ImageEnhance

import pygame

#
# read lines from file. handle both CR and LF
#
def linesgen_crlf(f):
  while 1:
    l = f.readline()
    if not l:
      yield None
      return
    i = 0
    while 1:
      p = l.find("\r", i)
      if p == -1:
        yield l[i:]
        break
      else:
        yield l[i:p+1]
        i = p+1

#
# get image list from pdf data and
# generate position list (in that file)
# dirty PDF parsing!
#
def get_imagelist_from_pdf(f):
  fg = linesgen_crlf(f)
  icnt = 1
  images = []
  while 1:
    #l = f.readline()
    l = fg.next()
    if not l: break
    c = l.rstrip()
    if c.endswith("obj"):
      # detect obj
      b = []
      strpos, strlen = 0, 0
      while 1:
        #ll = f.readline()
        ll = fg.next()
        cc = ll.rstrip()
        if cc == 'endobj':
          break
        b.append(cc)

        if cc.endswith('stream'):
          # detect stream
          strpos = f.tell()
          while 1:
            #lll = f.readline()
            lll = fg.next()
            ccc = lll.rstrip()
            if ccc == 'endstream':
              break
            strlen += len(lll)

      objstr = " ".join(b)

      if "/Image" in objstr and "/ImageI" not in objstr:
        if "/FlateDecode" in objstr:
          # bitmap image(assumimg 1bit bitmap... dirty)
          oo = objstr.replace("/", " /").split(" ")
          w, h = -1, -1
          for i in range(len(oo)-1):
            if oo[i] == '/Width':
              w = int(oo[i+1])
            elif oo[i] == '/Height':
              h = int(oo[i+1])
          images.append(("%05d.bmp" % icnt, "bitmap", w, h, strpos, strlen))
          icnt += 1
        elif "/DCTDecode" in objstr:
          # jpeg image
          images.append(("%05d.jpg" % icnt, "jpeg", 0, 0, strpos, strlen))
          icnt += 1
  return images


# adjust contrast of given image
def adjust_image(img):
  ih = ImageEnhance.Contrast(img)
  return ih.enhance(1.5)


#
# image list holder (pdf)
#
class PDFImageList(object):

  def __init__(self, pdffile):
    self.f = open(pdffile, 'rb')
    self.images = get_imagelist_from_pdf(self.f)
    self.imagenum = len(self.images)

  def image(self, pos):
    im = self.images[pos]
    self.f.seek(im[4])
    streamstr = self.f.read(im[5])
    if im[1] == 'jpeg':
      i1 = Image.open(StringIO(streamstr))
      i1 = i1.convert("RGB")
      return adjust_image(i1)
    elif im[1] == 'bitmap':
      streamstr = zlib.decompress(streamstr)
      i1 = Image.frombuffer("1", (im[2], im[3]), streamstr, "raw", "1", 0, 1)
      i1 = ImageOps.invert(i1.convert("RGB"))
      return i1


#
# image list holder (image directory)
#
class DirImageList(object):

  def __init__(self, imagedir):
    self.images = glob.glob(imagedir + '/*.jpg') + glob.glob(imagedir + '/*.jpeg') + glob.glob(imagedir + '/*.png')
    self.imagenum = len(self.images)

  def image(self, pos):
    im = self.images[pos]
    i1 = Image.open(im).convert("RGB")
    return adjust_image(i1)


# image list holder (image zipfile)
class ZipImageList(object):

  def __init__(self, zipfilename):

    self.z = zipfile.ZipFile(zipfilename, 'r')
    self.images = [i for i in self.z.namelist() if i.endswith('.jpg') or i.endswith('.jpeg') or i.endswith('.png')]
    self.imagenum = len(self.images)

  def image(self, pos):
    o = self.z.read(self.images[pos])
    sio = StringIO(o)
    i1 = Image.open(sio).convert("RGB")
    return adjust_image(i1)

#
# imprements page moving feature
#
class ImageBook(object):

  def __init__(self, imagelist):
    self.imagelist = imagelist
    self.bookpages = imagelist.imagenum

    self.pos = 0
    self.page1 = self.prepare_image1()
    self.page2 = self.prepare_image2()

  def prepare_image1(self):
    i = self.pos % self.bookpages
    return self.imagelist.image(i)

  def prepare_image2(self):
    i = (self.pos+1) % self.bookpages
    return self.imagelist.image(i)

  def proceed(self):
    self.pos += 2
    self.page1 = self.prepare_image1()
    self.page2 = self.prepare_image2()

  def proceed_one(self):
    self.pos += 1
    self.page1 = self.page2
    self.page2 = self.prepare_image2()

  def back(self):
    self.pos -= 2
    self.page1 = self.prepare_image1()
    self.page2 = self.prepare_image2()

  def back_one(self):
    self.pos -= 1
    self.page2 = self.page1
    self.page1 = self.prepare_image1()


#
# imprements user interaction
#
class ImageBookViewer(object):
  def __init__(self, book, surface):
    self.swidth = surface.get_width() / 2
    self.sheight = surface.get_height()
    self.book = book
    self.surface = surface
    self.direction = 'l'
    self.quickrender = False

  def show_pages(self):
    if self.quickrender:
      resizeop = Image.NEAREST
    else:
      resizeop = Image.ANTIALIAS
      #resizeop = Image.BICUBIC

    if self.direction=='r':
      i = self.book.page1.resize((self.swidth, self.sheight), resizeop)
      s1 = pygame.image.fromstring(i.tostring(), i.size, i.mode).convert()
      self.surface.blit(s1, (0, 0))

      i = self.book.page2.resize((self.swidth, self.sheight), resizeop)
      s2 = pygame.image.fromstring(i.tostring(), i.size, i.mode).convert()
      self.surface.blit(s2, (self.swidth, 0))

    else:
      i = self.book.page1.resize((self.swidth, self.sheight), resizeop)
      s1 = pygame.image.fromstring(i.tostring(), i.size, i.mode).convert()
      self.surface.blit(s1, (self.swidth, 0))

      i = self.book.page2.resize((self.swidth, self.sheight), resizeop)
      s2 = pygame.image.fromstring(i.tostring(), i.size, i.mode).convert()
      self.surface.blit(s2, (0, 0))
    pygame.display.update()

  def handle_leftkey(self):
    if self.direction=='r':
      self.book.back_one()
    else:
      self.book.proceed_one()
    self.show_pages()

  def handle_rightkey(self):
    if self.direction=='r':
      self.book.proceed_one()
    else:
      self.book.back_one()
    self.show_pages()

  def handle_upkey(self):
    self.book.back()
    self.show_pages()

  def handle_downkey(self):
    self.book.proceed()
    self.show_pages()

  def view(self):
      self.show_pages()
      pygame.display.update()

      while 1:
        e = pygame.event.wait()
        if e.type == pygame.QUIT:
          return
        elif e.type == pygame.KEYDOWN:
          if e.key == 27:
            return
          #print e.key, e.unicode
          if e.key == 274:
            self.handle_downkey()
          elif e.key == 273:
            self.handle_upkey()
          elif e.key == 275:
            self.handle_rightkey()
          elif e.key == 276:
            self.handle_leftkey()
          elif e.unicode == u'f':
            if self.direction == 'r':
              self.direction = 'l'
            else:
              self.direction = 'r'
            self.show_pages()
          elif e.unicode == u'q':
            self.quickrender = not self.quickrender
            self.show_pages()

#
# see pathname and select suitable ImageList object
#
def GenImageBook(pathname):
  if pathname.endswith('.pdf'):
    p = PDFImageList(pathname)
  elif pathname.endswith('.zip') or pathname.endswith('.cbz'):
    p = ZipImageList(pathname)
  else:
    p = DirImageList(pathname)
  m = ImageBook(p)
  return m



def main():
  
  m = GenImageBook(bookname)
  pygame.init()
  s = pygame.display.set_mode((640,480))
  #s = pygame.display.set_mode((0,0), pygame.FULLSCREEN)
  mv = ImageBookViewer(m, s)
  mv.view()
  pygame.quit()


bookname = r'c:\some\path\to\imagedir'
#bookname = r'c:\some\path\to\zip.zip'
#bookname = r'c:\some\path\to\pdf.pdf'
main()


"""
#
# **OMAKE** (export all images from pdf)
#
pdffile = r'c:\some\path\to\pdf.pdf'
p = PDFImageList(pdffile)
n = 1
for im in p.images:
  p.f.seek(im[4])
  content = p.f.read(im[5])
  if im[1] == 'jpeg':
     open("%05d.jpg" % n, "wb").write(content)
  elif im[1] == 'bitmap':
     imgdata = zlib.decompress(content)
     img = Image.frombuffer("1", (im[2], im[3]), imgdata, "raw", "1", 0, 1)
     img = ImageOps.invert(img.convert("RGB"))
     img.save("%05d.png" % n)
  n += 1
"""

IPアドレスから国名を知る

IPアドレスから国名を知る方法について、ときどき必要になったり忘れたりするので、メモ。

MaxMind社がCreate Commonsライセンスで公開してくれている、GeoLite2っていうデータベースを使わせてもらう。
http://dev.maxmind.com/geoip/geoip2/geolite2/

これは有料版のGeoIP2ってやつの簡易版らしいのだけど、十分な精度だと思う。

ここでダウンロードできる、GeoLite2 CountryのCSVフォーマットをつかう。
(Cityのほうは多分都市まで分かるようなものなんだろうけど、僕には用がないし、データは大量だし、中身を見たことはない。)

このデータの中身はこんなふう。

"1.0.0.0","1.0.0.255","16777216","16777471","AU","Australia"
"1.0.1.0","1.0.3.255","16777472","16778239","CN","China"
"1.0.4.0","1.0.7.255","16778240","16779263","AU","Australia"
...
"223.255.252.0","223.255.253.255","3758095360","3758095871","CN","China"
"223.255.254.0","223.255.254.255","3758095872","3758096127","SG","Singapore"
"223.255.255.0","223.255.255.255","3758096128","3758096383","AU","Australia"

This product includes GeoLite2 data created by MaxMind, available from http://www.maxmind.com.

6つのフィールドの1つめと2つめが、IPアドレスの範囲。3つめと4つめもIPアドレス範囲なんだけど、これはIPを整数値に直したもの。0から255までの値が4つ並ぶのがよく見るIPアドレスの姿なのだけど、これは 256*256*256*256 = 4294967296 までの整数でも表せるというわけ。

5つめと6つめのフィールドは、国名ですね。

このデータを、まずは扱いやすいようにB-treeにする。B-treeなら、並んだデータの頭出しがしやすいから。使うのはpython

import bsddb

sourcefile = 'GeoIPCountryWhois.csv'
btreefile = 'geo'

ipdb = bsddb.btopen(btreefile)

for line in open(sourcefile):
  a = line[:-1].split(",")
  a = [i.strip('"') for i in a]
  k = '%010d' % int(a[3])
  ipdb[k] = a[5]

整数であらわしたIPアドレスを、10桁にそろえてbtreeのキーに使っている。文字列しかキーとして扱ってくれないっぽいので、こんな感じになった。

このデータを実際に検索して国名を出すのは、こんな感じ。

import bsddb

btreefile = 'geo'
ipdb = bsddb.btopen(btreefile)

def ip2country(ip):
  a = [int(i) for i in ip.split(".")]
  n = (a[0]<<24) + (a[1]<<16) + (a[2]<<8) + a[3]
  c = ipdb.set_location('%010d' % n)
  return c[1]

print ip2country("13.14.15.16")

btreeを頭出しして、調べるIPの直前の並びにいるものが、たぶん調べるレコード。

この例では 13.14.15.16 を調べてみた。結果は "United States" で、あってるっぽい。

実際には終了位置もチェックしないと万全ではないのだけど、データを眺める限り、ほぼスキマなくIPが網羅されているようなので、別にいいんじゃないかな。(ローカルIPとかそういうのを調べたらダメだけど)

B-treeファイルを作りたくないな、というときには、メモリ上のリストを二分探索する方式でもいいね。↓がサンプル。

import bisect

sourcefile = 'GeoIPCountryWhois.csv'

iplist = []
iplistdict = {}

for line in open(sourcefile):
  a = line[:-1].split(",")
  a = [i.strip('"') for i in a]
  k = int(a[3])
  iplist.append(k)
  iplistdict[k] = a[5]

def ip2country(ip):
  a = [int(i) for i in ip.split(".")]
  n = (a[0]<<24) + (a[1]<<16) + (a[2]<<8) + a[3]
  i = bisect.bisect_left(iplist, n)
  return iplistdict[iplist[i]]

print ip2country("13.14.15.16")

LimeSurveyのインストール

LimeSurveyはオープンソースのWebアンケートツール。便利です。

インストール(すべてroot権限で)

CentOSなら、yumで必要な基本ソフトをインストール。

# yum install httpd php php-mysql php-mbstring php-gd php-xml
# yum install mysql mysql-server
# yum install wget

httpdmysqlはマシン起動時に開始させよう。

# chkconfig httpd on
# chkconfig mysqld on

mysqlコマンドを実行し、下のコマンドでデータベースの新規作成とアクセスユーザーの設定。ここでは作るDBをlimesurvey、アクセスするユーザーもlimesurveyとする。

> create database limesurvey character set utf8;
> grant all on limesurvey.* to limesurvey@localhost identified by '(DBアクセスのパスワード)';

LimeSurveyの配布物をダウンロード。最新の配布物は http://www.limesurvey.org/en/ からチェックすべき。

例では、最新ビルドのビルド20140703を取得している。

# wget "https://www.limesurvey.org/en/stable-release/finish/25-latest-stable-release/1133-limesurvey205plus-build140703-tar-gz"
# mv 1133-limesurvey205plus-build140703-tar-gz 1133-limesurvey205plus-build140703.tar.gz
# tar zxf 1133-limesurvey205plus-build140703.tar.gz 
# mv limesurvey /var/www/html/
# chown -R apache.apache /var/www/html/limesurvey/

これでApache上のドキュメントルートにディレクトリを置き、パーミッションも整理した。

この時点で、http://(コンピュータ)/limesurvey/ にアクセスするとインストールウィザードが開始する。

DB設定は、上で設定したものを入力。ホストはlocalhostにする。

LimeSurveyの管理者ユーザーの情報も聞かれるので、パスワードも含めて適宜入力する。

まずはインストールまで完了。

※この例は、selinuxを無効にしたもので確認しています。

利用法

…追って書こう。マニュアルは http://manual.limesurvey.org/ から得られるけど。(半分くらいなら日本語訳もある)

サーバー証明書のつくりかたと、その原理

サーバー証明書」とやらを作る羽目になって困ってる人がときどきいる。こんなの分からないよう、できないよう、とか言って。特に、「証明書が失効しているよ」という報告を突然うけて、誰かがそれの対処をしなくちゃ、ということがありがち。なんで証明書ってのは永久に使えないんだ!と逆切れする人もいる。

同じようなことをけっこう色々な場面で説明してきた気がするが、こういう所にもメモを残してしまえば誰かの役に立つかもしれないと思うので、書きます。てっとり早く作るためのコマンドの叩き方だけじゃなくて、原理も含めて説明してみます。技術的にすごく正確に書く自信はないんだけど、ちょっと調べてみる限り、類似の記事がなぜかネット上にあまり見つからない(チラシみたいなページなら多いんだけど)ので、じゃあやってみようかなあと。

自分用に管理しているメモも、ネタをこうやって公開したらもう捨ててしまえるし。

ここでは、Apacheというウェブサーバーに設置するためのサーバー証明書のことに絞って説明していきます。別に他のシステムでも本質は変わらないんだけど、個々のシステムにあわせて何通りもやり方を確認するのはめんどくさいのです。でも、最終的な設置場所とか設置フォーマット以外のことは、どれも共通ですよ。

ウェブサーバーに入れて使うときに、特に「SSLサーバー証明書」って呼ぶことがあるみたいですね。別に呼び方はどっちでもいいと思うけど。

サーバー証明書には暗号化の「鍵」が入っている

ひとことで説明してみるなら、電子証明書サーバー証明書を含む一般的な呼び方)っていうのは、「これこれこうこう」という何かしらの(ほんとに何でもいいです)内容と、「確かにこの通りだよ!」という誰かのお墨付きがセットになったデータです。

サーバー証明書っていうのは、上の電子証明書のつくりかたを原則として、さらに一定の決まった方式に従って作られているやつです。X.509ってのがその方式の名前です。多分ね。

ウェブサイトのサーバー証明書は、クライアント(つまりウェブサーバーにアクセスする人々)と暗号化通信をするときに必要なものです。https:// で始まるURLのことですね。サーバー証明書の中の「これこれこうこう」という内容には、暗号化に必要な鍵情報も入っているんです。しかも、その鍵情報は、あとで言う「誰か」によって、確かなものだと認定されている。だからクライアントは安心してそのウェブサーバーと暗号化通信をエンジョイできる… こんなストーリーです。

暗号化通信ができたとしても、それだけで充分ではないです。その相手が実はニセモノだったらあまり意味ないですね。暗号化通信さえしているんなら大丈夫だろう、と思って、ニセの銀行サイトに大事なパスワードを打ち込んじゃう、なんてことも起こりえます。なので、「これこれこうこう」の中には、暗号化のための鍵情報だけではなくて、相手の名前やサーバーのFQDN(www.kirinwiki.comとかそういう名前のこと)なんかも入っていて、クライアントにとって充分に信用できる情報であるために努力がされています。この内容も、同じく「誰か」によって確かさが保証されています。

サーバー証明書には「電子署名」がついている

クライアントがサーバー証明書の内容を信用する根拠は、その証明書の中に書かれた「これこれこうこう」の中身によることももちろんですが、それにくっついている「お墨付き」が何より重要なものです。このお墨付きは正しくは「電子署名」というデータです。これがくっついているとなぜ電子証明書は信用できるものとみなせるのでしょうか。

思い切って単純化すると、電子署名というのは一種のチェックサムです。チェックサムなら分かりますよね。クレジットカード番号の下何ケタはチェックサムで、数字をいじりながら足し算していくとその数に一致するからこいつは正当なカード番号だ、とかそういうのが確かめられるやつです。電子署名のケタ数はむちゃくちゃ大きいですが、しかもこれを計算する方法は普通のチェックサムよりずっと複雑なものですが、まあチェックサムと理解してもそんなに外した話ではないと思います。

この電子署名という名前の“チェックサム”を計算するには、「これこれこうこう」という何らかの内容そのものと、お墨付きを与えようとしている「誰か」がもっている「秘密のデータ」、この二つを材料として作ることと決まっています。電子署名は、この「秘密のデータ」がないと作ることができないわけ。つまり、その「誰か」とやらが、「これこれこうこう」の内容をよく確認して、しかるのちに、自分の秘密のデータをおもむろに取り出し、おごそかに電子署名をつくるわけです。そういう情報も、サーバー証明書にはくっついている。

ここでいう「よく確認する」というのは、本当に人間がよく確認するということです。人間がその「これこれこうこう」という内容を見て、この内容が事実と一致すること、申込みをした担当者に電話が通じること、そもそも会社が本当に存在すること、そういったことなどをきっちり責任を持って調べます。これが、サーバー証明書に有効期間が設定されていたり、サーバー証明書の作成に料金がかかったりすることの理由です。内容の検証のために人間が実際に働く必要があるのだから、カネがかかります。また、検証された内容も、ある程度時間がたったら、また改めて検証しなおさないといけないですよね。

ここまで書くと「誰か」ってのが何なのか見当もつくでしょうが、それは後述。

電子署名の原理についてもう少し

さて、あるクライアントは、あるウェブサーバーと暗号化通信をしようとして、同サーバーからまずサーバー証明書を(自動的に)入手します。これを見て、今まさに通信をはじめようとしているサーバーの素性が明らかなかことを(特にFQDNがアクセスしたいURL中のものと一致することを)確認し、また、電子署名も見て、これが正当なお墨付きを持っていることを確認し、そこではじめて安心して当該サーバーと暗号化通信をはじめるわけです。

ところで、電子署名を作った人は、「秘密のデータ」を使ってそれを作りました。それを確認しようとするクライアントは、もちろんその「秘密のデータ」が何かを知りません。電子署名が「チェックサム」みたいなものだとしたら、当然、クライアント側でもその検算ができるのでないと、電子署名が正しいものなのかどうかがそもそも誰にも分からないですね。ここらへん、どうなっているんでしょう。

これは、「公開鍵と秘密鍵」という技術を使うことで実現しています。このペアになる鍵データには面白い特徴があり、秘密鍵を使って暗号化したデータは公開鍵を使わないと復号できない、ということを実現しています。逆に、公開鍵を使って暗号化したデータは、秘密鍵を使わないと復号できません。同一の鍵データを使ってでは、暗号化と復号の両方をできないんですね。面白く、また不思議な話です。

ここからの詳細を少しすっ飛ばしていきなり次のことを言うのですが、電子署名を作るときには誰かの秘密鍵、それの検証(検算)をするときは、その秘密鍵に対応する公開鍵を使うことになっているんです。電子署名を作ることそのものは、秘密鍵をもつ「誰か」にしかできないんだけど、それが確かであることを知るのは、公開鍵を知っている人にならだれでも可能です。

秘密鍵は決して他人にバラしてはいけないものなんですが、「公開鍵」という名前だけあって、こっちのほうは誰にも公開してしまってよいものなんです。それでもちゃんと暗号は成り立つようにできているんです。

なるほど、じゃあ認証局が作った電子署名を検証するには、その認証局の公開鍵を手に入れればいいというわけだ。僕らはみんなの(特に電子署名を作った「誰か」の)公開鍵をどうやって入手すればいいのかな?

公開鍵は、これもまた「電子証明書」の中に入っているものなんですよ。

電子証明書の「数珠つなぎ」

電子証明書の「チェーン」って見出しにしたかったけど、別に技術用語のつもりじゃないことを強調したかったので、いっそ「数珠つなぎ」で。

電子署名をつくる「誰か」のことを、そろそろ正しく「認証局(CA)」って呼ぶことにします。別に国の機関みたいなものじゃなくて、単に電子署名をつくる権利を任されている誰かのことです。

今までのことを整理して、次のようなことが言えます。

電子証明書をつくるために、認証局による電子署名が要る。その電子署名を検証するには、認証局の公開鍵が要る。その公開鍵は、電子証明書の中から得られる。

すいません、すこし意地悪な書き方をしました。より正しくは、

●あるサーバー用の電子証明書をつくるために、認証局による電子署名が要る。その電子署名を検証するには、認証局の公開鍵が要る。その公開鍵は、認証局電子証明書の中から得られる。

です。登場する電子証明書が二種類あるのがミソです。

認証局電子証明書は、(後に言う中間CAの場合、)これも通信しようとしているウェブサーバーから渡されるはずです。この場合、クライアントははじめに電子証明書を二枚受け取ります。その後、一枚目の電子証明書(サーバーのもの)の内容を、二枚目の電子証明書認証局のもの)に含まれる公開鍵を使って検証し、そこでやっと安心してサーバーと暗号化通信をすることになるわけです。

じゃあ、その「認証局の証明書」とやらは、どうして信用していいんでしょう。

それは、その認証局の証明書も、より上位の「誰か」にお墨付きをもらっていますから、全く同じような方法でそれを検証すればいいんです。上位の「誰か」の証明書を入手しましょう。

じゃあ、その上位の「誰か」とやらを信用していい根拠は? そいつの証明書に誰がお墨付きを与えるの?

なにやらキリがないですね。この、証明書のお墨付きの「数珠つなぎ」構造は、どこで止まるんでしょう。すべての電子証明書に、根っこから信用を与えるのは誰でしょう。

ルート証明書

答えは、お使いのウェブブラウザに内蔵されている、ルート証明書というものにあります。インターネットエクスローラ(たとえばIE11)なら、[インターネットオプション]→[コンテンツ]→[証明書]→[信頼されたルート機関]から一覧できますし、Firefox(バージョン30)なら、[オプション]→[詳細]→[証明書]→[証明書を表示]→[認証局証明書]から一覧できます。

ウェブブラウザは、これら数十種類の認証局電子証明書ルート証明書)のことは無条件で信用するという風に設定されています。なので、電子証明書の数珠つなぎを上に上にたどっていったとき、自分の知っているルート証明書のどれかに突き当ったら、もとになったサーバー証明書をやっと信用する、というルールになっているわけです。

どれでも適当なルート証明書を取り上げて調べると、その証明書の署名をしているのは、そのルート認証局自身という風になっています。つまりその証明書は「俺様の正しさを保証するのは、この俺様自身だ」と主張しています。こういう構造になっている、ある意味手抜きなサーバー証明書のことを俗に「オレオレ証明書」と呼んだりしますが、ルート証明書オレオレ証明書の本質的な違いは、世界中のウェブブラウザからの無条件の信用を勝ち得ているか、という一点に尽きるでしょう。ルート証明書は偉大な勝ち組オレオレ証明書です。

個人的に知りたいのは、ルート証明書がウェブブラウザに収録してもらえると、その認証機関は晴れて認証局ビジネスが始められるんですよね。ある意味、利権って感じだよね。これの選定ってどういう決まりがあるんだろう。よく知らないんだ…

いや、上の段の話はおいといて、ウェブブラウザによって、またそのバージョンによって、収録されているルート証明書は少しずつ違います。昔のブラウザとか、携帯電話のブラウザで一部のウェブサイトとまともに暗号化通信ができないときがあるのは、これって必要とされるルート証明書(数珠つなぎの終点にあたる証明書)が入っているかどうかに依存するからです。

ひとつ、ついでの話。認証局電子証明書には、署名を検証するための公開鍵が入っているのですが、サーバー証明書にも公開鍵が入っています。じつはこれらの内容はほとんど同じ仕組みです。サーバー証明書は、何かの署名をするための公開鍵を持っているわけではなくて、クライアントと暗号化通信をするためにそれを使います。ウェブサーバーとウェブクライアントの間の暗号化通信にも、公開鍵と秘密鍵という技術を使っているんです。

ここまでのまとめ・結局やるべきことは

サーバー証明書の使われ方、検証のされ方について説明してきました。(思ったよりずいぶん長くなったな…)これらを踏まえて、結局のところ、ウェブサーバーを立ち上げて、https:// でまともな暗号化通信ができるようになるには、担当者は何をしたらいいんでしょう、というところをまとめます。

サーバー証明書に入れるための、公開鍵と秘密鍵を作る

サーバーで使うための、公開鍵と秘密鍵のペア(鍵ペアともいいます)を、まず新しく作ります。公開鍵は、あとでサーバー証明書ができあがったとき、それに搭載される予定のものです。秘密鍵サーバー証明書には入りませんし、認証局に申し込むときにも渡しません。自分ひとりで厳重に管理しましょう。
これらは、他の用途で使ったものを使いまわしたりはせず、いちいち新しく作り直すのが普通です。

opensslというコマンドを使って作ります。簡単です。

「これこれこうこう」という情報を入力したデータをつくる

標準的なサーバー証明書には、どういう属性を設定すべきなのかが決められています。opensslコマンドを使って、これのためのウィザード機能を実行することができます。聞かれたことにポチポチ回答するたけでできあがります。簡単です。

データをまとめて、認証局に「電子署名してください」と提出する

上の「これこれこうこう」という情報を入力する時に、opensslは鍵ペアの情報も受け取ります。ですから、ウィザードが終了したら、この提出用データも同時に完成しています。簡単です。

この提出するデータを「証明書要求」とも呼びます。

データをどういう申込みルートで提出するのか、その他の申請フォームはあるのか、など、そういったことは、実際に電子署名を申し込む認証局のルールに従うことになるでしょう。ここらへんは、コンピュータ的な話じゃなくて、事務手続きですね。たぶん簡単です。

できあがった電子証明書を受け取る

申請を完了して、お金とかも払ったら、やがて、電子証明書サーバー証明書)という名前のデータが届きます。認証局の誰かが、おごそかに電子署名をつけ加えてくれたやつです。別に自分たちが何をするわけでもなく、簡単です。

ウェブサーバーに設置する

を、ウェブサーバーの決められた場所に置きます。ルート証明書は、ウェブブラウザが持っているのだから、ここに置く必要はありません。

簡単です。

でも、手順を全部通して眺めてみると、なんだか難しそうに見えるのも確かですね。原理がわかっていて、何が必要で、ひとつひとつが何のための手順なのかが理解できれば、別に詳細な手順メモがなくても割と大丈夫なものですよ。

opensslを使った、実際の手順例

上の手順は、実際にどういう操作で行うのかを見ていきます。

opensslが使えるコンピュータにログイン

まずは、opensslがインストールされた手近なUnix系コンピュータにログインします。LinuxMacなら、たいていの場合はopensslが入ってると思います。Windowsには普通は入ってないんですけど、それ用のバイナリもあるみたいなので、探してインストールしてみてもいいですね。

どの環境で作っても、できるデータには違いがないです。

鍵ペアをつくる

公開鍵と秘密鍵の鍵ペアを作ります。RSAという方式の鍵にするのが普通です。鍵には長さという概念があり、これが充分に長いものでないと、最近は安全ではないとされています。2048bit長を使えばよいでしょう。

手初めに、まず openssl genrsa とでも叩いてみましょう。ここらへんは、手順に関係ないイタズラです。

$ openssl genrsa
Generating RSA private key, 1024 bit long modulus
...++++++
...................................++++++
e is 65537 (0x10001)
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDO7OtI4Ip33lENaUSgMGpgI9USnMlg6FxXahCTJInPZ27OwpmQ
aLnA1THJEmBMDyXV9Jp3M9DL1gS9uoK17znuz+jfcwxRdYALymn15Y07Rbe6tQHa
(中略)
MnVU5T/YHqZqeYPcZ3sT8aIsz/jPnw2iOC8Yw0UjyBM=
-----END RSA PRIVATE KEY-----

画面上に、作られた鍵ペア情報がダラダラ表示されました。この中に、公開鍵と秘密鍵をつくるために必要な情報がすべて含まれています。BEGINという行からENDという行までが正味の部分です。Base64エンコードされているんですが、別に知らなくてもよいです。コピペもできる、英数字の並びとして表現されているところが特徴です。

何度も同じコマンドを叩いてみましょう。そのたびに、違う情報がが出力されます。乱数をもとに作っていますから、二度と同じものはできないのです。この環境では、鍵長を指定しないときのデフォルト値は1024のようですね。

次は、本当に使うための鍵ペアを作ってみましょう。鍵長と出力先ファイル名を指定します。

$ openssl genrsa -out my.key 2048
Generating RSA private key, 2048 bit long modulus
...........................................................+++
.............+++
e is 65537 (0x10001)

my.key というファイルに鍵ペアが格納されました。適当なテキストエディタで眺めてみるとよいでしょう。

これを使うと一旦決めたなら、それ以降、このファイルを厳重に管理しましょう。万一盗まれてしまうと、これを使って通信の機密性が確保できなくなってしまいます。また、このファイルを紛失してしまったら、これをもとにして作られたサーバー証明書が全く役に立ちません。

このファイル自体のことを、一般に「秘密鍵」と呼ぶことも多いです。

おまけ・鍵ファイルにパスフレーズをかける

鍵ファイルにパスワード(パスフレーズ)をつけて暗号化しておくこともできます。(暗号化のための鍵データをさらに暗号化する、というのは不思議な感じですが。)これなら、万一このファイルを盗まれてしまっても、パスワードを知らないかぎり実際の鍵ペア情報にはアクセスできませんから、より安全になるでしょう。(ただし、後述のウェブサーバーにこのファイルを設置したら、サーバーを起動するたびにパスワードを入力しなくてはいけなくなりますから、程度によって使い分けを考えましょう。)

鍵ペアを新規生成するときに、たとえば -des3 というオプションを加えるとそれができます。

$ openssl genrsa -des3 -out my.key 2048
Enter pass phrase for my.key:(ここでパスワードを入力させられる)
Verifying - Enter pass phrase for k.key:(確認入力)

パスワードを忘れると、二度とこのファイルの中の鍵ペアにはアクセスできません。注意しましょう。

または、すでにある鍵ペアファイル(my.key)について、新しくパスワードつきの鍵ペア(new.key)に変換することもできます。

$ openssl rsa -in my.key -des3 -out new.key
(このあと同様にパスワード入力)

逆に、パスワードつきの鍵ペアがあって、それをパスワードなしに戻したいときは、下のようにします。ファイル名の例は適当ですよ。

$ openssl rsa -in new.key -out my.key
(ここで元の鍵ペアにアクセスするためのパスワードを入力)

証明書要求データをつくる

サーバー証明書には、主に「FQDN」と「公開鍵」の情報が入っていることが必要です。(X.509的には、FQDNのことを「common name」というようですが。)証明書要求データも、これらの情報を含むものになるはずです。

まずはデタラメな証明書要求というのを作ってみましょう。my.keyという鍵ペアを使って、my.reqという証明書要求データをつくるときのコマンドは、下のとおりです。

$ openssl req -new -key my.key -out my.req

この後、ウィザードが開始します。証明したい内容をいろいろと聞かれます。でもまずは、全部デフォルト値で済ませてみましょう。ひたすら[Enter]キーを連打しちゃえばよいです。できあがるデータはデタラメなものになりますが、証明書要求を何度作ったり消したりしても、別に誰にも迷惑はかかりません。

my.reqができましたか。できたら適当なエディタで中身を見てみるとよいですが、バイナリデータ(のBase64表現)として格納されており、あまり目で見て意味のある内容が確認できません。これを視認用に整形してくれるコマンドは下の通りです。

$ openssl req -in my.req -text -noout

下のような出力がされましたか。

Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=XX, L=Default City, O=Default Company Ltd
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:d2:02:ee:7e:5c:c9:d8:57:bc:ce:67:c7:33:c9:
                    (中略)
                    d9:de:6c:8b:6b:82:4d:25:8d
                Exponent: 65537 (0x10001)
        Attributes:
            a0:00
    Signature Algorithm: sha1WithRSAEncryption
         1c:8e:69:1e:3c:03:d8:94:bf:10:3c:1c:e0:08:b2:fc:4e:ce:
         (中略)
         bf:65

見るべきは、subject: の行と、public-key: の入ったブロックです。subjectの内容は、今回は、デフォルト値だけで作られたデタラメのものです。public-key は、この証明書を入手した人は、このサーバー証明書の持ち主(subjectの内容の相手)と、この公開鍵を使って通信してほしいということをあらわしています。

(ここに出てくるSignature Algorhithmのあたりのデータも一応「電子署名」なんですが、これはサーバー証明書電子署名とは違います。)

まともな証明書要求にするためには、さきに[Enter]を連打した一連の質問のうち、少なくとも Common Name 属性を真面目に入力しましょう。そこだけまともに(例えば www.mydomain.com)入力すると、subject: の行は下のように変化します。

Subject: C=XX, L=Default City, O=Default Company Ltd, CN=www.mydomain.com

CNがCommon Name のことです。もちろん、その他の属性を入力してあることが結局は必要なんですが、こんな感じに「証明」したい内容をここに盛り込んでいくのがやるべきことです。本番用のデータを作るときにどの属性を入力しておくことが必要かは、個々の「認証局」が明確に指定しているでしょうから、それに従ってください。

認証局から、電子証明書を受け取る

ここはもう、受け取るだけです。ナントカ.crtという名前でファイルが手元に届くでしょう。中身は、たぶんやっぱりBase64形式のバイナリファイルです。テキストエディタで覗くだけでは、詳しい内容はわかりません。

たとえば、受け取った証明書ファイルがserver.crtだったら、下のようなコマンドで内容を視認してみるとよいでしょう。

$ openssl x509 -in server.crt -text -noout

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 102 (0x66)
    Signature Algorithm: sha1WithRSAEncryption
        Issuer: (認証局の素性にあたる情報)
        Validity
            Not Before: Dec 23 23:56:41 2013 GMT
            Not After : Dec 23 23:56:41 2014 GMT
        Subject: (サーバーの素性にあたる情報)
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:a6:76:22:a0:f0:73:fd:14:56:86:da:2f:29:0a:
                    (中略)
                    dc:12:2b:fa:8a:3f:bd:6d:bb
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                D4:70:95:1C:AC:0B:A8:83:C7:62:59:38:A8:59:79:A0:4B:7B:01:71
            X509v3 Authority Key Identifier:
                keyid:D4:70:95:1C:AC:0B:A8:83:C7:62:59:38:A8:59:79:A0:4B:7B:01:71

            X509v3 Basic Constraints:
                CA:TRUE
    Signature Algorithm: sha1WithRSAEncryption
         44:05:61:a4:f8:f9:6f:7d:72:e3:20:db:b1:43:53:bd:45:87:
        (中略)
         8c:9b

Issuerが、この証明書に署名をつけた認証局が誰かをあらわす情報です。Subjectは、証明書要求を作ったときと同じ情報が入っているはずです。

Validityのあたりを見ると、このサーバー証明書はいつからいつまで有効なのかが分かります。

Publick-Keyのあたりが、このサーバーと通信するときに使わせたい、公開鍵の情報です。

そして、Signature Algorithmのあたりが、認証局に作ってもらった電子署名の部分です。

ここらへんの情報は、ウェブブラウザで適当なhttpsのサイトにアクセスして、アドレス欄に現れる鍵マークなんかをクリックして探してみると見られる内容と同じです。だから、サーバー内に置いた、まさしくこの証明書データがクライアントに渡って、それが素性の確認に使われているんだということがわかりますね。

中間CA証明書を入手する

中間CA証明書は、必要なら、たいていは認証局のサイトのわかりやすいところからダウンロードできるようになっているはずです。これも、ナントカ.crt という拡張子のはず。

それぞれのファイルを、Apacheが指定する場所に配置する

配置すべきファイルがそろいました。秘密鍵ファイル、電子証明書ファイル、中間CA証明書ファイル(あれば)です。

Linuxディストリビューションによって少しずつ標準的な配置場所は違いますが、最近のCentOSの場合だと、

です。スーパーユーザーでないとアクセスできない場所なので注意しましょう。特に秘密鍵ファイルは、ファイル所有者(つまりroot)しかアクセス権がないようにしておきましょう。(chmod 600 とかで)

CentOSyumコマンドを使ってApacheを入れたとき、SSL設定のための設定ファイルは、

/etc/httpd/conf.d/ssl.conf

です。この中身のうち下の三行を直しましょう。

SSLCertificateFile /etc/pki/tls/certs/localhost.crt
SSLCertificateKeyFile /etc/pki/tls/private/localhost.key
#SSLCertificateChainFile /etc/pki/tls/certs/server-chain.crt
  • 一行目が、サーバー証明書の場所。
  • 二行目が、秘密鍵の場所。
  • 三行目が、中間CA証明書の場所。必要なければ、行頭に#をつけて無効化したままでよい。

全部設置と設定が終わったら、Apacheを再起動ね。

# service httpd restart

長い話になった

今度はちゃんと、有効期限が切れる前に証明書を更新しといてよね。

システムを企画するときの覚え書き

ここでは、あまり大規模なシステムのことは扱っていない。局所的な使い道のシステムとか、ある大きなシステムの補助になるサブシステムとか、そんなのを想定。中小規模なシステムなだけに、わりと業者任せに「これこれこうしてよう」といいかげんに発注してしまい、あとでトラブって「こんなはずじゃなかった」と言いたくなることってある気がするので、そういうのを防ぎたいなあと思いながら書くメモです。

要求を見つける

こういうことがしたいんだ、という声がどこかから挙がってくるので、これが最初の要求かなあ、と考えるのが自然なんだけど、現場から上がる声と上司から降りてくる声には差があるので要注意。

現場の声からは、実際に困っている具体的なシーンを知ることができるが、システム全体としてのその業務の位置づけは別に考えてあげる必要がある。他の部署と共通点のある業務なのに、その人たち専用のシステムを作りこんでしまい、他の部署ではイマイチ使いまわせない…というのは時々ある罠。

上司から降りてくる声は、(あくまで一般論だけど)業務全体の効率化を願ってのことでも、業務上の個々の作業量への考慮は少ないことがある。あとは稀に、変なバズワードにかぶれてたり。そのネタ、どこの日経コンピュータから仕入れたんスか。

要求として声が聞こえてこなくても、システムが少し分かる立場から見てみると、なんだか変な苦労をわざわざしているなあ、と思えることがある。仕事している人は疑問をあまり持たずにそれを行ってても、その仕事って明らかに非効率なんじゃないかなあ、とか。これが潜在的な要求だとわかる。下のような感想を持つことって、割とあるじゃないですか。

  • なんか無意味なペーパーワークさせられてるなあ…
  • なんか無意味な人的チェックをしてるなあ…
  • なんか無意味な利用制約があるなあ…
  • なんか無意味な処理時間待ちをしてるなあ…
  • なんか無意味なデータ(レポート)整形に労力使ってるなあ…
  • なんか無意味に大きな添付ファイルをやりとりしてるなあ…

こういうのを見つけたら、現場の人とかに「これがこうだったら、もっと効率上がりませんかねえ」とか話を振ってみる。「そんなことができるなら(許されるなら)、やりたいな!」という返事が返ってきたら、ああ、要求はあったんだなあということ。

構想を練る

ここらへんで、誰にどのように役に立つ仕組みなのかを改めてよく考えてみる。この時点で、プログラミングなどの知識は不要。いるものは想像力。

最初はいい思い付きだと思っていても、完成したシステムの運用が始まってみると大して便利になっていないという失敗は、わりとありがち。そのシステムによって、誰が何を「できる」のか、誰が何を「できない」のか、誰が何を「してもしなくてもいい」のか。また、この際、「だれに面倒が生じるか」ということも想像してみるとよい。Aさんの手間を省くシステムのつもりで作ったら、Bさんの仕事が増えるとか。

ボタン配置がどうとか、ページ遷移がどうとか、そういうのを考えるにはまだ早い。

プログラミングの知識は不要というものの、システムが「データ」を扱うんだという認識はそれなりに必要。システムはデータのないところからそれを魔法のように生み出してくれるわけではないので、どんな「データ」を投入されて動くのかは意識したい。ただし、ここで賢しらにXMLだのKey-Valueだの変な用語をふりまわす必要はない。システムを「聞いたことを忘れない、(ある意味)超優秀な人間」くらいに想定して、そいつが何を知っていて管理してくれるとみんなの役に立つのか、という想像なんかをしてもいいかも。

やりたいことの「本道」のような部分により集中力を使ってみる。本質的でない部分を議論し始めてもあまり生産的ではない。「アイテムの表示ページには、ボスのコメントが点滅表示されるといいなあ」といった思いつきをねじ込まれたとしても、はいはい、くらいに生返事して、しばらく放っておくとか。

この時点で、利用の可能性がある人にできるだけ話を吹っかけて、大体同意、という意見を多く得ておくとよい。あとになっての基本的な機能の変更は、手戻りになってしまうので避けたい。開発業者にもとても嫌がられるものだし、場合によってはコスト割れさえしてしまうかも。

類似システムを予習する

やりたいことがよほど独創的なことでない限り、似たようなシステムはあるはず。それを探したり思い出したりし、それらと似ている点、違う点を考えてみると、最初の手掛かりになりやすい。

開発業者にあとで相談するときも、「○○という仕組みに似ていて…」という話から入ると、通じやすいかも。(業者側からは、「たしかに似ていますね」とか「それは実は似ていないですね」といった反応があるだろう。どちらにしても設計の助けになる)

こういう「似たシステム探し」を効果的に行うためには、よくあるシステム類型とその特徴は一通り知っていたほうがいいと思う。思いつくままに適当に挙げてみると…

  • Webアプリケーション…Webクライアントさえあればシステムが使える。不特定多数の利用を想定するなら普通これ。攻撃的アクセスを試されることが多い。
  • クライアントサーバー(クラサバ)型…VBとか、MS-Accessとかで、複数のクライアントがひとつのサーバー上のデータを共有・管理する。クライアント環境に注文が多くなる。一般に、開発は手早い。
  • スタンドアロン…入力ルートもひとつ、出力も、何か整形した成果物を取り出したいだけなんだ、といった感じのシステムだと、そもそもコンピュータ一台で完結してしまうようなときもある。

WebアプリならWebアプリでも、CMS(コンテンツ管理システム)みたいなもの、SNSソーシャルネットワーク)みたいなもの、Wikiみたいなもの、メールフォームみたいなもの、掲示板みたいなもの、ブログみたいなもの、アップローダ―みたいなもの…とかいろいろありますね。ふだんWebでいろいろ遊んでいると、自然と分かってくると思うけどなあ。

仕様のたたき台をつくってみる

利用中のイメージ(起こること、画面上の表示)をいろいろスケッチしてみよう。大き目の紙にガリガリ書いてみたり、紙を切り貼りしたりして組み立ててみてもいいだろう。思いつきをどしどし描くのに、パワポなんか使ってきれいな作図するヒマはないと思う。手描きが基本。

データはどこからどこに入って…といった感じの矢印記号を入れるのもいい。登場人物(操作者、管理者など)を棒人間でいいから横に書いてみよう。この人が画面を操作するんだな、というイメージが明らかになる。

こうしてメモ的な「プレ仕様」とでも呼べるものを作っているうちに、ふと「プロジェクト名」が浮かんでくることがある。プロジェクト名が決まると愛着がわいて、幸先がいい。

データを再利用したいときがあるかを考えてみる

システムからはどんなデータが発生するか。それらのデータはシステム内で使われることはもちろん、システムの外に出しても使い道があるかもしれない。どんな使い道が想像できるか。もしプログラムが書けたら、そのデータで何がしたいか。

プログラムなんて作れないよう!と思考停止しないで。Excelマクロとか、意外と書けてたりするじゃない。(ある意味、専用のプログラム言語より小難しいことさえしている。)でも、内輪でも特殊な人にしか書けないような複雑なプログラムが必要になりそうなら、その人がいなくなる時のことを考えて、プログラム作成はシステム開発の範囲に盛り込んだ方がいいだろうし、ここの見極めはちょっと難しいかもしれない。とはいえ、プログラム的なものは、自分たちでは一切管理できないと考える必要はないと思う。データの発生はシステム上で起こっても、その後、Excel等で生データが取り込めれば、グラフとかクロス集計で済ませられる部分がある。また、マスタ系のデータは、専用の編集インターフェースがなくたって、SQLやそれに類するツールである程度(危険度の考慮は必要だけど)整備できる。MS-Accessとか、phpMyAdminとか、データを自力でいじるための簡単な方法は以外といろいろあるものです。むしろ、これら相当の機能まで業者に作りこませたことで、高い上に却って融通のきかない(ときにはバグさえある)仕組みができてしまうことがある。

自前で足りる開発というのは、きっとある。プログラムの技術が少々と、あとは何より「ドキュメント作成の技術」があれば。意外と後者のほうが問題なんですよね。

業者に相談する

開発業者を呼んで相談するときには、相談に至ったストーリーに触れるのが重要。「何々がしたいと思って、こういう形のシステムになりそうだと考えた。」とはじめに伝えておくと、業者にとってはとても分かりやすい。業者は開発のプロであり、これこれの要求があるときは、お客自身のアイデアよりも、むしろこういう種類のソリューションが適している、という知識を持っているかも知れない。設計方針を間違えるとこういった落とし穴がある、という経験も持っているかもしれない。そこを引き出す役にも立つ。

業者相手にあまり技術上の知ったかぶりはしないほうがいい。どういう技術を選択するかは、普通は業者にある程度任せるのが正しい。背伸びして一知半解な用語を使ってしまい、それを真に受けられて変なシステムを作られたりしても嫌だし。

あとは、業務上の知識・概念を正確に伝えられるよう努力すべき。ふだん慣れた業務だと、変な用語が(それが一般常識とズレていても)当たり前のものとして認識されがちなので、つい共通の言葉だと思って使ってしまう。業務の内容や用語が正確に何を表しているのかについては、あらかじめ現場の人によくインタビューしておくくらいのことが必要。少なくともシステム企画をする自分には分かっていないと始まらない。あいまいに伝わりそうな言葉として思いつくものに、「支払」「締め」「営業日」「ユーザー」などがある。

システム的な考えになじまない人から、システムを作るための情報を聞くためにうまくインタビューするのはとても難しいよ。意外なほどに熟練が必要。

仕様を詳しくする

業者との打ち合わせで得た知識を援用して行う。どういうボタン、どういう入力欄があり、どういう操作のタイミングで何が起こるのかをできるだけ詳しく書いてみる。仕様のつくりかた全般については、このメモだけでは述べ尽くせないけど。

正しい値が入力されたときに何が起こるのかは想定しやすいが、間違った値が入力された(または空白のままだった)ときにどういう困った事態になりそうかも想像してみる。仕様上は、そういった入力、悪意のある入力を防ぐこと、といった記述が必要になる。これらが不完全なままシステムがリリースされると、入力ミスが即トラブルの原因になり、「おそるおそる」使わなくてはいけないシステムになる。「このボタンを押すと取り返しがつかないから押すな!」とか。システムが壊れたときに、なぜか通常権限しかないオペレータが怒られたり。傍から見ているとワケがわからない話だけど、時々起るのも確か。

システムに、どれほどの負荷がかかりそうかを現実的な値として考えてみる。データの容量が増えればストレージが不足したりバックアップ&リストアに時間がかかるようになるかもしれない。同時アクセス数が多ければメモリやCPUの使用を圧迫するかもしれない。これを検討したのちに、業者にシステム利用の想定規模を伝えて、スペックを見積もってもらうことになるだろう。

システムが「使えなくてもいい」日や時間帯はあるか。夜間は使わないシステムなら、その時間にすっかりシステムを止めて、フルバックアップバッチ処理にあてられる。止めることが許されないシステムなら、もうちょっとうまい方法が必要。それでも「止めていい」部分と「24時間稼働」な部分は切り分けられるといい。

発注する(契約内容を決める)

望むシステムについて、どのくらい環境依存が発生するのかは確かめておく。Windowsサーバーの特定バージョンでしか動きません、IEの特定バージョンでしか動きません、特定のプリンタでしか帳票が出せません、とか限定するほうが開発者にとってはリスクが少なくて楽なのだけど、使う立場は環境がいろいろ選べる方が有利なので、ここは妥協点を探して真剣に打ち合わせるべき部分。環境依存が減るのは、実は開発者にとっていい面もあるはずだよね。特殊なテスト環境をずっと抱えなくて済むとか。

要求する内容を変えることで、環境依存部分を減らせるかもしれない。希望する機能を達成するためにブラウザ上でActiveXを使うとか、一見うまいソリューションに思えても、後々になるとロクな保守ができずに困ることがある。要注意。

使う人がWindows以外の環境に自信がない(またはLinuxとか聞いたとたんに思考停止してしまう)ときは、ついサーバー環境にもクライアント環境にもそれらの慣れた環境を期待してしまい、結果として環境依存の強いシステムができてしまうことがある。

発注前には、当然見積もりを取ることになるのだけど、今まで挙げたような仕様上のポイントを押さえた上で見積もりを取ることで、業者にとっては余計なリスク感が減って、安くしやすい。また相見積もりをとって安い方を選んでも失敗が少ない。いいかげんな仕様にいいかげんな安値をつけてくる業者が、いい仕事すると思う?

開発の進捗をチェックする

作りかけのものを見せてもらえると、ちょっとした微修正もお願いしやすくてよいし、業者にとっても間違った道を進んでいないという安心感になる。変に「途中経過をなんとしても見せよ」とか言っていじめることはないけど。

「あれは思ったより難しいことが判明した。これをこうすれば代替案になる」なんていう提案が開発側から来るときがある。嬉しいことではないが、契約の範囲内で、なんとか柔軟に対応できるとよい。こちらの都合だけで書いた形式的なガントチャートみたいなのを押しつけて、ギリギリ締めるだけで困らせるのはよくないと思うんだよな…

納品物をチェックする

正常系のテスト(想定どおりのデータ・想定通りの操作手順を試すテスト)はもちろんだが、エラーを起こしてやろうという、ちょっと意地悪なくらいのテストをするのも必要。堂々と壊せるのは、ある意味今だけ。システムのシャットダウン・再起動もやってみる。本当にクリティカルなシステムのときは、電源を突然切断するとかそんな意地悪なテストもするときがあるが、それは程度次第。

バックアップ&リストアが正しくできるかどうか確認しておくべき。

ログがなぜか滅茶苦茶にたくさん出力されてるのに気づかず、ある日ストレージが枯渇する、なんていう話がときどきある。デバッグモードがONのままだったとか、内部的に山ほど起こっているWarningを無視していた、とか。サーバーの内容がある程度確認できるのなら、いろいろ探検してみると、こんな隠れ不具合も発見できるかも。

利用者へのトレーニングをする

利用者が多い時は、FAQが整備できるとよい。というか、問い合わせの殺到を防ぐためにぜひ作りたい。運用側の負荷を減らすために、よいFAQを目指すことは結構真剣な仕事。事例集やメールのやりとり記録なんかを無秩序にコピペしてもあまり役に立たない。FAQ作成担当が、事例をちゃんと消化して過不足のない内容にしなくてはいけない。また頻繁に更新もできるように。利用者目線の内容でないといけないので、あまり開発業者に任せられないところだと思う。

異常発生時を想定する

どんな問題が起こったときに誰に連絡し、その誰々が開発業者にどういう連絡をするのかを考えておく。必要ならば期間サポート契約を考慮する。

また、納品ドキュメントの品質がよければ、当該の開発業者にメンテナンスができなくなってしまったあとも対応が容易になるので、(品質の見分けをつけるのは簡単でないが)できるだけ注意する。

運用ドキュメントをつくる

運用担当者は入れかわるものだという前提を持つこと。何を把握しておき、どういう事態にどういう対応をすべきなのかを文書にしておく。どういった登場人物がいるのか、どういったルーチン作業があるのか、どこでパフォーマンスを監視できるのか、どこまでパラメータを調整していいのか。つまり属人的な運用体制にしない工夫をすること。これは、普通、開発業者にはできないこと。

メンテがままならないシステムのお守りを突然任せられる新人の悲しさったらないよ。

システムの寿命を想定する

家に帰るまでが遠足です! 捨てるまでがシステム企画です!

いつになったら(どういう条件が満たされたら)このシステムが不要になるか、または後継システムによって役割を引き継げるのかを想定しておけると望ましい。もちろん正確にこれを予想するのは難しいのだけれど。

時代遅れになってしまったシステムを後生大事に使い続けるときの弊害というものがある。遅い、高負荷に耐えられない、保守がままならない、セキュリティアップデートができない、後継システムへのデータ移行が難しい、とか。

とはいえ、ある程度時間がたつと、システムは「捨てていいのか分からない」という状態になってしまいがち。誰もシステムの全貌がわからなくなるからです。誰がこのシステムを使っているのか、必要なデータは誰が受け取っているのか、また、このシステムから出力されたデータはどう使われているのか、といったことが把握できなくなると、システムを捨てたときにどんな影響が現れるのかが判断できない。ドキュメントを残してあるのが重要な理由がここにあります。また、このシステムがそもそも何のために作られたんだっけ、ということをいつでも思い出せることが必要。これは運用ドキュメントの中に入っているべき内容です。

ステンシルを着色画像に合成する

前回のつづき。

ステンシルを着色画像に直すには、まずはpythonだとPILモジュールで簡単にできる。

実際のシステムでは、CGIスクリプトが指定のステンシルを指定色で合成したものを返してくれることになっているが、その核心部分だけ書けば、下のよう。

import PIL.Image
import PIL.ImageColor

s = PIL.Image.open('stencil/%s.png' % stencil)

w = s.size[0]/snum
h = height

outimage = PIL.Image.new("RGBA", (w, h), (0,0,0,0))
for i in xrange(snum):
  a = PIL.Image.new("RGBA", (w, h), (0,0,0,0))
  a.paste(PIL.ImageColor.getrgb(colors[i]), (0,0), s.crop((i*w,0,(i+1)*w,h)))
  outimage.paste(a, (0,0), a)

outimage.save(ファイル名または標準出力, format="PNG")

あらかじめ、colors配列には適当な色データが入っていること。["#000000","#ffffff"]といった感じで。snumは色数。

ステンシルは下のような二値画像だが…
f:id:yamatt2:20140215104554p:plain

これをマスク用データとし、できあがり予定サイズの画像データに、次々と色を重ねていくのが、pasteメソッドの発行部分。下の絵のようなイメージの仕事をしている。
f:id:yamatt2:20140215111947j:plain

すべての色を塗り重ねたら、どっかにセーブするなり、標準出力にでも書き出してしまうなりして、終了。

さて、ウェブブラウザ上でも、着色具合を試しながら色指定をしたくなるはずなので、これと大体同様のことをJavaScriptでも書くことにした。これも核心だけ書くと、こんなふう。要canvasAPI。

var canvas_plt;

var disp_colored_image = function(stencil, conf, ctx_tmp, ctx_dist ) {

  ctx_tmp.drawImage(stencil, 0, 0);
  var t = ctx_tmp.createImageData(conf.w, conf.h);
  // fill in 'transparent' color
  for (var i=0;i<conf.w/6;i++) {
    for (var j=0;j<conf.h/6;j++) {
      for (var ii=0;ii<6;ii++){
        for (var jj=0;jj<6;jj++) {
          var p = ((j*6+jj)*conf.w + i*6+ii)*4;
          if (i%2 == j%2) {
            t.data[p] = 240;
            t.data[p+1] = 240;
            t.data[p+2] = 240;
          } else {
            t.data[p] = 220;
            t.data[p+1] = 220;
            t.data[p+2] = 220;
          }
          t.data[p+3] = 255;
        }
      }
    }
  }
  var pltctx = canvas_plt.getContext('2d');
  // merge images
  for (var j=0;j<conf.snum;j++) {
    r = ctx_tmp.getImageData(conf.w*j, 0, conf.w, conf.h);
    rl = r.data.length / 4;
    pltctx.fillStyle = conf.colors[j];
    pltctx.fillRect(0,0,1,1);
    var rp = pltctx.getImageData(0,0,1,1);
    for (var i=0;i<rl;i++) {
      if (r.data[i*4] != 0) {
        t.data[i*4] = rp.data[0];
        t.data[i*4+1] = rp.data[1];
        t.data[i*4+2] = rp.data[2];
        t.data[i*4+3] = 255;
      }
    }
  }
  ctx_dist.putImageData(t,0,0);
};

このdisp_colored_image関数が、stencil(画像データ)、conf(画像サイズや色の指定)、ctx_tmp(一時処理用描画コンテキスト)、ctx_dist(最終表示用描画コンテキスト)を受け取って描画処理をする。

前半部分は、単に下のような透明っぽいチェッカー模様を描こうとしてがんばっただけ。もっとシンプルに書けるんだろうけど。
f:id:yamatt2:20140215113246j:plain

後半部分で、ステンシルの「真っ黒以外のピクセル」に対応する部分について、指定色のピクセルをひとつづつ置いていくこをと繰り返し、最終的に作りたい画像のプレビューを作れるようになった。

すごく端折って書いているが、あとで自分にはわかるだろうから、まあいいや。

クリップアートサイトを作ったときの技術解説、これでおしまい。

サイトはここ。

画像からクリップアートを切り抜き、ステンシル化する

前回のつづき。

手描き画像を取り込んで着色し、そこからクリップアートのデータを作ることにする。

まず、着色のルールを決めた。ひとつの作業用画像が複数のクリップアートを含むことができることにした。この際、背景以外の色が連続して隣接しているものをひとつのクリップアートとみなそうかと思ったが、そうすると、このようなクリップアートの
f:id:yamatt2:20140215101751p:plain

声の部分だけが別のまとまりとして認識されてしまうので、「切り抜き色」というのを決めて(この場合は濃い灰色)、これを含んで背景色以外のピクセルが隣接しているものをひとつのクリップアートとみなすことにした。

「切り抜き色」は、作業用画像全体の左上のピクセル色、背景色は右上のピクセル色を使うというルールにした。だからどの作業用画像も、左上にちょっとテスト塗りみたいなものが紛れている。
f:id:yamatt2:20140215102157p:plain

背景色のほうは、右上端には何も塗らない、というルールにしておけばよい。

さて、GIMPで塗り作業をしたあとで、pngに一旦落とした画像データを、切り抜き色をつかってバラバラにするというプログラムが必要になった。

pythonとPILモジュールを使って、まずクリップアートを切り抜く仕事をするスクリプトを書いた。不定形の範囲の塗りつぶしに相当する処理を書かないといけなかったので、少し長い。

import sys
from PIL import Image

class ImageClipper(object):

  def __init__(self, imagefile):
    self.load(imagefile)

  def load(self, imagefile):
    self.img = Image.open(imagefile).convert('RGB')
    self.imgbuf = self.img.load()
    self.bcolor = self.img.getpixel((self.img.size[0]-1,0))
    self.ccolor = self.img.getpixel((0,0))
    # dummy
    self.clip_one()

  def extend_sides(self, p):
    limit = self.img.size[0]-1
    lbound, rbound = p[0], p[0]
    while 1:
      if lbound == 0: break
      if self.imgbuf[lbound-1,p[1]] == self.bcolor: break
      lbound -= 1
    while 1:
      if rbound == limit: break
      if self.imgbuf[rbound+1,p[1]] == self.bcolor: break
      rbound += 1
    return (lbound, rbound)

  def scan_ajacent_lines(self, startx, endx, y):
    result = []
    x = startx
    while 1:
      if self.imgbuf[x,y] != self.bcolor:
        b = self.extend_sides((x,y))
        if (b[0], y) not in self.lines:
          result.append((y, b[0], b[1]))
          self.lines[(b[0], y)] = b[1]-b[0]+1
          x = b[1]+1
        else:
          x = b[0] + self.lines[b[0], y]
      else:
        x += 1
      if x > endx: break
    return result

  def fill_region_bcolor(self, r):
    for k in r:
      for i in xrange(r[k]):
        self.imgbuf[k[0]+i, k[1]] = self.bcolor

  def fittable_rect(self, r):
    left, top, right, bottom = (sys.maxint,sys.maxint,0,0)
    for i in r.keys():
      if top > i[1]: top = i[1]
      if bottom < i[1]: bottom = i[1]
      if left > i[0]: left = i[0]
      if right < i[0]+r[i]-1: right = i[0]+r[i]-1
    return (left, top, right, bottom)

  def region_lines(self, point):
    self.lines = dict()
    q = []

    b = self.extend_sides(point)
    q.append((point[1],) + (b[0], b[1]))
    self.lines[(b[0], point[1])] = b[1]-b[0]+1

    while q:
      (ty, lbound, rbound) = q.pop()
      if ty > 0:
        r = self.scan_ajacent_lines(lbound, rbound, ty-1)
        q.extend(r)
      if ty < (self.img.size[1]-1):
        r = self.scan_ajacent_lines(lbound, rbound, ty+1)
        q.extend(r)
    return self.lines

  def clip_image_region(self, r):
    box = self.fittable_rect(r)
    width = box[2]-box[0]+1
    height = box[3]-box[1]+1
    pp = Image.new("RGB", (width, height), self.bcolor)
    px = pp.load()
    for k in r:
      for i in xrange(r[k]):
        c = self.imgbuf[k[0]+i, k[1]]
        if c != self.ccolor:
          px[k[0]+i-box[0], k[1]-box[1]] = c
    return pp

  def clip_one(self):
    for x in xrange(self.img.size[0]):
      for y in xrange(self.img.size[1]):
        if self.imgbuf[x,y] == self.ccolor:
          r = self.region_lines((x, y))
          img = self.clip_image_region(r)
          self.fill_region_bcolor(r)
          return img
    return None

  def clip_generator(self):
    i = self.clip_one()
    while i:
      yield i
      i = self.clip_one()

このImageClipperクラスの使い方は下のよう。

ic = ImageClipper(ファイル名)
for i in ic.clip_generator():
  #iに画像ファイルが入ってくる…

切り抜き色を含んだ画像が、すべてループで取り出せる。一番最初に取り出されるものは、左上端の「切り抜き色指定用」範囲にあたるが、そこは自動的に捨てられる。

下のような画像などが、次々とループの値として得られる。切り抜き色はもう要らないので、背景色に置き換えられる。
f:id:yamatt2:20140215103040p:plain

さて、これをクリップアートとして利用する際には、背景色を透明色にしたいのはまずもちろんのことなのだけど、色の変更もできるようにしたいと思っていた。この場合は輪郭の「黒」と、頭部の「白」をそれぞれ好きな色に置き換えられたら面白い。

だから、できあがったこの画像を、色ごとの版木のようなものに変換して管理することにした。「ステンシル」と名づける。

できあがる予定のステンシルは、こんな感じ。
f:id:yamatt2:20140215103621p:plain

完全二値の画像にして、色ごとの版木データが横につながったものになる。このステンシルには対応する(デフォルトの)着色データと出来上がり予定の画像サイズデータも必要になる。この場合は、([黒、白]、352x303)といった感じ。

ここでは二色だけなのでステンシルが面白くないが、別の例で、下のような着色データは…
f:id:yamatt2:20140215104718p:plain

こんなステンシルデータになる。
f:id:yamatt2:20140215104554p:plain

着色は、境界をきれいにぼやかしてフワフワ塗ると、微妙に違う色が全部別のステンシルになってしまうので、きっかり同一色で塗らなくてはいけない。注意。

ステンシルデータを作るメソッドを、さきのImageClipperクラスに作り足すことにした。

class ImageClipper(object):

  #
  # …すでに書かれたいろいろのメソッド…
  #

  def stencilize(self, pimg):
    px = pimg.load()
    cdir = dict()
    for x in xrange(pimg.size[0]):
      for y in xrange(pimg.size[1]):
        c = px[x,y]
        if c != self.bcolor:
          if c not in cdir:
            cdir[c] = Image.new('1', pimg.size, 0)
          cdir[c].putpixel((x,y), 255)
    colors = cdir.keys()
    colors.sort()
    stencil = Image.new('1', (pimg.size[0]*len(colors), pimg.size[1]), 0)
    for (i, c) in enumerate(colors):
      stencil.paste(cdir[c], (pimg.size[0]*i, 0))
    return {'colors':colors, 'stencil':stencil}

このメソッドに画像データを放り込むと、ステンシル画像と、それの(デフォルトの)着色データを辞書の形式で返してくれる。

今まで書いたものをどうやってまとめるかは別に書かないが、これで「手描き画像」→「ステンシルデータ」までのデータ処理をするための一連のプログラム類を完成させた。

次はステンシルを実際のクリップアート画像に直す処理を書かなくてはいけないが、記事を分ける。