読者です 読者をやめる 読者になる 読者になる

Pythonで、URLルーティング機能だけがあるWSGIフレームワークを自作してみた

Python WSGI

以前、

を自作しました。

これらの自作により、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の第3章以降もPythonで書く見通しが立ちました。

gihyo.jp

 
第3章以降では、URLルーティングやクッキー・セッションなどの機能が登場します。

ただ、それらの機能を

のどちらの形で実装するか悩みました。

それでも、せっかくなので、後者のWSGIフレームワークを自作してみようと考えました。

そこで今回、URLルーティング機能だけがあるWSGIフレームワークを自作してみました。

なお、学習用途で作ったため、セキュリティ面は考えていませんのであしからず。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 
なお、WSGI準拠のWebサーバは自作したものを使うため、server.pyとして保存しておきます。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

   

復習:単純なWSGIアプリケーション

まずは復習として、単純なWSGIアプリケーションを作ってみます。

no1_base_app.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
return [b"Hello, world."]

 
動作確認として、

(env) >python server.py no1_base_app:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

URLルーティングは実装していないため、http://localhost:8888/hogeのようなURLでも、「Hello, world」が表示されます。

 

URLルーティング機能を作る(関数版)

続いて、URLルーティング機能を持ったアプリを作ります。

ただ、最初からデコレータを使ってURLルーティング機能を実装すると、自分の理解が追いつかないかもしれないと考えました。

そこで、その代わりとなる方法を調べてみたところ、以下の記事がありました。
Serving Static Files and Routing with WSGI

この記事の方法が分かりやすかったため、no2_router_separately_by_func.pyとして実装してみます。

 
まずはURLルーティングのためのタプルのリストを作ります。

routes = [
    ('/', get),
    ('/hoge', hoge),
]

その内容は

  • タプルは2つの要素を持つ
    • ひとつ目の要素は、対象のURLパス
    • ふたつ目の要素は、対象のURLパスにアクセスした時に呼ばれる関数

です。

 
続いて、WSGIサーバから呼ばれる関数を用意します。

def application(environ, start_response):
    for path, func in routes:
        if path == environ['PATH_INFO']:
            return func(environ, start_response)

    return not_found(environ, start_response)

タプルを順次読み込み、リクエストのパス(environ['PATH_INFO'])と一致した場合に、タプルで指定した関数を呼び出しています。

 
あとは、application関数から呼び出される関数を

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge"]

def not_found(environ, start_response):
    start_response('404 Not Found', [('Content-Type', 'text/plain')])
    return [b"Not found."]

と用意して完成です。

 
動作確認として、

(env) >python server.py no2_router_separately_by_func:application 

を実行し、ブラウザでアクセスすると以下のようになります。

アクセス先のURL 表示内容
http://localhost:8888/ Hello, world
http://localhost:8888/hoge hoge
http://localhost:8888/fuga Not found.

 

URLルーティング機能を作る(クラス版)

URLルーティング機能を、関数版からクラス版へと変更してみます。

no3_router_separately_by_class.py

class MyWSGIApplication(object):
    def __init__(self):
        self.routes = [
            ('/', self.get),
            ('/hoge', self.hoge),
        ]

    def get(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world by class."]

    def hoge(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"hoge by class"]

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by class."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if path == environ['PATH_INFO']:
                return method(environ, start_response)

        return self.not_found(environ, start_response)

app = MyWSGIApplication()

 
動作確認として、

(env) >python server.py no3_router_separately_by_class:app

として、ブラウザでアクセスしても、末尾に「 by class.」が付くだけで、それ以外の動作は変わっていません。

 

フレームワーク

次は

の2つに機能を分割して、それぞれを実装します。

 
WSGIフレームワークno3_router_separately_by_class.pyからURLルーティング機能を移植します。

no4_1_framework.py

class MyWSGIFramework(object):
    def __init__(self, routes):
        self.routes = routes

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by framework."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

 
WSGIアプリはURLルーティング用のタプルのリストと、URLルーティングされた時に呼ばれる関数だけになります。

no4_2_app.py

from no4_1_framework import MyWSGIFramework

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world by framework."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge by framework"]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
])

 
動作確認として、

(env) >python server.py no4_2_app:app

として、ブラウザでアクセスしても、末尾に「by framework」が付くだけで、それ以外の動作は変わっていません。

 

HTMLテンプレートやCSS、画像の配信

最後に、Jinja2を使ってHTMLテンプレートやCSS・画像を表示できるようにします。

CSSや画像の出力については定型的な処理のため、今回はフレームワーク側で処理します。

画像ファイルについては、自作WSGIサーバではsocket.sendfile()を使って送信しています。そのため、画像ファイルをファイルライクオブジェクトの形にして自作WSGIサーバに渡す必要があります。

今回は、バイナリモードでファイルを読み込み、io.BytesIOにてバイナリストリームとすることで、ファイルライクオブジェクトの形にしています*1
16.2. io — ストリームを扱うコアツール — Python 3.5.1 ドキュメント

fullpath = './{}'.format(environ['PATH_INFO'])
with open(fullpath, "rb") as f:
    r = f.read()

binary_stream = io.BytesIO(r)

# 変数content_typeには、ファイルの種類により'text/css'か'image/png'が設定済
start_response('200 OK', content_type)

return binary_stream

 
それを受けて、自作WSGIサーバ(server.py)では、

if 'image' in str_response:
    # 画像データの場合
    self.connection.sendall(str_response.encode('utf-8'))
    self.connection.sendfile(byte_response_body)

else:
    # 画像データ以外の場合
    for byte_body in byte_response_body:
        str_response += byte_body.decode('utf-8')
    self.connection.sendall(str_response.encode('utf-8'))

のような形で、クライアントへデータを送信しています。

なお、PEP3333にはファイルの扱いに関しての記述もありましたが、今回は置いておきます。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
ソースコード全体ですが、WSGIフレームワーク

no5_1_framework.py

import re
import io

class MyWSGIFramework(object):
    def __init__(self, routes, css_dir=None, img_dir=None):
        self.routes = routes

        # 静的ファイル向けのディレクトリ設定
        self.static_dir = '/static'
        css_dir = css_dir if css_dir else "/css/"
        self.css_dir = '{static}{css}'.format(static=self.static_dir, css=css_dir)
        img_dir = img_dir if img_dir else "/images/"
        self.img_dir = '{static}{img}'.format(static=self.static_dir, img=img_dir)

    def static(self, environ, start_response):
        content_type = []
        if re.match(self.css_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'text/css'))
        elif re.match(self.img_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'image/png'))
        else:
            return self.not_found(environ, start_response)

        try:
            fullpath = './{}'.format(environ['PATH_INFO'])
            with open(fullpath, "rb") as f:
                r = f.read()
            binary_stream = io.BytesIO(r)
        except:
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"Internal server error"]

        start_response('200 OK', content_type)
        return binary_stream

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found."]


    def __call__(self, environ, start_response):
        if re.match(self.static_dir, environ['PATH_INFO']):
            return self.static(environ, start_response) 

        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

となりました。

また、WSGIアプリは、

no5_2_app.py

# python server.py no5_2_app:app
from no5_1_framework import MyWSGIFramework
from jinja2 import Environment, FileSystemLoader

def get(environ, start_response):
# (省略)

def hoge(environ, start_response):
# (省略)

def index(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('index.html')
    html = template.render({'messages': ['hoge', 'fuga', 'piyo']})

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
    ('/index', index),
])

となりました。

 
動作確認として、

(env) >python server.py no5_2_app:app

として、ブラウザでhttp://localhost:8888/indexへとアクセスすると、HTMLテンプレートやCSS、画像が表示されました。

 

ソースコード

GitHubに上げました。routing_onlyディレクトリの下が、今回のサンプルです。
wsgi_framework-sample/routing_only at master · thinkAmi-sandbox/wsgi_framework-sample

 

