Docker + Alpine3.5 + Apache2.4 + Python3.6で、CGIのリダイレクトを使ってみた

以前、SSIを使ってみました。
Docker + Alpine3.5 + Apache2.4 + Python3.6で、SSIを使ってみた - メモ的な思考的な

今回はCGIのリダイレクトを使ってみます。

目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac12
  • Alpine3.5 + Apache2.4.25 + Python 3.6.1

 
なお、Dockerfileは以前のものを流用し、Dockerは以下のコマンドで利用しています。

## DockerfileからDockerイメージをビルド
$ docker image build -t alpine:python3_httpd24_redirect .

## Dockerコンテナを起動し、CGIのディレクトリをホストと共有
$ docker container run -p 8081:80 --name redirect -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_redirect

 
また、動作確認にはcurlを使います。
インターネットにアクセスしよう 番外編 : curlの使い方 - Cocoaはやっぱり!

 

Locationヘッダを使ったリダイレクト

今回はLocationヘッダを使ったリダイレクトを試します。

LocationヘッダのURLの仕様は、RFC7231にあり、

Location = URI-reference

7.1.2. Location | RFC 7231 — HTTP/1.1: Semantics and Content (日本語訳)

とのことです。

URI-referenceの定義は、RFC3986にあり、

URI-reference は、URI か相対的参照のどちらかである。 URI-reference の先頭がコロン分離記号を従えるスキームの構文に合致しなければ、URI-reference は相対的参照である。

4.1. URI 参照 | Uniform Resource Identifier (URI): 一般的構文 - RFC3986 日本語訳の複製

とのことです。

そのため、今回は相対的参照として、ホスト以下を記載します。

以上をもとに、

  • リダイレクト前:redirect.py
  • リダイレクト後:done_redirect.py

の2ファイルを用意します。

redirect.py

#!/usr/bin/python3

# HTTPレスポンスヘッダ
print('Location: /cgi-bin/done_redirect.py')
print('')

 
done_redirect.py

#!/usr/bin/python3

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8')
print('')

print('Hello')

 
curlLオプションでリダイレクトに対応し、vオプションで実際の往復を見てみます。