参考

 
もし、FlaskやBottleのようにデコレータを使ったURLルーティング機能を実装する場合には、以下のBottleのソースコード解説も参考になるかもしれません。
Python Bottleのソースを読む ルータ編 - TAISA BLOG

*1:この辺りの説明が正しいかどうか分からないため、誤りがあればご指摘いただけるとありがたいです

#gcpug #glnagano GCPUG信州 キックオフ勉強会(機械学習/TensorFlow)に参加しました

イベント

7/23に開催されたDGCPUG信州 キックオフ勉強会(機械学習/TensorFlow)に参加しました。資料は以下にまとまっています。
GCPUG信州 キックオフ勉強会(機械学習/TensorFlow) - connpass

会場は岡谷市のイルフプラザのIT研修室でした。各机に電源コンセントの他、設備としてプロジェクタやスクリーンがありました。
生涯学習館(イルフプラザ3階 カルチャーセンター) - 岡谷市ホームページ

また、ギークラボ長野を会場にして、リモート中継も行われました。
GCPUG信州キックオフ リモート中継会場 - GEEKLAB.NAGANO | Doorkeeper

当日の様子と映像がTogetterにまとめられています。
GCPUG信州 第1回勉強会まとめ(2016/7/23) - Togetterまとめ

 
Google関係で自分がさわったことがあるのは、GoogleAppEngineやGoogleAppsScriptだけでした。そのため、機械学習はよく分からないのに参加していいのか悩みました。ただ、アジェンダでは機械学習以外にもいろいろと含まれていたため、参加を決めました。

勉強会では

  • Google Cloud Platformの全体像
  • TensorFlow
  • Speech API
  • Docker

などについて、自分で試す時のキーワードを拾うことができました。発表者のみなさま、ありがとうございました。

以下にメモを残しておきます*1

 

Google Cloud Platform (Shinmetalさん)

Google Cloud Platformの全体像の分かりやすい説明を受けました。

Datacenter as a ComputerやGoogleのネットワーク専用線、AIで電力管理などスケールの大きい話でした。

また、Regionに属しているリソースを使ったほうが楽ちんということで、実際に使ってみるときはその辺りも注目してみようと思いました。

BigQueryのデモもありました。1億件超のデータを2秒強でまとめ上げたのを見て圧倒的な速度を実感しました。

 

Speech APIとほにゃららAPI (satoru_magさん)

Speech APIについてデモを交えながらのお話でした。

日本語の認識率はそれなりにあるものの、まだまだこれからのようでした。

ただ、これがあれば会議メモとか電話での問い合わせの記録が効率化できるので、今後に期待したいところです。

また、3日前に発表されたばかりのCloud Natural Language APIについてもふれられており、これはこれで凄いと感じました。

 

Tensor Flow (Shuhei Fujiwaraさん)

TensorFlowについてのお話と、Googleが提供している各APIについてのお話でした。

ライブラリであるTensorFlowの設計思想や使い方、Googleの各APIとの関係が分かりやすかったです。

 

IoT機器でCloud Vision APIを使ってみる (kopayutaponさん)

Cloud Vision APIを中心とした紹介がありました。

収集できるデータの都合上、特殊な用途はTensorFlow、一般的なのはCloud Vision APIが良いとのことでした。

自分に画像まわりの知識がないため、「一度縮小し、単純拡大すればモザイクになる」というところで、「ほー」となりました。

 

GCPでDocker Google Container Engine紹介 (koda3さん)

Dockerを使いたくなるセッションでした。

VMとDockerのざっくりした違いが資料のp14p15で図解されていてイメージがしやすかったです。

また、一つのDockerコンテナの中には、一つのアプリ(責務)を入れると良いとの解説も参考になりました。

 

GCEをTensorFlowの「計算エンジン」にする (stealthinuさん)

ディープラーニングのざっくりした説明から、Google Compute Engineで使うためのTipsなどの紹介がありました。

スライドの中に図があったので、どんなことをやるかのイメージがわきました。

また、gcloudコマンドの良いところやAnsibleを使うところも参考になりました。

 

プレゼント抽選会など

GDG信州で見かけた、おみくじによる抽選会がありました。

自分はGoogle Cloud PlatformのTシャツをいただきました。ありがとうございました。

 
懇親会では、機械学習に興味を持って参加した非IT系の方々からお話を聞き、盛り上がりを感じました。また、生存バイアス的な話を聞いた時に、職種の壁を超えて意見が一致したことも嬉しかったです。祈ってます。

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

*1:が、機械学習分野については他のかたの記事を期待しています...

Pythonで、自作したWSGI準拠のWebサーバ上で、自作のWSGIアプリを動かしてみた

Python WSGI

前回WSGI準拠のWebサーバを自作しました。

その時はWSGIアプリケーションフレームワークとしてBottleを使いましたが、今回はWSGIアプリケーションも自作してみます。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit
  • Jinja2 2.8

 
WSGIアプリケーションのテンプレートエンジンはどうしようかと考えました。ただ、以下を読むと、自作に手間がかかりそうでした。
みんなのPython Webアプリ編 - 標準モジュールを使ったテンプレートエンジン | TRIVIAL TECHNOLOGIES 4 @ats のイクメン日記

そこで今回は、Django・Flask・Bottleなどに対応しているJinja2をテンプレートエンジンとして使うことにしました。
Welcome to Jinja2 — Jinja2 Documentation (2.8-dev)

 
また、自作したWSGI準拠のWebサーバは以下を流用します。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

 

最も単純なWSGIプリケーション

関数一つで動作します。
第1回 WSGIの概要:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

Python3版の場合、戻り値はバイト列とする必要があるため、

hello_wsgi_app_by_function.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

とします。

 
動作確認として

(env) >python multi_response_wsgi_server.py hello_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

 

Jinja2テンプレートを使う(関数版)

Jinja2テンプレートエンジンを使う方法は、以下が参考になりました。

 
Jinja2をインストールします。

(env) >pip install jinja2

 
Jinja2向けのテンプレートファイルを用意します。

templates/hello.html

<html>
    <head>
        <meta charset="UTF-8">
        <title>hello</title>
    </head>
    <body>
        <h1>Hello, world part2</h1>
    </body>
</html>

 
Jinja2テンプレートエンジンをWSGIアプリケーションへと組み込みます。

jinja2_wsgi_app_by_function.py

from jinja2 import Environment, FileSystemLoader

def application(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('hello.html')
    html = template.render()

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]

 
動作確認として

(env) >python multi_response_wsgi_server.py jinja2_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world part2」 が表示されました。

 

Jinja2テンプレートを使う(クラス版)

次はクラス形式で書いてみます。

PEP3333によると、__call__メソッドを持つインスタンスを用意すれば良さそうでした。
Specification Overview | PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

jinja2_wsgi_app_by_class.py

from jinja2 import Environment, FileSystemLoader

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
        template = jinja2_env.get_template('hello.html')
        html = template.render()

        start_response('200 OK', [('Content-Type', 'text/html')])
        return [html.encode('utf-8')]

app = MyWSGIApplication()

 
動作確認として

(env) >python multi_response_wsgi_server.py jinja2_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world part2」 が表示されました。

 

クエリパラメータをテンプレートへ反映する

GETの動作は確認できたので、今度はGETのクエリパラメータを扱ってみます。

GETのクエリパラメータを扱いやすくする方法を探したところ、cgi.FieldStorageを使うのが良さそうでした。
Python(WSGI)でGET、POSTを処理する方法.-蔦箱(ツタハコ)

 
templates/query.html

<html>
    <head>
        <meta charset="UTF-8">
        <title>GETテスト</title>
    </head>
    <body>
        <h1>GETテスト</h1>
        <p>{{ title }} : {{ comment }}</p>
    </body>
</html>

 
get_query_wsgi_app_by_class.py

import cgi
from jinja2 import Environment, FileSystemLoader

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "GET":
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('get.html')

            fs = cgi.FieldStorage(
                environ=environ,
                keep_blank_values=True,
            )
            html = template.render(title=fs['title'].value, comment=fs['comment'].value)

            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 
ただ、これだけではクエリパラメータがWSGIアプリケーションに渡らないため、WSGIサーバも修正します。

クライアントからのリクエストにて、ステータスラインにクエリパラメータが含まれます(例:GET /?title=aaa&comment=bbb HTTP/1.1)。

ただ、ステータスラインからクエリパラメータだけを抜き出す良い方法が思い浮かばなかったため、

multi_response_wsgi_server.py

def get_environ(self, byte_request):
#...
    request_method, path, request_version = str_request_line_without_crlf.split()

    fullpath = "http://{server}:{port}{path}".format(
        server=self.server_name,
        port=str(self.server_port),
        path=path,
    )
    parsed_fullpath = urlparse(fullpath)

    env = {}
#...
    env['PATH_INFO']         = path                  # /
    env['QUERY_STRING']      = parsed_fullpath.query # query parameter

#...
    return env

と、一度URLへと変換してからurllib.parse.urlparse()で抜き出してenv['QUERY_STRING']へと設定しました。

 
動作確認として

(env) >python multi_response_wsgi_server.py get_query_wsgi_app_by_class:app

を実行し、http://localhost:8888/?title=aaa&comment=bbbへアクセスすると

GETテスト

aaa : bbb

と、クエリパラメータの値が表示されました。

 

POSTを扱う

POSTを扱うWSGIアプリケーションとして、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の「3.3.3. Tomcatで掲示板を作る」の掲示板アプリケーションを作ります。

このアプリケーションの流れは、

  1. POST
  2. 301リダイレクト
  3. GET

でした。

301リダイレクトをするには、レスポンスヘッダにLocationヘッダを追加します。

bbs_wsgi_app.app

import datetime
import cgi
import io
from jinja2 import Environment, FileSystemLoader

class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()


class MyWSGIApplication(object):
    def __init__(self):
        self.messages = []

    # https://knzm.readthedocs.io/en/latest/pep-3333-ja.html#the-application-framework-side
    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "POST":
            # POSTヘッダとボディが一緒に格納されている
            # cgi.FieldStorageで使うために、
            #  ・リクエストをデコード
            #  ・ヘッダとボディを分離
            #  ・ボディをエンコード
            #  ・ボディをio.BytesIOに渡す
            # を行う
            decoded = environ['wsgi.input'].read().decode('utf-8')
            header_body_list = decoded.split('\r\n')
            body = header_body_list[-1]
            encoded_body = body.encode('utf-8')

            # http://docs.python.jp/3/library/io.html#io.BytesIO
            with io.BytesIO(encoded_body) as bytes_body:
                fs = cgi.FieldStorage(
                    fp=bytes_body,
                    environ=environ,
                    keep_blank_values=True,
                )
            # 念のためFieldStorageの内容を確認
            print('-'*20 + '\nFieldStorage:{}\n'.format(fs) + '-'*20)

            self.messages.append(Message(
                title=fs['title'].value,
                handle=fs['handle'].value,
                message=fs['message'].value,
            ))

            # リダイレクトはLocationヘッダをつけてあげれば、ブラウザがうまいことやってくれる
            location = "{scheme}://{name}:{port}/".format(
                scheme = environ['wsgi.url_scheme'],
                name = environ['SERVER_NAME'],
                port = environ['SERVER_PORT'],
            )
            start_response('301 Moved Permanently', [('Location', location)])
            # 適当な値を返しておく
            return [b'1']

        else:
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('bbs.html')
            html = template.render({'messages': self.messages})
            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 
動作確認として

(env) >python multi_response_wsgi_server.py bbs_wsgi_app:app

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、掲示板アプリが再現できました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_application-sample

 

その他参考

クラス宣言について

いろいろなコードのクラス宣言を見ると、明示的なobjectの継承があったりなかったりしました。

どちらのほうが良いか調べてみましたところstackoverflowに情報がありました。
Python class inherits object - Stack Overflow

自分はPython2系で書く機会もあるため、今後も明示的なobject継承で書くことにしました。

PythonでWSGI準拠のWebサーバを自作し、その上でBottleを動かしてみた

Bottle Python WSGI

以前、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考に、PythonでWebサーバを書きました。
「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた - メモ的な思考的な

gihyo.jp

 
この時は3章以降は読むだけにしましたが、やはり3章以降もPythonで実装してみたいと考えました。

3章以降はJava + Tomcatを使っていたため、Pythonの場合はWSGIを使って作るのが良さそうです。
java - What is the Python equivalent of Tomcat? - Stack Overflow

 
まずはWSGIの仕様を知ろうと、PEP3333の日本語訳を中心に読んでみました。

 
次に、WSGI準拠Webサーバをイチから作成する方法を調べてみたところ、以下の記事を見つけました(以下、「参考記事」と表記)。
Let’s Build A Web Server. Part 2. - Ruslan's Blog

図などで詳しく説明されていたため、だいたいの流れは理解できました。ありがとうございました。

 
上記を元にWSGI準拠のWebサーバを実装してみたため、その時の内容をメモしておきます*1

なお、誤りなどがあれば、ご指摘いただけるとありがたいです。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit
  • bottle 0.12.9

 
今回は、WSGI準拠の自作Webサーバ上で、WSGIアプリケーションフレームワークのBottleを使ったアプリを動かします。
Bottle: Python Web Framework — Bottle 0.13-dev documentation

Bottleアプリの内容は、

bottle_app.py

@route('/')
def index():
    # Bottleで設定するレスポンスヘッダを確認
    print("----Bottle's response_header----")
    print(bottle.response.headerlist)
    return template('index.html')

@route('/static/css/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/css/')

@route('/static/images/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/images/')

app = bottle.default_app()

 
views/index.html

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>テスト</title>
        <link rel="stylesheet" href="/static/css/default.css">
    </head>
   <body>
       <h1>テストタイトル</h1>
       <p>テストページです</p>
       <p>今日のシナノゴールド</p>
       <img src="/static/images/shinanogold.png">
   </body>
</html>

 
static/css/default.css

h1 {
    color: red;
}

です。

 

1つのレスポンスを返すWSGI Webサーバの作成

まずは、単純に1つのレスポンスを返すWSGI Webサーバを作成します。

前回作成した以下のWebサーバをベースにします。
syakyo_create_web_server/modoki01_1_5_3.py at master · thinkAmi-sandbox/syakyo_create_web_server

 
WSGI準拠とするため、

  • WSGI環境変数の作成
  • WSGIアプリケーションから呼び出されるコールバック関数の作成
  • WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

の3点を追加します。

 

WSGI環境変数の作成

PEP3333では、このあたりに書いてあります。

今回は、

env = {}
env['wsgi.version']      = (1, 0)   # WSGIのバージョン:決め打ち
env['wsgi.url_scheme']   = 'http'   # urlのスキーム(http/https)
# HTTP リクエスト本体のバイト列を読み出すことができる入力ストリーム 
env['wsgi.input']        = io.BytesIO(byte_request)
env['wsgi.errors']       = sys.stderr
env['wsgi.multithread']  = False
env['wsgi.multiprocess'] = False
env['wsgi.run_once']     = False
env['REQUEST_METHOD']    = request_method    # GET
env['PATH_INFO']         = path              # /
env['SERVER_NAME']       = server_name       # FQDN
env['SERVER_PORT']       = str(port)         # 8888

としました。

 

WSGIアプリケーションから呼び出されるコールバック関数の作成