$ curl -L -v http://localhost:8081/cgi-bin/redirect.py
*   Trying ::1...
* Connected to localhost (::1) port 8081 (#0)
> GET /cgi-bin/redirect.py HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sat, 20 May 2017 21:22:27 GMT
< Server: Apache/2.4.25 (Unix)
< Transfer-Encoding: chunked
< Content-Type: text/plain;charset=utf-8
< 
Hello
* Connection #0 to host localhost left intact

リダイレクトが行われているようですが、ステータスコードが200になっていました。

 

NPHスクリプトによる、Locationヘッダを使ったリダイレクト

ステータスコードも自由に変更できる方法を探したところ、書籍「CGIプログラミング 第2版」の3章にApacheNPH (Non Parsed Headers)スクリプトを使う方法が紹介されていました*1

CGIプログラミング

CGIプログラミング

 
また、RFC3875などにも記載があります。

 
書籍ではPerlを使っていましたが、今回はPythonで書いてみます。

NPHスクリプトの場合、

  • ファイル名の接頭辞にnph-を付ける
  • 少なくとも、ステータス行、Conent-Typeヘッダ、Serverヘッダを出力する

とのことですので、以下のPythonスクリプトを書きました。

nph-redirect_hardcord.py

#!/usr/bin/python3

# HTTPレスポンスヘッダ
# NPHなので、ステータスラインも記述する
print('HTTP/1.1 302 Found')
print('Location: /cgi-bin/done_redirect.py')
print('Server: hoge')
print('')

 
curlにて確認します。

$ curl -L -v http://localhost:8081/cgi-bin/nph-redirect_hardcord.py
*   Trying ::1...
* Connected to localhost (::1) port 8081 (#0)
> GET /cgi-bin/nph-redirect_hardcord.py HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 302 Found
< Location: /cgi-bin/done_redirect.py
< Server: hoge
* no chunk, no close, no size. Assume close to signal end
< 
* Closing connection 0
* Issue another request to this URL: 'http://localhost:8081/cgi-bin/done_redirect.py'
* Hostname localhost was found in DNS cache
*   Trying ::1...
* Connected to localhost (::1) port 8081 (#1)
> GET /cgi-bin/done_redirect.py HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sat, 20 May 2017 22:14:46 GMT
< Server: Apache/2.4.25 (Unix)
< Transfer-Encoding: chunked
< Content-Type: text/plain;charset=utf-8
< 
Hello
* Connection #1 to host localhost left intact

302リダイレクトになりました。

 

NPHスクリプト + 環境変数による、Locationヘッダを使ったリダイレクト

上記のNPHスクリプトでは色々とハードコーディングしていたため、

を使うように変更します。

 
nph-redirect_environ.py

#!/usr/bin/python3

import os
# ステータスコードとメッセージはPythonの定数を使う
# https://docs.python.jp/3/library/http.html
from http import HTTPStatus

# CGIの環境変数からプロトコルとバージョン・ホストを取得する
protocol = os.environ.get('SERVER_PROTOCOL')
server = os.environ.get('SERVER_SOFTWARE')

# HTTPレスポンスヘッダ
print(f'{protocol} {HTTPStatus.FOUND.value} {HTTPStatus.FOUND.phrase}')
print('Location: /cgi-bin/done_redirect.py')
print(f'Server: {server}')
print('')

 
curlで確認します。

$ curl -L -v http://localhost:8081/cgi-bin/nph-redirect_environ.py

> GET /cgi-bin/nph-redirect_environ.py HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 302 Found
< Location: /cgi-bin/done_redirect.py
< Server: Apache/2.4.25 (Unix)
< 
> GET /cgi-bin/done_redirect.py HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sat, 20 May 2017 22:23:31 GMT
< Server: Apache/2.4.25 (Unix)
< Transfer-Encoding: chunked
< Content-Type: text/plain;charset=utf-8
< 
Hello

同じく、302リダイレクトになりました。

 

その他

NPHスクリプトを使う際、環境によっては以下のエラーが出るようです。
apache - Apache2 sends two HTTP headers with a mapped “nph-” CGI - Stack Overflow

 

ソースコード

GitHubに上げました。alpine_apache_python36_redirectディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

*1:旧版の英語版であれば、オライリーの「O'Reilly Open Books Project」により公開されています:http://www.oreilly.com/openbook/cgi/ch03_08.html

Google I/O 2017 Extended Live Viewing 信州に参加しました

5/17の深夜に開催された、Google I/O 2017 Extended Live Viewing 信州に参加しました。

久しぶりの参加であり、長野県でGoogle I/O 2017 Extendedが毎年開催されるのはありがたいと感じています。

 
信州会場は基調講演前から開場していたこともあり、他の参加者のみなさんと交流を深められ、楽しい時間を過ごせました。

基調講演のYoutube配信は英語 + 英語字幕でしたが、

というライフハックなどもあり、自分なりに楽しむことができました。英語のカンファレンスに参加すると、英語学習意欲の増加するので良いです。

 
その中でも、個人的には「Google Lens」と「Google Photo books」が気になり、またどのプレゼンターもAIを推していたのが印象に残りました。
photo books | Google Photos - All your photos organized and easy to find

なお、詳細については、すでに他のサイトで記載されているので省略します。

 
今後の予定としては、6/10(土)にGoogle I/O 報告会があります。
Google I/O 2017 報告会 信州会場 - connpass

例年同様、ゲストパネラーとして Google I/O参加のDeveloperExpertの方も参加されるようですので、いろいろなお話がうかがえそうです。

 
最後になりましたが、開催してくださった関係者のみなさま、ありがとうございました。

Docker + Alpine3.5 + Apache2.4 + Python3.6で、SSIを使ってみた

前回、フォームのデータをcgiモジュールを使って受け取りました。
Docker + Alpine3.5 + Apache2.4 + Python3.6で、フォームのデータを標準モジュールcgiで受け取ってみた - メモ的な思考的な

 
今回は、ApacheのSSI(Server Side Include)を、公式チュートリアルを見ながら試してみます。
Apache チュートリアル: Server Side Includes 入門 - Apache HTTP サーバ バージョン 2.4  
 
目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac12
  • Alpine3.5 + Apache2.4.25 + Python 3.6.1

 
なお、Dockerfileは前回のものを流用します。

また、コンソールで「Dockerのコンテナを起動する」とした場合は、以下のコマンドを入力しています。
参考:docker container / image コマンド新旧比較 - Qiita

# Docker container runしたのとは別のターミナルで実行
## Dockerのssiコンテナを停止
$ docker container stop ssi

# Docker container runしたターミナルで実行
## Dockerコンテナを削除
$ docker container rm $(docker container ls -a -q)
## もしくは起動していないDockerコンテナを全削除
$ docker container prune

## 削除したいDockerイメージのIDを知る
$ docker image ls
REPOSITORY          TAG                          IMAGE ID
alpine              python3_httpd24_ssi          658225783800

## Dockerイメージを削除
$ docker image rm 658225783800

## DockerfileからDockerイメージをビルド
$ docker image build -t alpine:python3_httpd24_ssi .


## Dockerコンテナを実行
### HTMLのみの場合、HTMLのディレクトリのみホストと共有
$ docker container run -p 8081:80 --name ssi -v `pwd`/htdocs/:/usr/local/apache2/htdocs alpine:python3_httpd24_ssi

### HTML & CGIの場合、HTML & CGIのディレクトリをホストと共有
$ docker container run -p 8081:80 --name ssi -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_ssi

 

AddOutputFilterを使って、shtmlファイルのみSSIを有効にする

まずは、SSIが動くか試してみます。

そのため、

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>SSIサンプル(Echo)</h1>
    <!--#echo var="DATE_LOCAL" -->
</body>
</html>

  • echo.html
  • echo.shtml

として2つ用意し、両方ともドキュメントルートの中に入れます。

ホスト上のパーミッションは以下の通りです。

$ ls -al
...
-rw-r--r--  1 you  staff  182  5 14 15:28 echo.html
-rw-r--r--  1 you  staff  182  5 14 15:15 echo.shtml

 
次に、httpd.confを修正します。

  • SSIを使うため、include_moduleをLoadModule
  • Directoryディレクティブに以下を追加
    • Options Includes
    • AddType text/html .shtml
    • AddOutputFilter INCLUDES .shtml

とします。

なお、Web上ではAddOutputFilter INCLUDESAddHandler server-parsedの2つの方法がありました。

Apacheのドキュメントによると、

とのことなので、2.4系の今回はAddOutputFilterを使います。

httpd.conf

...
LoadModule include_module modules/mod_include.so
...
DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    # shtmlファイルでSSIを使うための設定を追加
    Options Includes
    AddType text/html .shtml
    AddOutputFilter INCLUDES .shtml
</Directory>

 
Dockerコンテナを起動し、curlで確認します。

# htmlファイルではダメ
$ curl http://localhost:8081/echo.html
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    <!--#echo var="DATE_LOCAL" -->
</body>


# shtmlファイルでは、SSI動作
$ curl http://localhost:8081/echo.shtml
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 06:36:59 
</body>

 
shtmlファイルのみSSIが動作しました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_shtml_echo_using_outputfilter

 

AddOutputFilterを使って、shtml・htmlファイルでSSIを有効にする

htmlファイルでもSSIが動作するよう、httpd.confにてhtmlをAddOutputFilterへ追加します。

httpd.conf

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    Options Includes
    AddType text/html .shtml
    # AddOutputFilterに.htmlを追加
    AddOutputFilter INCLUDES .shtml .html
</Directory>

 
ホストのパーミッションには変更ありません。

$ ls -al
-rw-r--r--  1 you  staff  182  5 14 15:28 echo.html
-rw-r--r--  1 you  staff  182  5 14 15:15 echo.shtml

 
Dockerコンテナを起動し、curlで確認します。

# shtmlファイル
$ curl http://localhost:8081/echo.shtml
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 06:45:58 
</body>

# htmlファイル
$ curl http://localhost:8081/echo.html
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 06:46:01 
</body>

 
shtml・htmlファイルでSSIが動作しました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_html_echo_using_outputfilter

 

XBitHack onを使って、htmlファイルのみでSSIを有効にする

AddOutputFilter以外の方法を探したところ、公式チュートリアルXBitHack onを使う方法が記載されていました。

XBitHack は、ファイルの実行ビットが立っている場合、 SSI ディレクティブにより解析することを Apache に伝えます。 従って、SSI ディレクティブを現在のページに加えるためには、 ファイル名を変更しなくてもよく、単に chmod を使用してファイルを実行可能にするだけで済みます。

SSI を許可するためのサーバの設定 | Apache チュートリアル: Server Side Includes 入門 - Apache HTTP サーバ バージョン 2.4

 
httpd.confを変更し、OptionsとXBitHackを使うようにします。

httpd.conf

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    Options Includes
    # XBitHackをonにする
    XBitHack on
</Directory>

 
Dockerコンテナを起動し、curlで確認します。

まずはパーミッションがそのままの場合です。

$ curl http://localhost:8081/echo.shtml
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    <!--#echo var="DATE_LOCAL" -->
</body>

$ curl http://localhost:8081/echo.html
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    <!--#echo var="DATE_LOCAL" -->
</body>

いずれもSSIは動作しませんでした。

 
続いて、ホストのパーミッションを変更します。

$ chmod +x echo.html 
$ chmod +x echo.shtml

$ ls -al
-rwxr-xr-x  1 you  staff  182  5 14 15:28 echo.html
-rwxr-xr-x  1 you  staff  182  5 14 15:15 echo.shtml

 
念のため、Dockerコンテナにあるファイルのパーミッションも確認します。

$ docker exec -it `docker ps | grep ssi | awk '{print $1}'` /bin/bash
bash-4.3# ls ./htdocs -al
...
-rwxr-xr-x    1 root     root           182 May 14 06:47 echo.html
-rwxr-xr-x    1 root     root           182 May 14 06:47 echo.shtml

ホストと同期されています。

 
パーミッションが変更されていたので、curlにて確認します。

$ curl http://localhost:8081/echo.shtml
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    <!--#echo var="DATE_LOCAL" -->
</body>

$ curl http://localhost:8081/echo.html
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 07:06:21 
</body>

htmlファイルのみSSIが有効になりました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_html_cgi_using_xbithack_on

 

XBitHack onを使って、html・shtmlファイルでSSIを有効にする

XBitHackディレクティブのドキュメントを見ると、

on

ユーザの実行ビットが設定されている text/html ファイルは全てサーバで解析する html ドキュメントとして扱われます。

XBitHack ディレクティブ | mod_include - Apache HTTP サーバ バージョン 2.4

との記載がありました。

そのため、AddTypeshtmlを追加すれば、shtmlでも有効になると考えられたため、試してみました。

httpd.conf

# DocumentRoot
DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    Options Includes
    # text/htmlに、.shtmlファイルも追加する
    AddType text/html .shtml
    XBitHack on
</Directory>

 
パーミッションは両方とも実行可能です。

$ ls -al htdocs/
drwxr-xr-x  4 you  staff  136  5 14 16:08 .
drwxr-xr-x  7 you  staff  238  5 14 16:08 ..
-rwxr-xr-x  1 you  staff  182  5 14 16:08 echo.html
-rwxr-xr-x  1 you  staff  182  5 14 16:08 echo.shtml

 
Dockerコンテナを起動し、curlで確認します。

$ curl http://localhost:8081/echo.shtml
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 07:15:58 
</body>

$ curl http://localhost:8081/echo.html
...
<body>
    <h1>SSIサンプル(Echo)</h1>
    Sunday, 14-May-2017 07:16:03 
</body>

両方ともSSIが動作しました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_shtml_echo_using_xbithack_on

 

XBitHack onを使って、CGIを動かす

SSIの動作が確認できたため、今度はCGIを動かしてみます。

httpd.confにCGIの記述を追加します。

httpd.conf

# CGIを使うので、alias・cgidモジュールを追加
LoadModule alias_module modules/mod_alias.so
LoadModule cgid_module modules/mod_cgid.so

# CGIを使うための設定
# alias_module settings
ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/"
# CGI directory
<Directory "/usr/local/apache2/cgi-bin">
    AllowOverride None
    Options ExecCGI
    SetHandler cgi-script
    Require all granted
</Directory>

 
続いて、標準出力に現在時刻を出すCGIPythonスクリプトを作成します。

now.py

#!/usr/bin/python3
import datetime

# HTTPヘッダ
print('Content-Type: text/plain;charset=utf-8')
print('')

print(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

if __name__ == '__main__':
    pass

 
確認用のHTMLファイルも作成します。

CGIが正しく動作しているかを確認するため、JavaScriptでも現在時刻を取得します。PythonUTCJavaScriptJSTタイムゾーンが異なりますが、今回は確認するだけなのでそのままとします*2

cgi.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>SSIサンプル(CGI)</h1>
    <p>CGIの時間(UTC):<!--#include virtual="/cgi-bin/now.py" --></p>
    <p>JSの時間(JST) :<span id="js_time"></span></p>

    <script>
        document.getElementById("js_time").innerHTML = function(){
            var now = new Date();
            var year = now.getFullYear();
            var mon = now.getMonth() + 1;
            var day = now.getDate();
            var hour = now.getHours();
            var min = now.getMinutes();
            var sec = now.getSeconds();

            return `${year}-${mon}-${day} ${hour}:${min}:${sec}`
        }();
    </script>
</body>
</html>

 
また、htmlとPythonスクリプトを実行可能にします。

$ cd ../ssi_html_cgi_using_xbithack_on/

# 両方とも実行可能に変更
$ chmod +x cgi/now.py 
$ chmod +x htdocs/cgi.html 

# それぞれのファイルのパーミッションを確認
$ ls -Ral
...

./cgi:
-rwxr-xr-x  1 you  staff  179  5 14 16:23 now.py

./htdocs:
-rwxr-xr-x  1 you  staff  194  5 14 16:23 cgi.html

 
Dockerコンテナを起動します。CGI用のディレクトリもホストと共有します。

$ docker container run -p 8081:80 --name ssi -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_ssi

 
ブラウザでhttp://localhost:8081/cgi.htmlへアクセスして確認します。

SSIサンプル(CGI)

CGIの時間(UTC):2017-05-14 08:43:18

JSの時間(JST) :2017-5-14 17:43:18

 
再度アクセスします。

SSIサンプル(CGI)

CGIの時間(UTC):2017-05-14 08:43:58

JSの時間(JST) :2017-5-14 17:43:58

更新されているようです。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_shtml_echo_using_xbithack_on

 

XBitHack fullを使って、CGIを動かす

XBitHackディレクティブの公式ドキュメントを見ると、

注意 他の CGI を #include するかもしれないものや、各アクセスに対して違う出力を生成する (もしくは後のリクエストで変わるかもしれないもの) すべての SSI スクリプトに対してグループ実行ビットが 設定されていないことを確認できない場合は、full は使わない方が良い でしょう。

XBitHack ディレクティブ | mod_include - Apache HTTP サーバ バージョン 2.4

とあったため、試してみます。

 
httpd.confでXBitHackをfullに修正します。

httpd.conf

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    Options Includes
    # XBitHackを変更
    XBitHack full
</Directory>

 
Dockerコンテナを起動し、ブラウザでhttp://localhost:8081/cgi.htmlへアクセスして確認します。

SSIサンプル(CGI)

CGIの時間(UTC):2017-05-14 08:46:52

JSの時間(JST) :2017-5-14 17:46:52

 
再度アクセスします。

SSIサンプル(CGI)

CGIの時間(UTC):2017-05-14 08:46:52

JSの時間(JST) :2017-5-14 17:47:8

 
CGIの時間が初回アクセス時と同一のため、キャッシュが使われているようです。

よって、ドキュメントにもある通り、CGIを使う場合にはXBitHack onの方が良さそうです。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_html_cgi_using_xbithack_full

 

CGIを動かす際、includeやexecなどいくつかのパターンを試す

Apacheの公式ドキュメントを読むと、CGIPythonスクリプトを動かすには、

  • include virtual
  • exec cgi
  • exec cmd
  • exec cmd python3

などが使えそうでしたので、試してみます。
基本要素 | mod_include - Apache HTTP サーバ バージョン 2.4

 
HTMLにはそれぞれの動かし方と、PythonスクリプトでのContent-Type出力の有無のパターンを記載します。

cgi.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>SSIサンプル(CGI: XBitHack on)</h1>
    <h2>CGIにcontent-typeあり</h2>
    <ul>
        <li>include virtual: <!--#include virtual="/cgi-bin/with_content_type.py" --></li>
        <li>exec cgi: <!--#exec cgi="/cgi-bin/with_content_type.py" --></li>
        <li>exec cmd: <!--#exec cmd="/usr/local/apache2/cgi-bin/with_content_type.py" --></li>
        <li>exec cmd python3: <!--#exec cmd="python3 /usr/local/apache2/cgi-bin/with_content_type.py" --></li>
    </ul>
    <h2>CGIにcontent-type無し</h2>
    <ul>
        <li>include virtual: <!--#include virtual="/cgi-bin/witout_content_type.py" --></li>
        <li>exec cgi: <!--#exec cgi="/cgi-bin/witout_content_type.py" --></li>
        <li>exec cmd: <!--#exec cmd="/usr/local/apache2/cgi-bin/witout_content_type.py" --></li>
        <li>exec cmd python3: <!--#exec cmd="python3 /usr/local/apache2/cgi-bin/witout_content_type.py" --></li>
    </ul>
</body>
</html>

 
Pythonスクリプトでは、Content-Typeを出すものと出さないものを用意します。

with_content_type.py

#!/usr/bin/python3
import datetime

# HTTPヘッダ
# SSIで「include virtual」する時はHTTPヘッダが必要
print('Content-Type: text/plain;charset=utf-8')
print('')

print(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

 
witout_content_type.py

#!/usr/bin/python3
import datetime

print(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

 
Dockerコンテナを起動し、ブラウザでhttp://localhost:8081/cgi.htmlへアクセスして確認します。

CGIにcontent-typeあり

・include virtual: 2017-05-14 09:49:46
・exec cgi: 2017-05-14 09:49:46
・exec cmd: Content-Type: text/plain;charset=utf-8 2017-05-14 09:49:46
・exec cmd python3: Content-Type: text/plain;charset=utf-8 2017-05-14 09:49:46

CGIにcontent-type無し

・include virtual: [an error occurred while processing this directive]
・exec cgi:
・exec cmd: 2017-05-14 09:49:46
・exec cmd python3: 2017-05-14 09:49:47

エラーが発生したり、表示されないものがありました。Dockerのログも見てみます。

[cgid:error] Premature end of script headers: witout_content_type.py
[include:error] unable to include "/cgi-bin/witout_content_type.py" in parsed file /usr/local/apache2/htdocs/cgi.html, subrequest returned 500
[cgid:error] Premature end of script headers: witout_content_type.py

 
これより、

  • Content-Typeが必要なパターン
    • include virtual, exec cgi
  • Content-Typeは不要なパターン
    • exec cmd, exec cmd python3

と分かりました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_options_includes_using_xbithack_on

 

CGIを動かす際、OptionsディレクティブでIncludesNOEXECを使う

今までOptionsディレクティブはIncludesを指定してきました。

ただ、SSIではIncludesNOEXECという指定もできます。
Options ディレクティブ | core - Apache HTTP サーバ バージョン 2.4

ドキュメントによると、include virtualだけが使えるようですので、試してみます。

 
httpd.confを修正します。

httpd.conf

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    AllowOverride None
    Require all granted

    # SSIを使うための設定
    # .htmlファイルでSSIを使えるようにするが、
    # include virtualのみ有効にする
    Options IncludesNOEXEC
    XBitHack on
</Directory>

 
Dockerコンテナを起動し、ブラウザでhttp://localhost:8081/cgi.htmlへアクセスして確認します。

CGIにcontent-typeあり

・include virtual: 2017-05-14 09:51:38
・exec cgi: [an error occurred while processing this directive]
・exec cmd: [an error occurred while processing this directive]
・exec cmd python3: [an error occurred while processing this directive]

CGIにcontent-type無し

・include virtual: [an error occurred while processing this directive]
・exec cgi: [an error occurred while processing this directive]
・exec cmd: [an error occurred while processing this directive]
・exec cmd python3: [an error occurred while processing this directive]

 
SSIで動かす時にinclude virtual以外ではエラーが出ています。

Dockerのログも見ます。

[cgid:error] AH01271: exec used but not allowed in /usr/local/apache2/htdocs/cgi.html
[cgid:error] AH01271: exec used but not allowed in /usr/local/apache2/htdocs/cgi.html
[cgid:error] Premature end of script headers: witout_content_type.py
[include:error] unable to include "/cgi-bin/witout_content_type.py" in parsed file /usr/local/apache2/htdocs/cgi.html, subrequest returned 500
[cgid:error] AH01271: exec used but not allowed in /usr/local/apache2/htdocs/cgi.html
[cgid:error] AH01271: exec used but not allowed in /usr/local/apache2/htdocs/cgi.html
[cgid:error] AH01271: exec used but not allowed in /usr/local/apache2/htdocs/cgi.html

ドキュメント通り、include virtualだけが正常に動作しました。

ソースコード全体は以下です。
https://github.com/thinkAmi-sandbox/Docker_Apache-sample/tree/master/alpine_apache_python36_ssi/ssi_options_includes_noexec_using_xbithack_on

 

ソースコード

GitHubに上げました。alpine_apache_python36_ssiディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

*1:Apache公式のが見当たらなかったため、Oracleのサイトにあるものをリンクしました

*2:Alpineのタイムゾーンを修正すれば良いかと思いますが、手間だったので対応せず…

Docker + Alpine3.5 + Apache2.4 + Python3.6で、フォームのデータを標準モジュールcgiで受け取ってみた

前回、フォームのデータをCGIPythonスクリプトsys.stdin.read()os.environを使って受け取りました。
Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをCGIで受け取ってみた - メモ的な思考的な

 
今回は標準モジュールcgiにあるFieldStorage使ってデータを受け取ってみます。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac5
  • Alpine3.5 + Apache2.4.25 + Python 3.6.1

なお、Dockerfileは前回のものを流用します。

HTMLファイルは前回のものを流用し、actionだけを対象のPythonスクリプトに差し替えます。

Dockerまわりのコマンドは以下の通りです。

# Docker上のApacheでCGIとして動かすために、Dockerと共有するローカルファイルのパーミッションを変更
$ chmod 755 cgi_module.py 
$ chmod 755 cgi_file_upload.py
$ chmod 755 cgi_file_upload_with.py

# Dockerイメージのビルド
$ docker image build -t alpine:python3_httpd24_cgi_module .

# Dockerコンテナの起動
$ docker container run -p 8081:80 --name cgi_module -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_cgi_module

 

cgi.FieldStorageを使ったフォームデータの受け取り

データの読み込み

cgi.FieldStorageインスタンスを生成することで、標準入力などからデータを読み込めます。

cgi_module.py

#!/usr/bin/python3
import cgi

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print('')

form = cgi.FieldStorage()
print(form)
# => FieldStorage(None, None, [MiniFieldStorage('quantity', '1個'), 
#                              MiniFieldStorage('hidden_valude', '隠しデータ')])

 
また、フォームに入力していない要素も取得したい場合は、

form = cgi.FieldStorage(keep_blank_values=True)
print(form)
# => FieldStorage(None, None, [MiniFieldStorage('subject', ''), 
#                              MiniFieldStorage('quantity', '1個'), 
#                              MiniFieldStorage('hidden_valude', '隠しデータ'), 
#                              MiniFieldStorage('memo', '')])

と、FieldStorageのインスタンス生成時の引数としてkeep_blank_values=Trueを指定します。

ただ、上記を見る限り、未選択のcheckboxやradio button、select multipleは取得できないようです。

 
なお、FieldStorageのインスタンス生成については、公式ドキュメントにある

標準入力または環境変数からフォームの内容を読み出します (どちらから読み出すかは、複数の環境変数の値が CGI 標準に従ってどのように設定されているかで決まります)。インスタンスが標準入力を使うかもしれないので、インスタンス生成を行うのは一度だけにしなければなりません。

21.2. cgi — CGI (ゲートウェイインタフェース規格) のサポート — Python 3.6.1 ドキュメント

という点に注意します。

 
また、FieldStorageインスタンス生成後にsys.stdin.read()を使うと、

form = cgi.FieldStorage()

print('stdin:\n{}'.format(sys.stdin.read()))
# => stdin:

と、何も取得できません。

 
一方(今回のケースだけかもしれませんが)、os.environについては

form = cgi.FieldStorage()

print('os.environ:\n')
for k, v in os.environ.items():
    print('{}: {}'.format(k, v))
# =>
# HTTP_HOST: localhost:8081
# HTTP_CONNECTION: keep-alive
# ...

と、環境変数の値が取得できました。

 

FieldStorageの属性やメソッド

属性やメソッドを使ってみると、

form = cgi.FieldStorage()

print(form.type)
# => application/x-www-form-urlencoded

print(form.headers)
# => {'content-type': 'application/x-www-form-urlencoded', 'content-length': '94'}

print(form.keys())
# => ['quantity', 'memo', 'hidden_valude', 'subject']

となりました。

 

フォームのフィールド値の取得

例えば、「自分で持ち帰る」チェックボックスにチェックを入れた場合、

form = cgi.FieldStorage()

print(form['takeout'])
# => MiniFieldStorage('takeout', '自分で持ち帰る')

print(form['takeout'].value)
# => 自分で持ち帰る

と、辞書の添字アクセスと同じようにして、フィールドの値を取得できます。

 
なお、添字アクセスで存在しないキーを指定した場合、

form = cgi.FieldStorage()

try:
    print(form['foo'])
except:
    print('not found: foo')
# => not found: foo

と、例外が出ます。

 
そのため、getvalue()メソッドを使うことで、

form = cgi.FieldStorage()
print(form.getvalue('foo'))
# => None
print(form.getvalue('foo', 'default_value'))
# => default_value

と、例外を回避したり、デフォルト値を返すことができます。

 

同じnameを持つ複数フィールド値の取得

チェックボックスなど、複数のフィールドで同じnameを持つ場合は

form = cgi.FieldStorage()

print(form.getfirst('purpose'))
# => 贈り物にする

print(form.getlist('purpose'))
# => ['贈り物にする', '自家用にする']

のように、getfirst()で最初のものだけ取得したり、getlist()でリストとして取得します。

 
なお、nameが一つだけの場合にgetlist()メソッドを使うと、

form = cgi.FieldStorage(keep_blank_values=True)

print(form.getlist('takeout'))
# => ['自分で持ち帰る']

と、要素数1のリストとなります。

 

cgi.FieldStorageを使ったアップロードファイルの受け取り

ファイルアップロードができるフォームを

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>ファイルアップロードサンプル(一気に読み込み)</h1>
    <form action="/cgi-bin/cgi_file_upload.py" method="POST" enctype="multipart/form-data">
        <!--input text-->
        <label for="id_upload_file">対象ファイル</label>
        <input type="file" id="id_upload_file" name="upload_file">

        <!--submit-->
        <p>
            <input type="submit">
        </p>
    </form>
</body>

</html>

と用意した場合も、cgi.FieldStorageでアップロードしたファイルのデータを扱えます。

 
例えば、

upload_file.txt

あ
a

というファイルを扱う場合、

cgi_file_upload.py

#!/usr/bin/python3
import cgi

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print('')

form = cgi.FieldStorage()
print(form)
# => FieldStorage(None, None, [FieldStorage('upload_file', 'upload_file.txt', b'\xe3\x81\x82\na')])

とすることで、FieldStorageのインスタンスとして取得できます。

 
FieldStorageインスタンスからファイルの中身を一気に読み込む場合、

form = cgi.FieldStorage()

content = form.getvalue('upload_file')

print(content.__class__)
# => <class 'bytes'>

print(content.decode('utf-8'))
# =>
# あ
# a

と、getvalue()メソッドでバイト文字列を取得し、それをdecode()にて文字列にします。

 
また、一気に読み込むのが難しい場合は、一定のバイト数で区切って読み込むこともできます。

cgi_file_upload_with.py

form = cgi.FieldStorage()
upload_file = form['upload_file']

print(upload_file)
# => FieldStorage('upload_file', 'upload_file.txt', b'\xe3\x81\x82\na')

print(dir(upload_file))
# => ['FieldStorageClass', '_FieldStorage__file', '_FieldStorage__write', '__bool__', 
#     '__class__', '__contains__', '__del__', '__delattr__', '__dict__', '__dir__', 
#     '__doc__', '__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattr__',
#     '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__',
#     '__iter__', '__le__', '__len__', '__lt__', '__module__', '__ne__', '__new__',
#     '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', 
#     '__subclasshook__', '__weakref__', '_binary_file', 'bufsize', 'bytes_read', 
#     'disposition', 'disposition_options', 'done', 'encoding', 'errors', 'file', 
#     'filename', 'fp', 'getfirst', 'getlist', 'getvalue', 'headers', 'innerboundary',
#     'keep_blank_values', 'keys', 'length', 'limit', 'list', 'make_file', 'name', 
#     'outerboundary', 'qs_on_post', 'read_binary', 'read_lines', 'read_lines_to_eof', 
#     'read_lines_to_outerboundary', 'read_multi', 'read_single', 'read_urlencoded', 
#     'skip_lines', 'strict_parsing', 'type', 'type_options']

print(upload_file.name)
# => upload_file

print(upload_file.filename)
# => upload_file.txt

print(upload_file.file)
#=> <_io.BytesIO object at 0x7fe34bba03b8>

# with文にも対応しているとのことなので、試してみる
# http://docs.python.jp/3/library/cgi.html
with upload_file as f:
    content = b''
    while True:
        # 1バイトずつ読み込む場合
        byte_strings = f.file.read(1)
        print(byte_strings)
        # => b'\xe3', b'\x81', b'\x82', b'\n', b'a', b'' の順に読み込まれる

        if not byte_strings:
            break
        content += byte_strings

print(content.decode('utf-8'))
# あ
# a

 

ソースコード

GitHubに上げました。alpine_apache_python36_cgi_moduleディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをCGIで受け取ってみた

以前、DockerでCGIを動かしてみました。
Dockerで、Alpine3.4 + Apache2.4.25 + Python3.6.0の環境を作って、CGIを動かしてみた - メモ的な思考的な

 
今回は、Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをPythonCGIで受け取ってみます。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac5
  • Alpine3.5
    • httpd:2.4.25-alpineをベースに、Python3をAlpineのパッケージapkでインストールしたもの

 

Dockerfile作成

以前は「Python3.6のAlpine版のイメージ + ApacheをセットアップするDockerfile」という構成でした。

ただ、Alpine3.4縛りのせいとはいえ、ApacheをセットアップするDockerfileがほぼコピペだったので、あまり良くないと感じていました。

 
他の方法を探してみたところ、Alpine3.5にapkのPython3をインストールしているDockerfileがありました。
frol/docker-alpine-python3: The smallest Docker image with Python 3.5 (~61MB)

 
そこで、httpd:2.4.25-alpineをベースに、apkのPython3をインストールするDockerfileを作成しました。

なお、Apacheのconfファイルは前回のものを流用します。

Dockerfile

FROM httpd:2.4.25-alpine

RUN apk --update --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/main/ add python3 && \
    python3 -m ensurepip && \
    rm -r /usr/lib/python*/ensurepip && \
    pip3 install --upgrade pip setuptools && \
    rm -r /root/.cache

# ローカルのhttpd.confをコピー
COPY httpd.conf /usr/local/apache2/conf/

 

HTMLフォームの作成

ひと通りのフォーム要素を持つHTMLを用意します。

また、GETだけではなくPOSTも試したかったので、formのmethodだけを変えたHTMLも用意します。

htdocs/form_get_stdin.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>フォームサンプル(GET)</h1>
    <form action="/cgi-bin/stdin_environ.py" method="GET">
        <!--input text-->
        <label for="id_subject">件名</label>
        <input type="text" id="id_subject" name="subject">

        <!--radio button-->
        <p>
            <label for="id_apple">リンゴ</label>
            <input id="id_apple" type="radio" name="fruit" value="りんご">
            <label for="id_mandarin">ミカン</label>
            <input id="id_mandarin" type="radio" name="fruit" value="みかん">
            <label for="id_grape">ブドウ</label>
            <input id="id_grape" type="radio" name="fruit" value="ぶどう">
        </p>
        <p>
            <label for="id_big"></label>
            <input id="id_big" type="radio" name="fruit_size" value="大きいもの">
            <label for="id_small"></label>
            <input id="id_small" type="radio" name="fruit_size" value="小さいもの">
        </p>

        <!--select-->
        <p>
            <label for="id_quantity">個数</label>
            <select id="id_quqntity" name="quantity">
                <option id="id_selected1_1" name="select1_1" value="1個">1</option>
                <option id="id_selected1_2" name="select1_2" value="2個">2</option>
                <option id="id_selected1_3" name="select1_3" value="3個">3</option>
            </select>
        </p>

        <!--select multiple-->
        <p>
            <label for="id_accessories">付属品</label>
            <select id="id_accessories" name="accessories" multiple>
                <option id="id_selected2_1" name="select2_1" value="紙袋">紙袋</option>
                <option id="id_selected2_2" name="select2_2" value="容器">容器</option>
                <option id="id_selected2_3" name="select2_3" value="紐"></option>
            </select>
        </p>

        <!--checkbox-->
        <p>
            <label for="id_takeout">持ち帰る</label>
            <input type="checkbox" id="id_takeout" name="takeout" value="自分で持ち帰る">
        </p>
        <p>
            <label for="id_gift">贈り物</label>
            <input type="checkbox" id="id_gift" name="gift" value="贈り物にする">
        </p>

        <!--hidden-->
        <input type="hidden" id="id_hidden_value" name="hidden_valude" value="隠しデータ">

        <!--textare-->
        <label for="id_memo">メモ</label>
        <textarea id="id_memo" name="memo"></textarea>

        <!--submit-->
        <p>
            <input type="submit">
        </p>
    </form>
</body>

</html>

POST用のHTML(htdocs/form_get_stdin.html)は省略します。

 

CGI用のPythonスクリプトを作成

Apacheのドキュメントによると、フォームのデータは環境変数と標準入力(STDIN)に設定されます。
裏で何が起こっているのか? | Apache Tutorial: CGI による動的コンテンツ - Apache HTTP サーバ バージョン 2.4

 
そこで今回は、環境変数と標準入力の値をブラウザへと返すようにしてみます。

なお、Pythonでは、

  • 環境変数の値: os.environ辞書
  • 標準入力の値: sys.stdin.read()

でそれぞれ取得できます。

cgi/stdin_environ.py

#!/usr/bin/python3
# shebangに指定するPython3を以下で確認
# bash-4.3# which python3
# /usr/bin/python3

import os
import sys

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print("\n")

# HTTPレスポンスボディ
# 標準入力
print('-'*20)
print('stdin:\n{}'.format(sys.stdin.read()))

# 環境変数
print('-'*20)
print('os.environ:\n')
for k, v in os.environ.items():
    print('{}: {}'.format(k, v))

 

CGI用のPythonスクリプトパーミッションを変更

今回、HTMLやPythonスクリプトは Dockerの-vオプションを使用して、ホストとコンテナで共有します。

ただ、Dockerfileでは共有した時のパーミッションをうまく設定できませんでした(後述)。

そこで今回は、ホストのPythonスクリプトパーミッションを変更します。これにより、DockerコンテナとPythonスクリプトを共有しても、同じパーミッションになります。

# 変更前のMac上のパーミッション
$ ls -al
-rw-r--r--   1 you  staff  513  5 10 05:46 stdin_environ.py

# 実行可能へと変更
$ chmod 755 stdin_environ.py 

# 変更後のMac上のパーミッション
$ ls -al
-rwxr-xr-x   1 you  staff  513  5 10 05:46 stdin_environ.py

 

Dockerの起動

ローカルとDockerでファイルを共有するため、-vオプションを使用します。

今回はhtmlファイルのディレクトリとCGIPythonディレクトリの2つを共有するため、-vオプションを2つ使ってそれぞれ指定します。
Mounting multiple volumes on a docker container? - Stack Overflow

ホスト コンテナ
htdocs/ /usr/local/apache2/htdocs
cgi/ /usr/local/apache2/cgi-bin/

 
実際に入力する内容は以下の通りです。

# ビルド
$ docker build -t alpine:python3_httpd24_cgi_form .

# 起動
$ docker run -p 8081:80 --name cgi_form -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_cgi_form

 
コンテナのパーミッションも確認します。

# コンテナに入る
$ docker exec -it `docker ps | grep cgi_form | awk '{print $1}'` /bin/bash

# カレントディレクトリの確認
bash-4.3# pwd
/usr/local/apache2

# カレントディレクトリのパーミッション
bash-4.3# ls -al
total 40
drwxr-xr-x    1 www-data www-data      4096 Mar  3 21:57 .
drwxr-xr-x    1 root     root          4096 Mar  3 21:57 ..
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 bin
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 build
drwxr-xr-x    4 root     root           136 May  9 20:47 cgi-bin
drwxr-xr-x    1 root     root          4096 May  9 20:53 conf
drwxr-xr-x    3 root     root          4096 Mar  3 21:57 error
drwxr-xr-x    5 root     root           170 May  9 20:46 htdocs
drwxr-xr-x    3 root     root          4096 Mar  3 21:57 icons
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 include
drwxr-xr-x    1 root     root          4096 May  9 20:54 logs
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 modules

# cgi-binディレクトリの中にある「stdin_environ.py」のパーミッションを確認
bash-4.3# cd cgi-bin/

bash-4.3# ls -al
-rwxr-xr-x    1 root     root           513 May  9 20:46 stdin_environ.py

ホストと同じパーミッションが設定されていました。

 

フォームでGET

http://localhost:8081/form_get_stdin.htmlにアクセスし、以下のようにフォームへ入力します。

f:id:thinkAmi:20170510210628p:plain:w300

 
送信ボタンを押したあとの結果は以下の通りです。

環境変数のみ値が設定されています。

--------------------
stdin:

--------------------
os.environ:

HTTP_HOST: localhost:8081
HTTP_CONNECTION: keep-alive
HTTP_UPGRADE_INSECURE_REQUESTS: 1
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
HTTP_ACCEPT: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
HTTP_REFERER: http://localhost:8081/form_get_stdin.html
HTTP_ACCEPT_ENCODING: gzip, deflate, sdch, br
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.8,en;q=0.6
PATH: /usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SERVER_SIGNATURE: 
SERVER_SOFTWARE: Apache/2.4.25 (Unix)
SERVER_NAME: localhost
SERVER_ADDR: 172.17.0.2
SERVER_PORT: 8081
REMOTE_ADDR: 172.17.0.1
DOCUMENT_ROOT: /usr/local/apache2/htdocs
REQUEST_SCHEME: http
CONTEXT_PREFIX: /cgi-bin/
CONTEXT_DOCUMENT_ROOT: /usr/local/apache2/cgi-bin/
SERVER_ADMIN: you@example.com
SCRIPT_FILENAME: /usr/local/apache2/cgi-bin/stdin_environ.py
REMOTE_PORT: 59532
GATEWAY_INTERFACE: CGI/1.1
SERVER_PROTOCOL: HTTP/1.1
REQUEST_METHOD: GET
QUERY_STRING: subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
REQUEST_URI: /cgi-bin/stdin_environ.py?subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
SCRIPT_NAME: /cgi-bin/stdin_environ.py

 

フォームでPOST

http://localhost:8081/form_post_stdin.htmlにアクセスし、同じようにフォームに入力し、送信ボタンを押します。

POSTでは環境変数と標準入力に値が設定されています。

--------------------
stdin:
subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
--------------------
os.environ:

HTTP_HOST: localhost:8081
HTTP_CONNECTION: keep-alive
CONTENT_LENGTH: 444
HTTP_CACHE_CONTROL: max-age=0
HTTP_ORIGIN: http://localhost:8081
HTTP_UPGRADE_INSECURE_REQUESTS: 1
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
CONTENT_TYPE: application/x-www-form-urlencoded
HTTP_ACCEPT: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
HTTP_REFERER: http://localhost:8081/form_post_stdin.html
HTTP_ACCEPT_ENCODING: gzip, deflate, br
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.8,en;q=0.6
PATH: /usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SERVER_SIGNATURE: 
SERVER_SOFTWARE: Apache/2.4.25 (Unix)
SERVER_NAME: localhost
SERVER_ADDR: 172.17.0.2
SERVER_PORT: 8081
REMOTE_ADDR: 172.17.0.1
DOCUMENT_ROOT: /usr/local/apache2/htdocs
REQUEST_SCHEME: http
CONTEXT_PREFIX: /cgi-bin/
CONTEXT_DOCUMENT_ROOT: /usr/local/apache2/cgi-bin/
SERVER_ADMIN: you@example.com
SCRIPT_FILENAME: /usr/local/apache2/cgi-bin/stdin_environ.py
REMOTE_PORT: 59536
GATEWAY_INTERFACE: CGI/1.1
SERVER_PROTOCOL: HTTP/1.1
REQUEST_METHOD: POST
QUERY_STRING: 
REQUEST_URI: /cgi-bin/stdin_environ.py
SCRIPT_NAME: /cgi-bin/stdin_environ.py

 

その他悩んだこと

Dockerfileの中で、共有ディレクトリのパーミッションを設定する方法

前述のとおり、今回はホストとコンテナの共有ディレクトリのパーミッションは、ホストのパーミッションを変更することで対応しました。

なお、ホストのパーミッションを変更しなかった場合、Dockerのログに以下が出力されるとともに、ブラウザに「Internal Server Error」が表示されました。

[pid 97:tid 140512665647944] (13)Permission denied: AH01241: exec of '/usr/local/apache2/cgi-bin/stdin_environ.py' failed
[pid 12:tid 140512664455856] [client 172.17.0.1:59462] End of script output before headers: stdin_environ.py, referer: http://localhost:8081/form_get_stdin.html

 
以下の方法を試してみましたが、うまくいきませんでした。

 
ちなみに、Alpine3.4では useraddusermodが無いとのことです。
Alpine Linuxでユーザやグループを追加・修正・削除する - 水底

 

ソースコード

GitHubに上げました。alpine_apache_python36_cgi_formディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

Pythonで、unittest.mock.patchを使ってデコレータを差し替える

同僚と話している中で、unittest.mock.patchを使ったデコレータの差し替えに関する話題が出ました。

そういえばデコレータは差し替えたことがなかったため、試してみたことをメモします。

なお、「テストファイル群に、デコレータを差し替える/差し替えないものが混在している場合」で使った方法は強引な気がします。そのため、もしより良い方法をご存じであればご指摘ください。

 
目次

 

環境

  • Python 3.6.1
  • pytest 3.0.7
    • テストランナーとして使用

 

用意したデコレータとプロダクションコード

デコレータとは、関数に処理を追加するためのシンタックスシュガー(糖衣構文)です。

以下が参考になりますので、デコレータの詳細は省略します。

 
今回用意するデコレータは、

  • 引数なしのデコレータ (@countup, @countdown)
  • 位置引数ありのデコレータ (@add)
  • 位置引数とキーワード引数ありのデコレータ (@calculate)

です。

また、functools.wrapsを使っていますが、使う意味や使い方などは以下が参考になりました。
[python]デコレータでfunctools.wrap()を使う - logging.info(self)

 
デコレータのソースコードはこんな感じです。

deco/my_decorator.py

from functools import wraps

def countup(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result + 1
    return wrapper


def countdown(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result - 1
    return wrapper


def add(decorator_arg):
    """デコレータadd(@addとして使う)と、デコレータの引数decorator_arg"""

    def decorate(function):
        """デコレート対象の関数を引数fuctionとして受け取る、関数"""

        @wraps(function)
        def wrapped(*args, **kwargs):
            """デコレート対象の関数の引数をargsやkwargsとして受け取る、関数"""
            # デコレート対象の関数を実行し、結果をresultに入れる
            result = function(*args, **kwargs)
            # resultに対し、デコレータの引数を加算して戻す
            return result + decorator_arg

        # decorate関数は、デコレート対象の関数をラップするwrapped関数を返す
        return wrapped

    # add関数は、decorate関数を返す
    return decorate


def calculate(*decorator_args, **decorator_kwargs):
    u""""""
    def decorate(function):
        @wraps(function)
        def wrapped(*args, **kwargs):
            result = function(*args, **kwargs)
            # 可変長引数で与えられた数を合計する
            summary = sum(decorator_args)
            # キーワード可変長引数に減算指定がある場合は減算、それ以外は加算
            if decorator_kwargs.get('is_decrement'):
                return result - summary
            else:
                return result + summary
        return wrapped
    return decorate

 
これらのデコレータは、以下のようにしてプロダクションコードで使います。

target.py

from deco.my_decorator import countup, countdown, add, calculate

class Target:
    def __init__(self, value=0):
        self.value = value

    @countup
    def execute_count_up(self):
        return self.value

    @countdown
    def execute_count_down(self):
        return self.value

    @add(2)
    def execute_add(self):
        return self.value

    @calculate(1, 2, 3)
    def execute_calculate_increment(self):
        return self.value

    @calculate(1, 2, 3, is_decrement=True)
    def execute_calculate_decrement(self):
        return self.value

 
動作はテストコードで説明すると

test_target.py

from target import Target

class TestCountUpWithoutPatch:
    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 1

    def test_count_down_decorator(self):
        sut = Target()
        actual = sut.execute_count_down()
        assert actual == -1

    def test_add_decorator(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 2

    def test_increment_decorator(self):
        sut = Target()
        actual = sut.execute_calculate_increment()
        assert actual == 6

    def test_decrement_decorator(self):
        sut = Target()
        actual = sut.execute_calculate_decrement()
        assert actual == -6

 
であり、テストはすべてパスします。

$ pytest test_target.py 
...

test_target.py .....

5 passed in 0.05 seconds

 

引数なしのデコレータを差し替える

プロダクションコード

@countup
def execute_count_up(self):
    return self.value

について、

  • execute_count_up()メソッドの挙動を確認したい
  • デコレータ@countupは動作させたくない

という条件を満たすテストを書きたいとします。

 
その方法を調べたところ、以下の記事がありました。
Patching decorators | Python Mock Gotchas - Alex Marandon

I hope this silly example convinces you that decorators are applied when the class is created, which happens straight away when we load the module containing the class.

To deal with this you first have to make sure that your decorator is defined in a module separate from the class, otherwise you’ll never get a chance to replace it with a mock before the class definition calls it. Then you need to write your test code so that it patches the decorator before it gets applied to the methods of your class:

とのことです。

そのため、プロダクションコードのimport前に、デコレータを差し替えれば良さそうです。

 
なお、上記の記事ではMockオブジェクトのstart()を使っていましたが、stop()するのがめんどうなので、今回はpatchをwith構文を使うことにします。

 
では、テストコードでデコレータを差し替えてみます。

test_global_patch_count_up.py

from unittest.mock import patch

# withを使ったパッチ
# デコレータはimportした時に確定するため、importだけをwithの中に入れる
with patch('deco.my_decorator.countup', lambda function: function):
    from target import Target

class TestCountUp:
    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

デコレータで何も処理しないよう、patchを使って差し替えました。

そのため、デコレータでは1を加算しなくなり、プロダクションコードからは1ではなく0が返ってくるはずです。

 
テストコードを実行してみます。

$ pytest test_global_patch_count_up.py 
...
test_global_patch_count_up.py .

...
1 passed in 0.08 seconds

想定通りの動きとなり、テストをパスしました。

 

引数ありのデコレータを差し替える

次は、引数ありのデコレータを差し替えるケースを考えます。

今回は、位置引数を持つデコレータ@addを差し替えます。

引数なしと同じように

test_global_patch_add.py

from unittest.mock import patch

with patch('deco.my_decorator.add', lambda function: function):
    from target import Target

class TestAdd:
    def test_add_decorator(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 0

としてしまうと、

$ pytest test_global_patch_add.py 
...
test_global_patch_add.py:6: in <module>
    from target import Target
target.py:3: in <module>
    class Target:
target.py:15: in Target
    @add(2)
E   TypeError: 'int' object is not callable

 1 error in 0.22 seconds

エラーになります。

 
位置引数を持つデコレータのソースコードを見ると、引数なしのデコレータよりも、引数functionを持つ関数のネストが一段深くなっています。

def add(decorator_arg):
    def decorate(function):
        @wraps(function)
        def wrapped(*args, **kwargs):
            ...
            return result + decorator_arg
        return wrapped
    return decorate

これより、with patch('deco.my_decorator.add', lambda function: function):だと、本来はdecorate関数の引数functionを返すよう差し替えるべきなのに、add関数のdecorator_argを返すようになっていることが分かります。

その結果、callableなオブジェクトの受け取りを想定しているところで、intの2というcallableでないオブジェクトが返っているため、エラーとなっています。

 
そのため、テストコードで

with patch('deco.my_decorator.add', lambda decorator_arg: lambda function: function):
    from target import Target

と差し替えるlambdaを一段深くするように変更したところ、

$ pytest test_global_patch_add.py 
...
test_global_patch_add.py .

1 passed in 0.08 seconds

テストをパスしました。

 

ダミー処理をするデコレータに差し替える

今までは、何も処理しないデコレータへと差し替えました。

ただ、何らかの処理を行うデコレータに差し替えたいこともあります。

その場合は、

test_global_patch_replacement.py

from unittest.mock import patch
from functools import wraps

# 差し替え用のデコレータを用意
def dummy_decorator(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result + 9
    return wrapper

# 用意したデコレータへ差し替える(引数無し版と引数あり版)
with patch('deco.my_decorator.countdown', lambda function: dummy_decorator(function)), \
        patch('deco.my_decorator.add', 
              lambda decorator_arg: lambda function: dummy_decorator(function)):
    from target import Target

class TestPatchArgs:
    def test_count_down_decorator(self):
        sut = Target()
        actual = sut.execute_count_down()
        assert actual == 9

    def test_add(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 9

と、

  • 何らかの処理を行うデコレータを、デコレータの差し替え前に定義
    • 差し替え前に定義しないと「NameError: name ‘dummy_decorator’ is not defined」エラー
  • 何らかの処理を行うデコレータを返すようなlambdaにしてpatch

することで、

$ pytest test_global_patch_replacement.py 
...
test_global_patch_replacement.py ..

...
2 passed in 0.08 seconds

と、何らかの処理を行うデコレータへと差し替わりました。

 

デコレータの差し替え有無が混在する複数のテストファイルを同時に実行する場合

上記では、

  • デコレータを差し替えないテストコード
    • test_target.py
  • デコレータを差し替えるテストコード
    • test_global_patch_count_up.py

の2種類があります。

そこで、2つのテストファイルを同時に実行してみると、

$ pytest test_target.py test_global_patch_count_up.py 
...

test_target.py .....
test_global_patch_count_up.py F

...
self = <test_global_patch_count_up.TestCountUp object at 0x10b4cb710>

    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
>       assert actual == 0
E       assert 1 == 0

test_global_patch_count_up.py:18: AssertionError
 1 failed, 5 passed in 0.13 seconds 

テストが失敗します。

テスト結果を見ると、デコレータを差し替えるテストコードにも関わらず、デコレータが差し替わっていないようです。

通常、複数のテストファイルを一括で実行してテストするため、このままでは使い物になりません。

 
そこでもう一度参考にしたブログを読むと、

I hope this silly example convinces you that decorators are applied when the class is created, which happens straight away when we load the module containing the class.

とあります。

ということは、複数のテストコードファイルでプロダクションコードがimportされた時に、デコレータの挙動が固定されてしまうのかなと考えられました。

 
ここで、Pythonでimportする場合、sys.modulesにモジュールに関するエントリが追加されることを思い出しました。

sys.modulesの挙動を詳しく見ると

新しいモジュールがインポートされたため、それらはsys.modules に追加される。これは、なぜ、同じモジュールを二回インポートする場合、それが非常に素早く行われるかを説明している: Python は、それをすでにロードし、sys.modules の中のモジュールとしてキャッシュしているため、二回目のインポートは単なる辞書への参照のみで済む。

6.4. sys.modules を使う - Dive into Python 5.4. (JAPANESE)

とのことです。

これより、importした時にデコレータの挙動が決まり、あとはそれが使い回されるのかなと考えました。

 
そこで、デコレータを差し替えるテストコードの場合のみ、事前にsys.modulesからエントリを削除してみます。

また、デコレータを差し替えない時のimportを優先させるよう、差し替えはテストメソッドの中で行うことにします。

test_method_patch_count_up.py

from unittest.mock import patch
import sys


class TestCountUpUsingWith:
    def test_count_up_decorator(self):
        # すでにimportされていることを考慮し、
        # そのtargetモジュールを無効化するためにsys.modulesより削除する
        # 未importの場合に例外KeyErrorとならないよう、第二引数にNoneを渡しておく
        sys.modules.pop('target', None)

        # 再度importしてパッチ
        with patch('deco.my_decorator.countup', lambda function: function):
            from target import Target

        # 検証
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

        # 使い終わったのでimportを削除
        sys.modules.pop('target')

 
テストしてみます。

$ pytest test_target.py test_method_patch_count_up.py 
...

test_target.py .....
test_method_patch_count_up.py .

6 passed in 0.12 seconds

想定通りの動きとなり、テストをパスしました。

 
なお、複数のテストケースでデコレータの差し替えを行いたい場合は、pytestのsetup/teardownを使い、

test_pytest_setup_patch_count_up.py

from unittest.mock import patch
import sys


class TestCountUpUsingWith:
    # 必要に応じて、クラスレベルやモジュールレベルにしても良い
    def setup_method(self):
        sys.modules.pop('target', None)

    def teardown_method(self):
        sys.modules.pop('target')

    def test_count_up_decorator(self):
        with patch('deco.my_decorator.countup', lambda function: function):
            from target import Target

        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

としても

$ pytest test_target.py test_pytest_setup_patch_count_up.py 
...

test_target.py .....
test_pytest_setup_patch_count_up.py .

6 passed in 0.10 seconds

テストをパスします。

 

ソースコード

GitHubにあげました。e.g._mocking_decoratorディレクトリの中が今回のコードです。
thinkAmi-sandbox/python_mock-sample: Python : usage unittest.mock samples

オープンハードカンファレンス2017 Naganoに参加しました

4/22にギークラボ長野で開かれた「オープンハードカンファレンス2017 Nagano」に参加しました。
オープンハードカンファレンス2017 Nagano – OSHWC

 
物理的な不器用さ*1から、相変わらず物理レイヤーについて学ぶことがあまりできていません…

ただ、以前GDG信州で色々なガジェットを見た時の経験から、モノを見るだけでも得られるものはあるだろうと思い、参加しました。

実際、色々と得るものがありました。

 

会場にて

セミナーを聞いたり、色々な展示の作者にいろいろな技術背景をうかがえたりと、貴重な機会でした。

特に、「ハードウェアなどのツールは色々と出揃っているので、あとはその存在を知ればいい」旨の話を聞き、最近眠っているRaspberry Piなどで何か作ろうかと感じました。

 
展示では、セミナーで紹介されたものの他、

  • IchigoLatteでJavaScriptが動き、Shellやviが動く様子
  • Raspberry Pi Zeroの小ささ
  • 零式電子弦の実機と実演、LEDテープ

などを見ていました。

 
他に、キッズスペースもあり、子どもたちがそちらで集中して熱心にモノづくりしていたのが印象に残りました。

 

セミナー 

スマートフォンで操作するRaspberry Pi 高音質オーディオプレイヤー(試作品) (三石さん)

フィルターレス型DAC変換の電子回路基板とオリジナル電源回路を工作した時の技術的な内容についてのお話でした。

質疑応答や展示での様子から情熱が伝わってきました。

楽しめる世界をより広くするためにも、やはり自分に不足している前提知識をいろいろと身につけたいなと感じました。

 

Raspberry PiでLEDが光るまで (佐藤さん)

LEDが光るという目に見える世界の裏側で、どのようなものが動いているのかのお話でした。

高いレイヤーから低いレイヤーへとお話が移っていったので、理解しやすい内容でした。このあたりの学習にはRaspberry Piを使うと良いようです。

 

GDG信州の紹介(石丸さん)

GDG信州 & GCPUG(ジーシーパグ)信州の紹介と、今後のイベント予定、ガジェット紹介と今後のガジェットについてのお話でした。

予定として、5/17と6/10あたりは空けておくと良いようです。

 

現実とバーチャルを繋ぐ実演 (ギークラボ長野)

Oculus & Raspberry Pi & Pepper & MESH & NFCを組み合わせて、ゲーム仕立ての実演&解説でした。新作ムービーも用意されていました。

それぞれのガジェットで何ができるのかが分かりました。画面に連動してPepperが電磁砲(?)の動きをしていたのにも驚きました。

 

 
最後になりましたが、企画・開催・運営をされたみなさま、ありがとうございました。

バタバタと帰宅したため、片付け*2を十分手伝えずにすみません…

*1:ハンダ付けが鬼門

*2:特に粘土系