PEP3333では、このあたりに書いてあります。

コールバック関数で必要な引数は、

  • status
    • 文字列で、 200 OK などが入ってくる
  • response_headers
    • 文字列で、 [('Content-Length', '398'), ('Content-Type', 'text/html; charset=UTF-8')] などが入ってくる
  • exc_info=None
    • エラーがなければ、基本はNone

の3つです。引数名は任意ですが、今回はPEP3333に合わせます。

 
コールバック関数の中では、ステータスコードやレスポンスヘッダがWSGIアプリケーションから渡されます。

ただ、

  • コールバック関数の戻り値として、それらをWebサーバへ渡すことができない
  • コールバック関数の中以外では、それらを取得できない

という制約があります。

そのため、参考記事ではインスタンス変数を使ってWebサーバへそれらを渡していました。

ただ、今回は関数による実装のため、

def start_response(status, response_headers, exc_info=None):
    # 任意の内容の、WSGIサーバで追加するレスポンスヘッダ
    server_headers = [
        ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
        ('Server', 'HenaWSGIServer 0.1'),
    ]

    global headers_set
    headers_set = [status, response_headers + server_headers]

と、グローバル変数へと保存しました。

 

WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

上記で作成した環境変数とコールバック関数をWSGIアプリケーションに渡します。

byte_response_body = application(env, start_response)

 

ブラウザへのレスポンスについて

以前作ったWebサーバでは、ステータスライン・レスポンスヘッダ・レスポンスボディを1行ずつ返していました。

今回も同じように1行ずつ返したところ、

Content-Length: 398

Content-Type: text/html; charset=UTF-8

Date: Sat, 16 Jul 2016 00:00:00 JST

Server: HenaWSGIServer 0.1


<!DOCTYPE html>
<html lang="ja">
...

のようなテキストがブラウザに表示されてしまい、正しいレスポンスとは認識されませんでした。

 
そこで

# ステータスラインからレスポンスボディまで、一括で送信する
# ステータスライン
str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

# レスポンスヘッダ
for header in response_headers:
    str_response += '{0}: {1}\r\n'.format(*header)

# レスポンスヘッダとレスポンスボディを分ける、改行コード
str_response += '\r\n'

# レスポンスボディ
for byte_body in byte_response_body:
    # WSGIアプリからもらったレスポンスボディはバイト列
    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
    str_response += byte_body.decode('utf-8')

# クライアントへ送信
# バイト列で送信する必要があるため、エンコードしてから送信
connection.sendall(str_response.encode('utf-8'))

のように、ステータスラインやレスポンスヘッダ、レスポンスボディを連結して、一括して返すようにしました。

 

動作確認

この時点のソースコードは以下の通りです。
wsgi_webserver-sample/single_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

 
動作確認として、コマンドプロンプトより

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ

f:id:thinkAmi:20160718170207p:plain

と表示されました。

1つのレスポンスしか処理できないため、CSSや画像の部分は対応できていません。

 

普通にWebページを表示するWebサーバの作成

今度はCSSや画像も含めたWebページを表示できる、WSGI準拠のWebサーバを作ります。

前回作成した以下のWebサーバのように、threading.Threadにてマルチスレッド化することで、複数のリクエストとレスポンスを扱えるようにします。
syakyo_create_web_server/modoki02_main_2_6.py at master · thinkAmi-sandbox/syakyo_create_web_server

また、せっかくなので、クラス形式でWebサーバを作ってみます。

 

コールバック関数のstatusやresponse_headersの扱いについて

1つのレスポンスを処理するバージョンでは、グローバル変数を使ってstatusresponse_headersを保存していました。

ただ、今回はマルチスレッド化するためグローバル変数が使えません。また、参考記事のようなクラス構成でインスタンス変数を使った場合、スレッド間でインスタンス変数を上書きしてしまいます。

スレッド固有のデータを保持するthreading.local()を使うことも考えましたが、うまい使い方が思い浮かびませんでした。
Python の threadling.local (スレッドローカル) を試してみる | CUBE SUGAR STORAGE

そんな中、WSGIのリファレンス実装であるwsgirefモジュールの構成を見たところ、serverとhandlerのクラスが分かれていました。これならマルチスレッド化できそうです。
cpython/Lib/wsgiref at master · python/cpython

 
そこで、

  • メインスレッドのサーバクラス(MyWSGIServer)でsocket.accept()
  • データを受信したら、別スレッドのハンドラクラス(MyWSGIHandler)でリクエストとレスポンスを処理

の形としました。

 
念のため、MyWSGIHandlerクラスのオブジェクトについて、

while True:
    client_connection, client_address = self.listen_socket.accept()
    handler = MyWSGIHandler(client_connection, client_address, self.application, self.server_name, self.server_port)

    # 念のため、インスタンスの識別値を確認
    print("handler object id: {}".format(id(handler)))

    # スレッドで画像ファイルとかも受け取れるようにする
    thread = threading.Thread(target=handler.handle_one_request)
    thread.start()

のように、オブジェクト識別値を確認してみました。

結果は

handler object id: 29123088
handler object id: 30610064
handler object id: 59447632
handler object id: 59448240

となり、リクエストごとにオブジェクトが生成されていました。

 

WSGI環境変数について

WSGI環境変数のうち、

  • wsgi.multithread
  • wsgi.multiprocess
  • wsgi.run_once

の値をどうするか悩みましたが、今回は

env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True

としました。

正しくないような気もしますので、ご存じの方がいればご指摘ください。

 

画像のレスポンスについて

上記の内容でWSGI準拠サーバを作成し、ブラウザで確認したところ、

f:id:thinkAmi:20160718171706p:plain

となりました。画像のレスポンスがうまくいっていないようです。

コンソールを確認したところ、

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

というエラーが出ていました。

レスポンスボディを結合する際の

str_response += byte_body.decode('utf-8')

で発生したようです。

 
画像なのでバイナリデータなのかなと思い、Bottleからのレスポンスボディを見たところ、

<bottle.WSGIFileWrapper object at 0x03305B30>

のようなBottleオブジェクトでした。

 
ファイルオブジェクトをsocketで送信する方法を探そうと公式ドキュメントを見たところ、Python3.5より、socket.sendfile()が追加されていました。
18.1. socket — 低水準ネットワークインターフェース — Python 3.5.1 ドキュメント

Bottleでの使用例も探してみたところ、

yield connection.sendall(response.render_headers())

res = yield connection.sendfile(self.filelike, 0, self.blocksize)

https://github.com/j4cbo/chiral/blob/master/chiral/web/httpd.py#L146

のような形で、レスポンスヘッダの送信後に画像データを送れば良さそうでした。

 

動作確認

コマンドプロンプトより、

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ、

f:id:thinkAmi:20160718172248p:plain

画像も表示されていました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_webserver-sample

こちらにも残しておきます。

import socket
import io
import sys
import threading

class MyWSGIHandler(object):
    def __init__(self, client_connection, client_address, application, server_name, server_port):
        self.connection = client_connection
        self.address = client_address
        self.application = application
        self.server_name = server_name
        self.server_port = server_port

        # コールバック関数で取得できるステータスコード・レスポンスヘッダの保存先
        self.headers_set = []


    def handle_one_request(self):
        request_data = self.connection.recv(1024)
        env = self.get_environ(request_data)
        byte_response_body = self.application(env, self.start_response)
        self.finish_response(byte_response_body)


    def get_environ(self, byte_request):
        byte_request_line = byte_request.splitlines()[0]
        str_request_line = byte_request_line.decode('utf-8')
        str_request_line_without_crlf = str_request_line.rstrip('\r\n')
        request_method, path, request_version = str_request_line_without_crlf.split()

        env = {}
        env['wsgi.version']      = (1, 0)
        env['wsgi.url_scheme']   = 'http'
        env['wsgi.input']        = io.BytesIO(byte_request) 
        env['wsgi.errors']       = sys.stderr
        env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
        env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
        env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True
        env['REQUEST_METHOD']    = request_method        # GET
        env['PATH_INFO']         = path                  # /
        env['SERVER_NAME']       = self.server_name      # FQDN
        env['SERVER_PORT']       = str(self.server_port) # 8888

        return env


    def start_response(self, status, response_headers, exc_info=None):
        server_headers = [
            ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
            ('Server', 'MyWSGIServer 0.2'),
        ]
        self.headers_set = [status, response_headers + server_headers]


    def finish_response(self, byte_response_body):
        try:
            status, response_headers = self.headers_set

            # ステータスライン
            str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

            # レスポンスヘッダ
            for header in response_headers:
                str_response += '{0}: {1}\r\n'.format(*header)

            # レスポンスヘッダとレスポンスボディを分ける、改行コード
            str_response += '\r\n'

            # レスポンスボディ
            print(byte_response_body)
            # 画像データのレスポンスがあった場合のオブジェクト
            # => <bottle.WSGIFileWrapper object at 0x03305B30>
            # これをそのままdecodeするとエラーになる
            # => UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

            if 'image' in str_response:
                # 画像データの場合
                # レスポンスヘッダを送信した後、sendfile()で画像データを送信する
                self.connection.sendall(str_response.encode('utf-8'))
                self.connection.sendfile(byte_response_body)

            else:
                # 画像データ以外の場合
                for byte_body in byte_response_body:
                    # WSGIアプリからもらったレスポンスボディはバイト列
                    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
                    str_response += byte_body.decode('utf-8')

                # クライアントへ送信
                # バイト列で送信する必要があるため、エンコードしてから送信
                self.connection.sendall(str_response.encode('utf-8'))

        finally:
            self.connection.close()


class MyWSGIServer(object):
    def __init__(self, ip_address, port, wsgi_app):
        self.listen_socket = socket.socket(
            socket.AF_INET,
            socket.SOCK_STREAM
        )
        self.listen_socket.bind((ip_address, port))
        self.listen_socket.listen(1)
        self.application = wsgi_app
        self.server_name = socket.getfqdn(ip_address)
        self.server_port = port
    
    def serve_forever(self):
        while True:
            client_connection, client_address = self.listen_socket.accept()
            handler = MyWSGIHandler(client_connection, client_address, 
                self.application, self.server_name, self.server_port)

            # 念のため、オブジェクト識別値を確認
            print("handler object id: {}".format(id(handler)))

            # スレッドで画像ファイルとかも受け取れるようにする
            thread = threading.Thread(target=handler.handle_one_request)
            thread.start()


def make_server(ip_address, port, wsgi_app):
    server = MyWSGIServer(ip_address, port, wsgi_app)
    return server


if __name__ == '__main__':
    if len(sys.argv) < 2:
        sys.exit('"module:callable"の形でWSGIアプリケーションを指定してください')

    app_path = sys.argv[1]
    module, application = app_path.split(':')
    module = __import__(module)
    wsgi_app = getattr(module, application)

    # WSGIサーバの起動
    httpd = make_server('', 8888, wsgi_app)
    print('MyWSGIServer: ホスト{address}、ポート{port}にて起動しました\n'.format(
        address=httpd.server_name, port=httpd.server_port))
    httpd.serve_forever()

 

その他参考

*1:もっとも、WSGI準拠とはいいつつ、BottleでGETが動く最低限のものしか実装していませんが...

PowerShellを使って、Gmail APIからメールを送信する

Google PowerShell

以前、PowerShellを使ってSMTPGmailを送信したことがありました。
PowerShellを使って、SMTPでGmailを送信する - メモ的な思考的な

 
今回は、以下の記事を参考に、Gmail APIを使たGmailの送信を試してみました*1
Powershell: Googlemail (GMail) nativ mit Powershell verwalten - administrator.de

なお、自分が実装してみたのは送信部分だけですが、記事にはそれ以外の内容についても記載されています。

 
ちなみに、Gmail APIでは .NET向けにライブラリも用意されていますが、PowerShellでNuGetパッケージを扱うやり方が分からなかったため、今回は使用しませんでした。
.NET Quickstart  |  Gmail API  |  Google Developers

 
目次

 

環境

 

Gmail APIの有効化とclient_id.jsonファイルの取得

以前Pythonでやった時と同じ方法で作業します。
Python3 + google-api-python-clientで、Gmail APIを使ってメールを送信する - メモ的な思考的な

  • dotnetチュートリアルへ移動
  • Step1のa.にある、this wizardをクリック
  • Google Developers Console で Gmail API を有効にするアプリケーションの登録画面で、以下を選択し、同意して続行をクリック
    • 新機能のお知らせ: 任意
    • 利用規約の順守:はい
  • プロジェクトMy Projectを作成していますの表示後、しばらく待つと、API が有効化されましたの表示が出る

    • 認証情報に進むをクリック
  • プロジェクトへの認証情報の追加画面

    • 1. 必要な認証情報の種類を調べるで、以下を選択し、必要な認証情報をクリック
      • 使用する APIGmail API
      • API を呼び出す場所: その他のUI (Windows、CLIツールなど)
      • アクセスするデータの種類: ユーザーデータ
    • 2.OAuth 2.0 クライアント ID を作成する
      • 名前: 任意 (MyPowerShellGmailSender など)
      • クライアントIDの作成をクリック
    • 3.OAuth 2.0 同意画面を設定する
      • メールアドレス: 自分のメールアドレス
      • ユーザーに表示するサービス名: 任意(MyPowerShellGmailSender Auth など)
      • 次へ をクリック
    • 4.認証情報をダウンロードする

 

OAuth2.0での認証部分の作成

参考サイトだと、Auth-Google関数の部分になります。

処理の流れは

  • 未認証の場合、ブラウザ経由で認証し、トークンを取得
  • 認証済&有効期限内の場合、トークンを再利用
  • 認証済&有効期間超過の場合、トークンをリフレッシュ

でした。以下の記事と同じ流れでした。
Google API OAuth2.0のアクセストークン&リフレッシュトークン取得手順メモ - Qiita

また、参考サイトではトークンなどはグローバル変数に入れていましたが、今回はPython版のgoogle-api-python-clientに合わせて、ローカルのJSONファイルとして保存することにします。

 
実装した時に悩んだ部分は以下の通りです。

 

JSONファイルの読み書き

ConvertFrom-JsonConvertTo-Jsonを使います。
PowerShell で JSON をファイル入出力 する - tech.guitarrapc.cóm

 
なお、読み込んだJSONオブジェクトの各項目は

$json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
$auth = $json.installed

のように、<オブジェクト名>.<項目名>でアクセスします。

 

IEで認可した時のcodeを取得

参考サイトだと、ブラウザに表示されているcodeをコピー&ペーストする形でした。

良い方法がないか調べたところ、

と、IEを使えば自動化できそうでした。

そこで、

$ie = New-Object -ComObject InternetExplorer.Application
$ie.Navigate($auth_url)
$ie.Visible = $true 

$code = ""
while($true){
    $title = $ie.Document.title
    if (($title) -and ($title.Contains("Success"))) {
        $code = $title.Replace("Success code=", "")
        break
    }
    Start-Sleep -s 1
}

$ie.Quit()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
Remove-Variable ie

としました。

 

PowerShellで既存のオブジェクトにプロパティを追加

OAuth2.0で取得したトークンの内容は

{
    "access_token":  "xxx",
    "token_type":  "Bearer",
    "expires_in":  3600,
    "refresh_token":  "xxx",
}

でした。有効期限切れまでの秒数(expires_in)はあるものの、有効期限の開始日時はありませんでした。

そのため、Add-Memberを使って

$credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force

のように、$credentialオブジェクトに created_atプロパティ(有効期限の開始日時)を追加し、有効期限を判定しやすくしました。
Add-Member - TechNet

 

OAuth2.0のURLにあるパラメータの意味について

以下が参考になりました。

 

メール送信部分の作成

参考サイトだとSend-GoogleMailMessage関数になります。

処理の流れは、

  • アクセストークンを取得
  • System.Net.Mail.MailMessageクラスを使ってメールのヘッダやボディを作成
  • リフレクションで非公開メソッドを使って、MailMessageバイト列へ変換
  • System.Convert.ToBase64String()を使って、Base64エンコードした文字列を取得
  • エンドポイントへPOST

でした。

 
.NETでBase64エンコードする場合、System.Convert.ToBase64String()を使いますが、

という点が悩ましかったです。

それらを解消する方法を調べたところ、以下のC#Gmail APIを使っているサイトが参考になりました。
Sending Email with the Gmail API in .NET / C# | Jason Pettys Blog

そのサイトでは

  • System.Convert.ToBase64String()に渡しやすい、AE.Net.Mailライブラリを使う
  • System.Convert.ToBase64String()で足りない部分は、自分で変換する

と実装しており、これで良さそうでした。

 
その他で悩んだ部分は以下の通りです。

 

PowerShellモジュールの読込

今回、

  • OAuth2.0での認証(google_credential.psm1)
  • メール送信(gmail_sender.ps1)

は別のPowerShellファイルとして用意しました。そのため、gmail_sender.ps1の中でgoogle_credential.psm1を読み込む必要があります。

方法としては

$path = Join-Path . "google_credential.psm1"
Import-Module -Name $path

のように、Import-Moduleを使うのが良さそうでした。
Importing a PowerShell Module

 

AE.Net.Mailライブラリ(dll)の読込

まずは、読み込むためのAE.Net.Mail.dllファイルを取得します。

C#プロジェクトとかであればNuGetを使いますが、今回は直接ダウンロードします。

以下のページのDownloadより、最新版のae.net.mail.1.7.10.nupkgをダウンロードします。
NuGet Gallery | AE.Net.Mail

拡張子をzipに変更・展開すると、libディレクトリの中にnet40net45の2ディレクトリがありました。

PowerShellで使っているバージョンを調べると、

PS > $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.0.10586.122
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.10586.122
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

より、CLRVersionが4.0でした。

そのため、net40ディレクトリのAE.Net.Mail.dllスクリプトと同じディレクトリへとコピーします。

また、インターネットからダウンロードしたdllのため、今回はdllのプロパティよりブロックの解除をしておきます。
Powershell load dll got error: Add-Type : Could not load file or assembly 'WebDriver.dll' or one of its dependencies. Operation is not supported - Stack Overflow

 
あとは、

$dll = Join-Path . "AE.NET.Mail.dll"
Add-Type -Path $dll
$msg = New-Object AE.Net.Mail.MailMessage

のように、Add-Typeを使うことで、dllに含まれるクラスが利用可能になります。
Add-Type - TechNet

 

リクエストURLのuserIdパラメータについて

参考サイトでは、OAuth認証をしたユーザのメールアドレスを、System.Net.WebUtility.UrlEncode()エンコードした値を使っていました。
WindowsアプリケーションでHTMLデコード/エンコードを行うには?[4以降、C#、VB] - @IT

ただ、ドキュメントをよく読むと、

The user's email address. The special value me can be used to indicate the authenticated user.

Users.messages: send  |  Gmail API  |  Google Developers

のように、meという値も設定可能だったため、今回はこちらを使いました。

 

Bounceメールについて

正常に送信できたにもかかわらず、Bounceメールも同時に送信されました。

原因は、Reply-Toに値を設定していなかったためです。
GMail API Emails Bouncing - Stack Overflow

 

ソースコード全体

以下の通りとなりました。

google_credential.psm1

set CREDENTIAL_FILE (Join-Path . "credential.json")
set SECRET_FILE (Join-Path . "client_id.json")
set DATE_FORMAT "yyyy/MM/dd HH:mm:ss"
set GMAIL_SCOPE "https://www.googleapis.com/auth/gmail.send"

function Save-GoogleCredential($credential){
    $credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force
    $credential_file = Join-Path . $CREDENTIAL_FILE
    $credential | ConvertTo-Json | Out-File $CREDENTIAL_FILE -Encoding utf8
}

function Get-GoogleCredential(){
    if (-not(Test-Path $SECRET_FILE)) {
        Write-Host "Not found client_id.json file"
        return $null
    }

    $json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
    $auth = $json.installed

    if (Test-Path $CREDENTIAL_FILE) {
        $current_credential = Get-Content $CREDENTIAL_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
        if (-not ($current_credential.access_token -and $current_credential.token_type -and $current_credential.expires_in `
                  -and $current_credential.refresh_token -and $current_credential.created_at))
        {
            Write-Host "No credential file: $($CREDENTIAL_FILE)"
            return $null
        }

        $elapsed_seconds = ((Get-Date) - [DateTime]::ParseExact($current_credential.created_at, $DATE_FORMAT, $null)).TotalSeconds
        if ($elapsed_seconds -lt $current_credential.expires_in ) {
            Write-Host "Reuse access token..."
            return $current_credential
        }
        else{
            Write-Host "Refresh access token..."

            $refresh_body = @{
                "refresh_token" = $current_credential.refresh_token;
                "client_id" = $auth.client_id;
                "client_secret" = $auth.client_secret;
                "grant_type" = "refresh_token";
            }

            try {
                $refreshed_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $refresh_body
            }
            catch [System.Exception] {
                Write-Host $Error
                return $null
            }
            
            Save-GoogleCredential $refreshed_credential
            return $refreshed_credential
        }
    }

    Write-Host "New access token..."

    $gmail_scope = "https://www.googleapis.com/auth/gmail.send"
    $auth_url = "$($auth.auth_uri)?scope=$($GMAIL_SCOPE)"
    $auth_url += "&redirect_uri=$($auth.redirect_uris[0])"
    $auth_url += "&client_id=$($auth.client_id)"
    $auth_url += "&response_type=code&approval_prompt=force&access_type=offline"

    $ie = New-Object -ComObject InternetExplorer.Application
    $ie.Navigate($auth_url)
    $ie.Visible = $true 

    $code = ""
    while($true){
        $title = $ie.Document.title
        if (($title) -and ($title.Contains("Success"))) {
            $code = $title.Replace("Success code=", "")
            break
        }
        Start-Sleep -s 1
    }

    $ie.Quit()
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
    Remove-Variable ie

    try {
        $new_body = @{
            "client_id" = $auth.client_id;
            "client_secret" = $auth.client_secret;
            "redirect_uri" = $auth.redirect_uris[0];
            "grant_type" = "authorization_code";
            "code" = $code;
        }
        $new_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $new_body
    }
    catch [System.Exception] {
        Write-Host $Error
    }
    Save-GoogleCredential $new_credential
    return $new_credential
}

Export-ModuleMemberFunction Get-GoogleCredential

 
gmail_sender.ps1

function ConvertTo-Base64Url($str){
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($str)
    $b64str = [System.Convert]::ToBase64String($bytes)
    $without_plus = $b64str -replace '\+', '-'
    $without_slash = $without_plus -replace '/', '_'
    $without_equal = $without_slash -replace '=', ''

    return $without_equal
}

function Send-Gmail(){
    $mail = @{
        from = "example@gmail.com";
        to = "example+to@gmail.com";
    }

    $path = Join-Path . "google_credential.psm1"
    Import-Module -Name $path
    $credential = Get-GoogleCredential
    Write-Host $credential

    if (-not $credential){
        Write-Host "Not Authenticated."
        return
    }

    $dll = Join-Path . "AE.NET.Mail.dll"
    Add-Type -Path $dll
    $msg = New-Object AE.Net.Mail.MailMessage

    $from = New-Object System.Net.Mail.MailAddress $mail["from"]
    $msg.From = $from
    $to = New-Object System.Net.Mail.MailAddress $mail["to"]
    $msg.To.Add($to)
    $msg.ReplyTo.Add($from)

    $msg.Subject = "gmail api subject"
    $msg.Body = "body: ハロー Gmail API!"

    $sw = New-Object System.IO.StringWriter
    $msg.Save($sw)
    $raw = ConvertTo-Base64Url $sw.ToString()
    $body = @{ "raw" = $raw; } | ConvertTo-Json

    $user_id = "me"
    $uri = "https://www.googleapis.com/gmail/v1/users/$($user_id)/messages/send?access_token=$($credential.access_token)"

    try {
        $result = Invoke-RestMethod $uri -Method POST -ErrorAction Stop -Body $body -ContentType "application/json"
    }
    catch [System.Exception] {
        Write-Host $Error
        return
    }
    Write-Host $result
}


# エントリポイント
Send-Gmail

 
また、同じ内容でGitHubにも上げてあります。自分の理解用なので、いくつかコメントが入っています。
PowerShell_misc/gmail_api at master · thinkAmi/PowerShell_misc

*1:元はドイツ語ですが、英語に翻訳するとだいぶ見やすくなります

PowerShellからイベントログの内容をメールで送信する

PowerShell

Windowsのイベントログの内容をメールで送信したい場合、今まではNotifEventLogSecondを使っていました(現在では開発終了)。
NotifEventLogSecond - With nothing better to do

ソースコードは公開されています。
With nothing better to do

 
今後どうしようかと考えましたが、PowerShellでも同様のことができるとの記載があったため、

  • 実現するために使うもの
    • PowerShellGet-WinEventコマンドレットとタスクスケジューラ
  • 対象のイベントログのレベル
    • 重大・エラー・警告の3つ
  • メールの送信タイミング
    • イベントログに出力された時にメール送信
    • 一日分のイベントログ内容をメール送信

という形で試してみることにしました。

 
なお、Get-EventLogでもイベントログを取得できますが、両者の違いは以下が参考になりました。。
遥佐保の技術メモ:[PowerShell]不定期イベントログを捕まえる!nmcapとPowerShellで必要なイベントログのみ取得する(2/2) - livedoor Blog(ブログ)

 
目次

 

環境

 

メールの内容について

NotifEventLogSecondを使っていた際は、

  • 日時
  • レベル
  • ログの名前
  • ソース
  • タスクのカテゴリ
  • イベントID
  • キーワード
  • ユーザー
  • コンピューター
  • オペコード
  • メッセージ

を送信していたため、PowerShellでも同じようにしたいと考えました。

 
Get-WinEventコマンドレットの戻り値は System.Diagnostics.Eventing.Reader.EventLogRecordオブジェクトのようでした。
Get-WinEvent - TechNet

MSDNによると、EventLogRecordからほぼ同じような内容が取得できそうでした。
EventLogRecord クラス (System.Diagnostics.Eventing.Reader) - MSDN

 
ただ、

  • ユーザー名
  • メッセージ

についてはそのままでは取得できなかったので、調べてみました。

 

ユーザー名

以下を参考に、UserIdからユーザー名へと変換するのが良さそうでした。
ドメイン名、ユーザ名と関連するSIDを相互変換するPowerShellスクリプト - YOMON8.NET

(New-Object System.Security.Principal.SecurityIdentifier($event.UserId)).Translate([System.Security.Principal.NTAccount]).Value

 

メッセージ

EventLogRecordクラスのプロパティにはメッセージそのものはありませんでした。そのため、Propertiesプロパティに含まれているのかなと考えました。
EventLogRecord.Properties プロパティ (System.Diagnostics.Eventing.Reader) - MSDN

試してみたところ、

# 本来は、「サービス "BITS" (DLL "C:\Windows\System32\bitsperf.dll") の Open プロシージャに失敗しました。...」が欲しい
$log_property = ""
foreach ($p in $event.properties){
    $log_property += $p.value
}

# => BITSC:\Windows\System32\bitsperf.dll82 0 0 0 0 0 0 0

メッセージではありませんでした。

 
PowerShell独自のプロパティの追加があるのかなと考え、以下を参考に.NETのプロパティ以外を表示してみます。

$event | Get-Member -Force | Where-Object {
    ($_.MemberType -ne "Method" -and
     $_.MemberType -ne "Property"
    )
}

# 結果
pstypenames       CodeProperty
psadapted         MemberSet
psbase            MemberSet
psextended        MemberSet
psobject          MemberSet
PSStandardMembers MemberSet
Message           NoteProperty

NotePropertyとしてMessageが追加されていました。

Messageプロパティの中身を見たところ、イベントログのメッセージと同じだったため、これを使うことにします。

 

イベントログに出力された時にメール送信

検知方法について

「イベントログに出力された時」を検知する場合の方法として、

がありました。

個人的な興味として、タスクスケジューラと組み合わせてみたかったため、今回は後者の方法で実装します。

 

Get-WinEventのフィルタについて

Get-WinEventコマンドレットには取得時のフィルタが必要です。フィルタの方法としては、

  • Get-EventLog | Where-Object
  • Get-EventLog -FilterHashTable

がありますが、前者だと全件取得後の絞込によりパフォーマンスが厳しいため、後者を使います。
イベントログから特定のイベントを抽出 at SE の雑記

 
フィルタとして使用するハッシュは、

$filter = @{
    LogName = $log_name;
    Level = 1,2,3;
    StartTime = $start_date;
    EndTime = $end_date;
}

とし、それぞれ

  • LogNameは、PowerShellスクリプトの引数として、ログ名をカンマで結合したものを与える
  • Levelは、1(重大)、2(エラー)、3(警告)の3つ
  • StartTimeは、イベントログで呼ばれた時から30秒前
    • $start_date = (Get-Date).AddSeconds(-30)
      • イベント発生してから30秒以上経過してからの起動はないものと想定
  • EndTimeは、現在時刻 (Get-Date)

とします。

 

既知のイベントログエラーの除外について

Get-WinEventコマンドレットでは、除外フィルターの設定が見当たりませんでした。そのため、以下を参考に除外リストを作り、除外することにしました。
UranuxTech blog: [PowerShell] Get-WinEventで必要なログのみを取得する

# 除外リスト
$exclusions = @(
    @{
        Id = 9999;
        LogName = "Application";
    }
)

# Get-WinEventの戻り値$eventsから、既知のものを除外
foreach($e in $exclusions){
    $events = $events | Where-Object { -not (
        $_.Id -eq $e.Id -and
        $_.LogName -eq $e.LogName
    )}
}

 

文字列中の変数展開について

文字列に変数を埋め込む場合、

$val = "ほげ"
"値 $val"
# => 値 ほげ

としますが、そのままではオブジェクトのプロパティは展開できません。

方法を探したところ、以下にありました。
リテラル - 文字列 | powershell チートシート - Qiita

$val.name = "ほげ"
"値 $($val.name)"
#=> 値 ほげ

変数を$()で囲めば良いようです。

 

全体のコード

上記を踏まえたコードは以下の通りです。

$mail = @{
    from = "example+from@gmail.com";
    to = "example+to@gmail.com";
    smtp_server = "smtp.gmail.com";
    smtp_port = 587;
    user = "example+to@gmail.com";
    password = "1234";
}


function Send-Gmail($mail, $msg){
    $password = ConvertTo-SecureString $mail["password"] -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential $mail["user"], $password
    $host_name = [Net.Dns]::GetHostName()
    Send-MailMessage -To $mail["to"] `
                     -From $mail["from"] `
                     -SmtpServer $mail["smtp_server"] `
                     -Credential $credential `
                     -Port $mail["smtp_port"] `
                     -Subject "$host_name ログ" `
                     -Body $msg `
                     -Encoding UTF8 `
                     -UseSsl
}


# オブジェクトのプロパティを示すため、カッコでくくる
# 念のため、呼ばれた前30秒のログを取得する
$start_date = (Get-Date).AddSeconds(-30)
$end_date = Get-Date

# 除外するイベントログ情報
$exclusions = @(
    @{
        Id = 9999;
        LogName = "Application";
    }
)

# ログ名は、引数にカンマ区切りでセットする
# 例) System,Application
$log_name = $args[0] -split ","

# FilterHashTable用のフィルタ
$filter = @{
    LogName = $log_name;
    Level = 1,2,3;
    StartTime = $start_date;
    EndTime = $end_date;
}

# FilterHashTableでフィルタした後のイベントを取得
$events = Get-WinEvent -FilterHashTable $filter

# 除外指定されているイベント情報は送信しない
foreach($e in $exclusions){
    $events = $events | Where-Object { -not (
        $_.Id -eq $e.Id -and
        $_.LogName -eq $e.LogName
    )}
}


$msg = @"
対象イベント件数: $($events.Count)
--------------------------------------------------


"@

foreach($event in $events){
    $log_task_category = if ($event.Task){ "$($event.TaskDisplayName) ($($event.Task))" } else { "なし" }
    $log_edited_user_id = if ($event.UserId){ "($($event.UserId))" } else { "" }
    
    $log_user_name = if ($event.UserId) {
        # http://yomon.hatenablog.com/entry/2015/06/19/183522
        (New-Object System.Security.Principal.SecurityIdentifier($event.UserId)).Translate([System.Security.Principal.NTAccount]).Value
    } else { "" }

    $log_property = ""
    foreach ($p in $event.properties){
        $log_property += $p.value
    }

    $msg += @"
日時: $($event.TimeCreated.ToString("yyyy/MM/dd HH:mm:ss"))
レベル: $($event.LevelDisplayName) ($($event.Level))
ログの名前: $($event.LogName)
ソース: $($event.ProviderName) 
タスクのカテゴリ: $log_task_category
イベントID: $($event.Id)
キーワード: $($event.KeywordsDisplayNames)
ユーザー: $($log_user_name) $log_edited_user_id
コンピューター: $($event.MachineName)
オペコード: $($event.OpcodeDisplayName) ($($event.Opcode))
プロパティ:
$log_property


メッセージ:
$($event.Message)

--------------------------------------------------


"@
}

Send-Gmail $mail $msg

 
テストしてみます。Write-EventLogでイベントログへ書き込みます。
PowerShellでイベントログに情報を出力 at SE の雑記

Write-EventLog -LogName Application -EntryType Error -Source Application -EventId 1001 -Message "test event"

 
その後PowerShellスクリプトを実行し、イベントログの内容がメール送信されることを確認します。

 

一日分のイベントログ内容をメール送信

基本はイベント発生ごとと同じです。

変更箇所は、

  • StartTimeを、(Get-Date).AddDays(-1)へと変更
  • LogNameを、イベントビューアーのカスタムビューにある管理イベントで設定されているものへと変更

です。

管理イベントで設定されているイベント名は、管理イベントのプロパティ > フィルターの編集 > XMLタブを選択し、

<QueryList>
  <Query Id="0" Path="Application">
    <Select Path="Application">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
    <Select Path="Security">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
    <Select Path="System">*[System[(Level=1  or Level=2 or Level=3)]]</Select>
...
  </Query>
</QueryList>

を確認します。この中の<Select>タグのPath属性値がログ名になります(例: Application、Security、System など)。

 
そのため、それらを配列にして、LogNameに渡します。

$log_name = @(
    "Application", "Security", "System"
)

$filter = @{
    LogName = $log_name;
# ...
}

 

タスクスケジューラの設定

以下を参考に、PowerShell向けのタスクを追加します。
Tech TIPS:WindowsのタスクスケジューラーでPowerShellのスクリプトを実行する際には「パス」に注意 - @IT

 

イベントログに出力された時にメール送信
タブ名 項目
全般 セキュリティオプション ●ユーザーがログオンしているかどうかにかかわらず実行する を選択
■最上位の特権で実行する にチェック
トリガー タスクの開始 イベント時
設定 カスタム
トリガーのフィルター イベントレベル 重大、エラー、警告
●ログごと 選択
イベントログ Application,システム
操作 操作 プログラムの開始
プログラム/スクリプト C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
引数の追加 -Command "D:\Sandbox\path\to\send_log_by_event.ps1 System,Application" *1

 

一日分のイベントログ内容をメール送信
タブ名 項目
全般 セキュリティオプション ●ユーザーがログオンしているかどうかにかかわらず実行する を選択
■最上位の特権で実行する にチェック
トリガー タスクの開始 スケジュールに従う
設定 毎日(任意の時間をセット)
〃 - フィルター イベントレベル 重大、エラー、警告
〃 - フィルター ●ログごと 選択
〃 - フィルター イベントログ Application,システム
操作 操作 プログラムの開始
プログラム/スクリプト C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
引数の追加 -Command "D:\Sandbox\path\to\send_log_per_day"

 

ソースコード

GitHubに上げました。
PowerShell_misc/send_event_log_mail at master · thinkAmi/PowerShell_misc

ディレクトリの中にあるファイルはそれぞれ、

  • send_log_when_handled_event.ps1 (イベントログに出力された時にメール送信)
  • send_daily_log.ps1 (一日分のイベントログ内容をメール送信)

です。

 

その他参考

*1:PowerShellの引数は、「.ps1 + 半角スペース + 引数」として設定します

PowerShellを使って、SMTPでGmailを送信する

Google PowerShell

以前、PowerShellからGmailを送信したことがありました。
Windowsでキーサインパーティに参加した時のまとめ & caffっぽいツールを作ってみた - メモ的な思考的な

 
読み返してみると、

  • System.Net.Mail
  • System.Web.Mail

を使っており、PowerShellSend-MailMessageコマンドレットは使っていませんでした。

 
そこで今回は

  • System.Net.Mail
  • Send-MailMessage

を使ってメールを送信してみます。

 
メールまわりの情報は以下の通りとします。

$mail = @{
    from = "example@gmail.com";
    to = "example+to@gmail.com";
    smtp_server = "smtp.gmail.com";
    smtp_port = 587;
    user = "example@gmail.com";
    password = "1234";
}

 
なお、SMTPのポートについては、Gmailでは465よりも587のほうが良さそうです。

 

環境

 

System.Net.Mail版

$client = New-Object Net.Mail.SmtpClient($mail["smtp_server"], $mail["smtp_port"])

# GmailはSMTP + SSLで送信する
$client.EnableSsl = $true

# SMTP Authのため、認証情報を設定する
$client.Credentials = New-Object Net.NetworkCredential($mail["user"], $mail["password"])

$msg = New-Object Net.Mail.MailMessage($mail["from"], $mail["to"], "subject", "body: Hello Net class")

$client.Send($msg)

 

Send-MailMessage

$password = ConvertTo-SecureString $mail["password"] -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential $mail["user"], $password

Send-MailMessage -To $mail["to"] `
                 -From $mail["from"] `
                 -SmtpServer $mail["smtp_server"] `
                 -Credential $credential `
                 -Port $mail["smtp_port"] `
                 -Subject "subject" `
                 -Body "body: Hello Cmdlet" `
                 -Encoding UTF8 `
                 -UseSsl

 

ソースコード

GitHubに置きました。send_gmail_by_smtpディレクトリ以下が今回のファイルです。
thinkAmi/PowerShell_misc: Misc collection of PowerShell code

 

参考