google map API でrailsアプリにmapを描画

前書き

rails でモデルが持っている住所情報からgoogleマップを表示するjsを書いたときに詰まったのでメモ。

参考

地名や住所から緯度・経度を取得する

内容

google map api では緯度経度からmapを描画することになっている。しかしながらモデルが持っている情報は住所である。ググったところ、Geocoderというgemについての記事がたくさん見つかったので使ってみた。どうやらこのライブラリは内部に住所や施設の名前などから緯度経度までいろんな情報を持っている優秀なgemだということがわかった。しかしながら、例えば私の出身地である静岡県湖西市などの都会ではない場所の情報は持っておらず、mapが表示できない問題があることがわかった。gemに地図の情報がたくさんあるのはすごいのだけど、なんでgemに頼る必要があるのか?一番地図情報を持ってるにはgoogle mapに決まってるよね。と考え直してさらにググったところ、上記の参考サイトを見つけてやっぱりgoogle map自体もGeocoderというクラスを持っていることがわかった。そして下記のように実装。うまく描画された。

<div id="map"></div>

<script>
function initMap(latlng) {
  var map = new google.maps.Map(document.getElementById('map'), {
    center: latlng,
    zoom: 16
  });

  var marker = new google.maps.Marker({
    position: latlng,
    map: map
  });
}

function getLatLng() {
  var geocoder = new google.maps.Geocoder();

  geocoder.geocode({
    address: "<%= @place.prefecture %><%= @place.city %><%= @place.street %>"
  }, function(results, status) {
    if (status == google.maps.GeocoderStatus.OK) {
      for (var i in results) {
        if (results[i].geometry) {
          var latlng = results[i].geometry.location;
          initMap(latlng)
        }
      }
    }
  });
}
</script>

<script src="https://maps.googleapis.com/maps/api/js?key=[APIのキー]&callback=getLatLng"
async defer></script>

結論

google map apiを利用するときにはgemではなくgoogle.maps.Geocoderクラスを利用するのが良い。

その他

mapのサイズ等cssをscssファイルに書くのを忘れないように。

Scrapy Item Pipeline

参考資料

Item Pipeline — Scrapy 1.5.1 documentation

バージョン

  • Python 3.6.5

  • Scrapy 1.5.1

  • pymongo 3.7.2

概要

spiderによって取得したアイテムは Item Pipelineへ送られる。 Item Pipelineには主に4つの役割がある。

  • HTMLデータを整理する
  • データのvalidationを行う
  • データにダブりがないか確認して、あれば弾く。
  • DBにアイテムを保存する。

pipelineクラスは四つのメソッドによって実装する。

process_item(self, item, spider)

このメソッドは全てのpipelineコンポーネントで呼び出される。

このメソッドはスパイダーが取得したitemをdict型なり、下位のオブジェクトなりデータベースに適した形に整形する役割を持っている。

    open_spider(self, spider)

このメソッドはspiderが開始されたときに呼び出される。

close_spider(self, spider)

このメソッドはspiderが終了したときに呼び出される。

from_crawler(cls, crawler)

このメソッドが宣言されている場合はcrawler本体からpipelineクラスを呼び出し、そのインスタンスを作ることができる。これがpipelineとその他のコンポーネントとの通路のような役割を果たす。

具体例

下記はmongoDBと接続する時の例。

# 1
import pymongo

class MongoPipeline(object):
    # 2
    collection_name = 'scrapy_items'

    # 3
    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
    # 4
    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get('MONGO_URI'),
            mongo_db=crawler.settings.get('MONGO_DATABASE', 'items')
        )
    # 5
    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
    # 6
    def close_spider(self, spider):
        self.client.close()
    # 7
    def process_item(self, item, spider):
        self.db[self.collection_name].insert_one(dict(item))
        return item
  1. pymongoを利用してmongoDBと接続する。
  2. collection_nameはitemクラスの名前がつけられる。
  3. 初期化メソッドnewで呼ばれる。3で説明。
  4. from_crawlerメソッド。crawler本体で呼ばれるメソッド。ここでこのクラスのインスタンスが作られる。内部的にsettingsファイルから変数MONGO_URIとMONGO_DATABASEを取ってきてそれを2番のようにアクセサメソッドとして格納。
  5. 2/3の設定を使ってmongoDBに入場。
  6. mongoDBから退場。
  7. process_itemメソッド。ここでmongoDBにitemがうまく入るように整形。dbとその中のitemクラスを指定して引数のitemを辞書型に変換してinsertしている。

その他

activating-an-item-pipeline-component (重要)

これを設定しないとコンポーネント自体が動かない。

Item Pipeline — Scrapy 1.5.1 documentation

pythonインスタンスメソッドとクラスメソッド

@classmethodとは?第一引数がselfのメソッドはインスタンスメソッド。インスタンスメソッドはその名の通り作られたインスタンスに持たせるメソッド。クラスメソッドはクラスが持っているメソッド。ここではmongo_uriとmongo_dbの二つのアクセサメソッドをもつクラスを返すメソッドが定義されている。

pymongo

Installing / Upgrading — PyMongo 3.7.2 documentation

pymongo-3.7.2をインストール。インストール時のメッセージ特になし。

Scrapy Tutorial

前書き

python初心者です。scrapyを使う機会を得たので公式tutorialで学習。

参考資料とバージョン

公式ドキュメント

Scrapy 1.5.1

python 3.6.5

学習記録

まずはコマンドラインから

scrapy startproject tutorial

このコマンドでproject作成。tutorialの部分が名前。これによりいくつかのディレクトリとファイルが生成される。scrapyはライブラリではなくフレームワークなので骨組みが決まっている。早速tutorial/spidersディレクトリにquotes_spider.pyというファイルを作成。これがいわゆるspider。とりあえずお手本通り作る。

    # quotes_spider.py
    
    import scrapy
    
    class QuotesSpider(scrapy.Spider):
        name = "quotes"
    
        def start_requests(self):
            urls = [
                'http://quotes.toscrape.com/page/1/',
                'http://quotes.toscrape.com/page/2/',
            ]
            for url in urls:
                yield scrapy.Request(url=url, callback=self.parse)
    
        def parse(self, response):
            page = response.url.split("/")[-2]
            filename = 'quotes-%s.html' % page
            with open(filename, 'wb') as f:
                f.write(response.body)
            self.log('Saved file %s' % filename)

これを見るとpythonではたぶんクラスを定義するときに引数にクラスを渡すとそれが継承されるようになる。これで新しいQuotesSpiderを作っている。すぐ下のname定義ではプロジェクト内で一意の値を与えないといけない。下記のようにコマンドラインからこの名前で呼び出せるようになる。

    scrapy crawl quotes

これでquotesのクローリングとスクレイピングが実行される。と思いきや、エラーが返ってくる。

python3.7/site-packages/twisted/conch/manhole.py", line 154
    def write(self, data, async=False):

ググったところこのサイトを見つけた。

python3.7とscrapyの組み合わせに何か不具合があるようで仮想環境上のバージョンをpython3.6に戻すと良いらしい。そこで

brew switch python 3.6.6

Error: python does not have a version "3.6.6" in the Cellar. python installed versions: 3.6.5, 3.7.0

と返ってきたので3.6.5にした。これでsyntax errorは出なくなった。上記のコマンドも実行することができた。

返ってきたのがこれ

2018-10-15 14:53:59 [scrapy.core.engine] INFO: Spider opened
2018-10-15 14:53:59 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2018-10-15 14:53:59 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2018-10-15 14:54:00 [scrapy.core.engine] DEBUG: Crawled (404) <GET http://quotes.toscrape.com/robots.txt> (referer: None)
2018-10-15 14:54:00 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/1/> (referer: None)
2018-10-15 14:54:00 [quotes] DEBUG: Saved file quotes-1.html
2018-10-15 14:54:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/2/> (referer: None)
2018-10-15 14:54:01 [quotes] DEBUG: Saved file quotes-2.html
2018-10-15 14:54:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-10-15 14:54:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
,
,
,
2018-10-15 14:54:01 [scrapy.core.engine] INFO: Spider closed (finished)

htmlファイルを作成している。

2018-10-15 14:54:00 [quotes] DEBUG: Saved file quotes-1.html

このhtmlファイルの作成はspiderクラスで定義したparseメソッドが指示している。parseは「解析する」という意味。下記の部分がその定義。

        def parse(self, response):
            page = response.url.split("/")[-2]
            filename = 'quotes-%s.html' % page
            with open(filename, 'wb') as f:
                f.write(response.body)
            self.log('Saved file %s' % filename)

ここでは、urlリクエストの結果がresponseに入って、urlのからページ数を取り出して、それを使ってファイル名をわかりやすく命名している。その後でそれをファイルとして開いている。

open(filename, mode)

ここでのmode 'wb' はwが書き込み、bはバイナリを意味している。wを指定する場合、第一引数のファイル名のファイルがあればそれを上書きし、なければ新規作成される。 そして、responseオブジェクトからbody属性を指定して、ページのhtmlを取得することができた。

    # 1
        def start_requests(self):
            urls = [
                'http://quotes.toscrape.com/page/1/',
                'http://quotes.toscrape.com/page/2/',
            ]
            for url in urls:
                yield scrapy.Request(url=url, callback=self.parse)
    
        def parse(self, response):
            ,
            ,
            ,

上記は下記のように置き換えることもできる。

    # 2
        start_urls = [
            'http://quotes.toscrape.com/page/1/',
            'http://quotes.toscrape.com/page/2/',
        ]
    
        def parse(self, response):
            ,
            ,
            ,

これらは同じ意味である。# 1のstart_requestsはscrapy.Requestを生成するためのメソッドだが、start_urlsをurlのリストで作成すると、それをもっと短い実装で実現することができる。

1ではparseをコールバック関数として明示的に指示しているが、2ではそうしていないがparseは実行される。これはscrapyにおいてはrequestに対してはparseで定義されたメソッドがデフォルトのコールバック関数として定義されているため。

ここを見ればscrapyが返すデータのオブジェクトの動きを調べることができる。

データの抽出方法

$ scrapy shell 'http://quotes.toscrape.com'
quote = response.css("div.quote")[0]
tags = quote.css("div.tags a.tag::text").extract()

scrapy shell を実行することで指定したurlのページの情報がresponseオブジェクトとして利用できるようになる。 下記がquoteとして定義している0番目のdiv.quote

    <div class="quote" itemscope itemtype="http://schema.org/CreativeWork">
            <span class="text" itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>
            <span>by <small class="author" itemprop="author">Albert Einstein</small>
            <a href="/author/Albert-Einstein">(about)</a>
            </span>
            <div class="tags">
                Tags:
                <meta class="keywords" itemprop="keywords" content="change,deep-thoughts,thinking,world" /    > 
                
                <a class="tag" href="/tag/change/page/1/">change</a>
                
                <a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
                
                <a class="tag" href="/tag/thinking/page/1/">thinking</a>
                
                <a class="tag" href="/tag/world/page/1/">world</a>
                
            </div>
        </div>

tagsで定義している部分の要素の指定方法。

"div.tags a.tag::text"

の部分はスペースで区切られていて、親子関係を持っていることがわかる。

予め指定したurlだけでなく、ページ内のリンクを辿ってさらに別のページの情報を取得したい場合。

    next_page = response.css('li.next a::attr(href)').extract_first()
            if next_page is not None:
                next_page = response.urljoin(next_page)
                yield scrapy.Request(next_page, callback=self.parse)

上記はspiderのparseメソッド内の定義。responseオブジェクトのurljoinメソッドは相対パスで書かれているであろうurlを補足するための便利なヘルパー。

    yield scrapy.Request(next_page, callback=self.parse)

によって第一引数のurlをresponseオブジェクトとして取得した結果をさらにparseメソッドに渡している。

まとめ

これでtutorial終了。ポイントになりそうだと思ったのはrequestとresponseのそれぞれのオブジェクトとしての動きとそのデータの受け渡し、あとはparseでresponseをどう処理するかだと思う。ここからyieldしたデータをjson形式でmongoDBに入れていきたいと思っているので Item Pipelineの設定を書いてくことになりそうです。

MacでmongoDBインストール

やりたいこと

MacにMongoDBをインストールして、DBを動かし、mongoインスタンスを立ち上げてみる。

実行の流れ

まずは

brew install mongodb

でインストール。ググったところ

mkdir -p /data/db

でdbのディレクトリを用意しておいてからmongoコマンドを実行すればDBが動き出すとのことだったのでやってみるが

exception in initAndListen: IllegalOperation: Attempted to create a lock file on a read-only directory: /data/db, terminating

みたいなエラーが出てうまくいかない。read-onlyだからダメみたいなことをいってるので

sudo chmod 777 data/db

パーミッションを解放したけど結果は変わらず。ここでようやく公式ドキュメントをまだ読んでないことに気づき、

mongod --dbpath <path to data directory>

の記述を発見。そういえばインストールの時下記のメッセージが出ていたことを思い出す。

To have launchd start mongodb now and restart at login:
  brew services start mongodb
Or, if you don't want/need a background service you can just run:
  mongod --config /usr/local/etc/mongod.conf

これの4行目の部分を実行したらうまくいった。data/dbを作ってはみたものの保存先として認識していないようだった。これでmongoDBが動き出したので公式docにあったように

mongo --host 127.0.0.1:27017

を別のターミナルで実行してmongoインスタンスを立ち上げることができた。よかった。

結論

やっぱり公式ドキュメントを真っ先に読まないといけない。

python3での仮想環境作成

python3 -m venv ディレクトリ名

これでpython3でのvenv(仮想環境)が作られる。

. scraping/bin/activate

このコマンドで仮想環境のなかに入ることができる。

.とディレクトリ名の間には半角スペースを一つ入れる。仮想環境に入るとシェルのプロンプトの先頭に(ディレクトリ名)がつく。

deactivate

仮想環境から抜けるにはdeactivateコマンドを実行する。

仮想環境内ではpipが使えないことがある。その場合は公式ページにしたがって下記のコマンドを順に実行する。

wget https://bootstrap.pypa.io/ez_setup.py -O - | python
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
python get-pip.py

これで作った仮想環境内でScrapyもインストールできる。

pip install Scrapy

『Ruby on Rails5 アプリケーションプログラミング』学習日誌 7

前書き

今日も第6章コントローラについて学習します。あと、ここに書いてあることは単なる学習記録なので事実とは関係がない場合があります。ご了承ください。

ページ範囲

p.312-400

スキーマファイルの実行について

db/schema.rbにはdbの最新のスキーマ情報が記載されている。これはマイグレーションファイルの実行が記録されているからである。

rails db:schema:load

でdbに内容を反映することができる。

コントローラ

コントローラの基本

コントローラの役割は大きく言ってリクエスト情報の取得とレスポンスの生成にある。リクエスト情報とはクライアントから送信された値のこと。コントローラで定義されている各メソッドのことをアクションと呼ぶ。

各種リクエスト情報
種類 概要
ポストデータ httpメソッドがpostのformから受け取った情報。
クエリ情報 urlの?以降に「キー=値&…」で受け取る情報。
ルートパラメータ /1などルートで渡された情報。

これらを1つにまとめて

params[:パラメータ名]

という形でアクセスできるようになっている。ルートパラメータの場合はparams[:id]でアクセスできる。

ストロングパラメーター

例えば下記のようにフィルターとしてコントローラーごとにストロングパラメータをまとめて定義しておくと便利。(scaffoldでは自動生成される)

def user_params
      params.require(:user).permit(:username, :password_digest, :email, :dm, :roles, :reviews_count, :agreement)
    end

でformの内容が受け取られることになる。ここで使われているメソッドのrequireとpermitはいわゆるストロングパラメータと呼ばれるものでformからリクエストを投げたときの返り値のうち必要なものだけを受け取るための仕組みである。requireなどの制限がない場合リクエストの返り値はモデルの全てのパラメータやコントローラ名、アクション名なども含めた情報が帰ってきているので予期せぬ値が入り込まないように、取得する値を限定する意味がある。

リクエストヘッダとサーバ環境変数

ブラウザからサーバに送られる不可視の情報のことをヘッダー情報と呼ぶ(逆方向ならレスポンスヘッダ)。対応している言語やブラウザの種類のことなど。リクエスト時に送信されるヘッダのことをリクエストヘッダと呼ぶ。

request.headers[パラメータ]

で取得できる。またサーバ環境変数も取得することができ、例えば

request.fullpath

などがある。これはその処理のリクエストurlが得られる。

アクションでの処理結果の出力について

アクションでの処理結果の出力のためのメソッド

メソッド 概要
render テンプレートの呼び出しなど、汎用的な結果出力の手段
redirect_to 指定されたアドレスに処理を移動
send_file 指定されたファイルに出力
send_data してされたバイナリデータを出力
head 応答ヘッダーのみを出力

renderメソッド

上記のメソッドの中で最も基本的なもの。出力について明示的な指定がない場合は暗黙的にrenderが呼び出されて、受け取ったリクエストの内容から推測される。テンプレートを呼び出す場合が多いが、json形式のデータを出力したりもできる。

redirect_toメソッド

引数のurlに処理を移動する。urlは文字列かハッシュ形式で渡すことができる(url_forメソッドと同様)。

send_fileメソッドとsend_dataメソッド

これらは指定されたパスにあるファイルやバイナリデータを読み込み、その内容をブラウザに送信する。

状態管理

状態管理とは複数のアクション間で情報を維持するための仕組みのこと。httpは本来ブラウザからのリクエストに対してサーバがレスポンスを返すだけのシンプルな仕組みになっている。そのため、複数回に渡るアクションは連続性を持たず、サーバはそれぞれを独立した別のクライアントとのやりとりだと認識している。しかし、これはそれぞれのユーザに合わせたサービスを提供したwebアプリにとっては致命的な制約になってしまう。なので何らかの方法で情報を維持するための仕組みが必要でそのことを状態管理と呼ぶ。下記のようにいくつかの方法がある。

状態管理の方法

機能 概要
クッキー ブラウザ側に持たせるテキスト情報
セッション クッキー、キャッシュ、dbなどに状態情報を保存する仕組み
フラッシュ 現在と次のリクエストでのみ維持できる特殊なセッション情報

クッキーとはクライアント側に保持される簡易的なテクストファイルのことである。原則ではサーバがクライアントにデータを書き込むことは許していない中でクッキーは唯一の例外。cookieはレスポンスヘッダ経由でブラウザ等に発行される。また同一クライアントの2回目以降のアプリ訪問時にリクエストヘッダ経由でサーバに送信される。それによってユーザの状態を判断し、それに基づき最適化されたリアクションをサーバ側から返すことができる。リクエストヘッダやレスポンスヘッダなどのヘッダ情報とはブラウザ⇄サーバ間で送信されるhttpメッセージに伴う不可視の情報のこと。cookiesメソッドはオプション名:値のハッシュ形式で設定することができる。

オプション名 概要 値の例
value クッキーの値 -------
expires クッキーの寿命 3.hours.from_now
domain クッキーが有効なドメイン yshrfmru.com
path クッキーが有効なパス /test/
secure trueの場合、暗号化通信でのみクッキーを送信 true
httponly httpクッキーを有効にするかどうか true

上の二つは必須のオプション。expiresを設定しない場合クッキーの寿命はブラウザを閉じるまでとなる。httponlyを有効にすることによって、jsからのクッキーアクセスができなくなり、xss攻撃によるクッキー盗聴を防ぐことができる。

session/flash

セッションとフラッシュについてはすでに取り上げたので補足のみ。sessionではその情報の保存先をクライアントサイド(クッキー)かサーバサイド(DB、キャッシュ)から選ぶことができる。ただし、セッションのidはクッキーに持たせることで、ユーザを区別する。

send_file

指定されたパスのファイルを読み込んで、その内容をクライアントに送信する。設定できるオプションがいくつかある。

オプション 概要 デフォルト
filename ダウンロード時に使用するファイル名 元のファイル名
type コンテンツタイプ(モデルに対してはctypeなどで指定可) application/octet-steam
disposition ファイルをブラウザインラインで表示するか(:inline)ダウンロードするか (:attachment) :attachiment
status ステータス 200 ok
url_based_filename ダウンロード時のファイル名をurlをもとに生成するかどうか false

loggerオブジェクト

アクションメソッドなどでの途中経過を確認するためログを標準出力やログファイルに出力するために利用できる。表の上の方ほど優先順位が高い。

メソッド 概要
unknown(msg) 不明なエラー
fatal(msg) 致命的なエラー
error(msg) エラー
warn(msg) 警告
info(msg) 情報
debug(msg) デバッグ情報

json.jbuilderファイル

scaffold を利用した場合はviewフォルダに自動的に生成される。jsonデータを生成するためのテンプレート。erbとは違い、rubyスクリプトがそのまま記述されている。

json.array! @books, partial: 'books/book', as: :book

json.array!メソッドでは第一引数にレコードのインスタンスをとり、partial:オプションで部分テンプレートのurlをとる。ここで呼び出す部分テンプレートはjson.jbuilder拡張子のついたjsonデータ用のテンプレートである。as:オプションで指定した値をパラメータとして、部分テンプレートから@booksのjsonにアクセスすることができる。

### フィルター

before_action 、 after_action とaround_action でフィルターを設定することができる。それらの引数になるmethodがフィルターとなる。オプションでonlyで指定すると指定したメソッドのみにフィルターをかけられる。また、exceptで指定するとフィルターをかけないメソッドを指定することができる。これらのオプションを指定しない場合は全てのメソッドにフィルターが適用される。なお、only/exceptはなるべく使わないようにコントローラの設計をすることが大切である。アクションの実行のフローが複雑になるとコードの可読性が損なわれるためである。

フィルターは予期せぬ動作を防ぐために、privateメソッドとして定義した方がいい。around_actionで指定するフィルターにはyieldを定義することによって、フィルターの対象となるメソッドの実行するタイミングを明示する必要がある。また、フィルターによって条件分岐を行い、renderメソッドを通すことによって、アクションをスキップすることもできる。

フィルターの継承

フィルターはコントローラをまたいで適用することもできる。継承関係にあるコントローラではフィルターも継承することができる。例えばApplication Controllerには全てのアクションに共通するフィルターを儲けることが出来る。実行される順は継承の親になるコントローラほど早くなる。

skip_XXXX_actionメソッド

XXXXの部分はbefore、after、aroundなどが入るが、これを宣言することで引数のフィルターをスキップすることが出来る。ただしスキップできるのは基底コントローラで定められたフィルターのみである。

authenticate_or_request_with_http_basic

authenticate_or_request_with_http_basicメソッドはそれ単体で認証の要求から入力されたパスワードの判定までを行う。ブロックにはnameとパスワードを渡してブロック内でその妥当性を判定するためのロジックを記述することが出来る。

Action Pack Variants

レスポンシブデザインを使わずにviewをデバイスごとに合わせて表示する方法のこと。

ruby request.variant = :mobile

のようにrequestオブジェクトのvariantプロパティにデバイスを指定しておくことによって適用されるviewが自動的に選択される。これはヘッダ情報から条件分岐すると良い。上記の例で言えばindex.html+mobile.erbが描画されることになる。指定がない場合はindex.html.erbが描画される。ちなみにrequestオブジェクトとはクライアントブラウザから送られてくる様々なリクエスト情報を含むオブジェクト。

requestのプロパティ 返り値
host リクエストで使用されるホスト名
domain(n=2) ホスト名の右から数えてn番目のセグメント
method リクエストで使用されたhttpメソッド
get?など httpメソッドが指定したものだった場合にtrueを返す
headers リクエストに付随するヘッダ情報
port リクエストに使用されたポート番号
protocol http://などプロトコル名に://を付けたもの
query_string urlの一部で使用されているクエリ文字(?以降)
remote_ip クライアントのipアドレス
url リクエストで使用されるurl全体

add_flash_type メソッド

フラッシュではnoticeキーやalertキーが用意されている。これらはredirect_toメソッドの引数として渡したり、view側でローカル変数のように使用したりすることが出来る。このようなキーをadd_flash_typeで追加することができる。

作業日誌2

前書き

今回やりたかったことはstaffの新規登録をしようとするとログイン認証がかかる→companyのパスワードでログイン→ログインしたcompanyを外部キーとして新規staffをcreateという流れ。その過程で学習したこととハマったところをまとめる。話題が飛んだりエラーの解決法はわかったけど、原因がわからなかったりする。わかったら追記する。

学習ポイント

フィルタとログイン認証

ルートでstaff/newをリクエストするとstaffコントローラのnewメソッドに飛ぶが

before_action :check_logined, only: [:new, :edit, :update, :destroy]

のようにフィルタをかけているので、check_loginedの処理に入る。check_loginedの部分(フィルタのメソッド)は通常privateメソッドとして定義する。before_actionメソッドでオプションonly: で指定したメソッドに先立ってcheck_loginedを実行させることができる。

    # app/controllers/staff_controller.rb
    
    def check_logined
    
          # sessionに:companyがあるかどうかで条件分岐
    
          if session[:company] then
    
            # begin...endで囲むと...の部分が最低一回は繰り返される。
    
            begin
    
              # 新たにインスタンス変数(テンプレート変数)を作る
    
              @company = Company.find(session[:company])
    
            # rescueで例外処理
    
            rescue ActiveRecord::RecordNotFound
    
              reset_session
    
            end
              
          end
          # companyがなければ、、
          unless @company
            # flashメソッドを使って現在地へのパスをredirect_toの先に渡している。
            flash[:referer] = request.fullpath
            # redirect_toメソッドで引数urlの位置に処理を飛ばすことができる。
            # ここではloginコントローラのindexを指定している。
            # もしコントローラクラスにindexがあればその処理を行うが
            # ない場合はviews/login/index.html.erbを表示。
            redirect_to controller: :login, action: :index
          end
        end

予めcompanyログインをしてない場合、unless以下の処理に入る。redirect_toは 処理を引数のurlに移動させるメソッド。引数はurlぽくなくてハッシュにしている。これはurl_forと同じ指定方法。

url_forメソッド

ルート定義から動的にurlを生成する。これ自体を使用する機会は多くはないが、引数やオプションの指定の仕方はredirect_toやform_tagでのurlのして方法とも共通するので理解を深めておくと便利。いずれもurlを動的に生成するための指定の仕方。

オプション 概要
controller コントローラー
action アクション名
host ホスト名(現在のホストを上書き)
protocol プロトコル名(現在のプロトコルを上書き)
anchor アンカー名
only_path 相対パスを返すかどうか。hostの指定がない場合、defaultはtrue
trailing_slash 末尾にスラッシュをつけるかどうか。
user http認証に使用するユーザ名
password http認証に使用するパスワード

上のredirect_toではこのようにした。

    redirect_to controller: :login, action: :index

この場合はloginコントローラのindexアクションを指定している。生成されるurlは/login/index。なのでroutes.rbにもget 'login/index'が必要。この場合は現在地がstaffコントローラの中にいるのでcontroller: :loginを明示的に指定する必要がある。redirect_toの先をみてみる。

    # app/views/login/index.html.erb
    <p style="color: Red"><%= @error %></p>
    # 
    <%= form_tag action: :auth do %>
      <div class="field">
        <%= label_tag :name, '企業名' %><br />
        <%= text_field_tag :name, '', size: 20 %>
      </div>
      <div class="field">
        <%= label_tag :password, '企業パスワード' %><br />
        <%= password_field_tag :password, '', size: 20 %>
      </div>
      <%= hidden_field_tag :referer, flash[:referer] %>
      <%= submit_tag 'ログイン' %>
    <% end %>

対して↑のform_tagのアクションのpost先は

    form_tag action: :auth

のようにアクションパラメータのみ指定している。こうするとまず各メソッドが現在地を基点にurlを生成するためコントローラはデフォルトでloginが指定される(loginディレクトリのviewだから)。具合的に生成されるurlは/login/authとなる。form_tagはデフォルトでpostメソッドでパラメータをurl先に渡すためroutes.rbにはpost 'login/auth'の定義がないといけない。次にloginコントローラのauth。

     def auth
      # paramsで受け取った:nameでCompanyインスタンスを作る。
       company = Company.find_by(name: params[:name])
      # companyインスタンスが作られていて、かつauthenticateできれば
       if company && company.authenticate(params[:password]) then
         #reset_session
         #セッションにcompanyの主キーを入れる。
         session[:company] = company.id
        # :refererにはflash[:referer]が入っているためnewの画面に戻れる。
         redirect_to params[:referer]
       else
         flash.now[:referer] = params[:referer]
         @error = 'ユーザ名/パスワードが間違っています。'
         render 'index'
       end
     end

    redirect_to params[:referer]

の:refererはapp/controllers/staff_controller.rbで flash[:referer] = request.fullpathとしてflashメソッドで定義しているものが次のapp/views/login/index.html.erbで

    <%= hidden_field_tag :referer, flash[:referer] %>

で:refererキーでpostされているものを受け取っている。なので中身はrequest.fullpathということになるがこれはその地点のルートからのパスが戻り値になるので、実際には/staff/newが入っていることになる。つまり、ログイン認証で入れなかった場所にとぶということ。その前の無事パスワード認証をクリアした時点でsession[:company]にさっき作ったcompanyインスタンスのidが格納されている。

セッション

セッションとはユーザ単位でデータを管理するための仕組み。

    session[:name] = value

のようにデータを保存する。sessionにキーを持たせてそこに値を入れる。取り出すときも

    @name = session[:name]

のようにする。セッションの寿命はデフォルトではブラウザを閉じるまでである。

    session[:name] = nil  #特定のキーのセッションを消去
    reset_session         #全てのsession情報を削除

のようにして記録を消すことができる。

テンプレート変数と部分テンプレート

/staff/newでコントローラに行くと改めてフィルターであるcheck_loginedがかけられる。今度はsession[:compaby]を持っているのでそこに格納されている主キーを元にテンプレート変数@companyが作られる。 viewメソッドでは@staff = Staff.newでstaffインスタンスが初期化され、staffs/new.htmlに行き、そこで部分テンプレートの_formが呼び出される。ここが超ハマったところ。

    <%= render 'form', staff: @staff, company: @company %>

ここでとても 重要なのは@company(や@staff)を変数経由で部分テンプレートに渡すということ。

例えば部分テンプレートが

    # app/views/staffs/_form.html.erb
    <%= form_with(model: staff, local: true) do |form| %>
    <div class="field">
    <%= form.hidden_field :company_id, :value => company.id %>
    </div>
    <div class="actions">
        <%= form.submit %>
    </div>
    <% end %>

となっている場合、company.idがいつでも参照できる。もし仮にcompany: @company の定義をせずに部分テンプレートで

    :value => @company.id

のように直接 @companyを参照しようとすると、staffs_controllerのcheck_loginedで@companyを作ってからそのままメインのテンプレートを経由して部分テンプレートまできた場合にはテンプレート変数@companyが生きている。しかしそれをpostした時に@companyの値がnilになってしまう。どのルートを経てもその値を保持するためにはメインのテンプレートで引数に入れておくことが重要になる。なぜnilになるのかはわからない。インスタンス変数を経由してインスタンスの値にアクセスできるのが最初にてviewを描画したときだけ、ということ何だと思うが逆にcompany: @companyならおっけーなの何で?renderが何かしているのかもしれない。

2018/08/19 追記
結局コントローラでsaveした時にformの値を見ることになるようでこの時にsaveするコントローラは@companyの出どころを確認しない。なのでcompanyパラメータを経由して@companyを参照できるようにしておくと良いということだと思う。formを描画するときとpostを受け取る時はそれを扱うコントローラが違うので変数の扱いも違うということ。

参考:Railsの部分テンプレートからインスタンス変数を参照するのはやめよう。 | CreativeStyle


submitするときは

    <%= form_with(model: staff, local: true) do |form| %>

のform_withメソッドによりURLとスコープが自動推測される。この場合はurlとして

    action="/staffs"

が自動生成され、formの元として渡しているモデルがメインテンプレートのstaff: @staffによって@staffになっており、@staffはstaff/newを通った時に@staff = Staff.newで初期化されているのでform_withによってcreateアクションが要求される。すでにこの時にはパーシャルに@companyを入れた場合それがnilになっているので考えられる仮説としてはsubmitした後に再度テンプレートが描画されるタイミングがあって、その際に@companyを格納している変数を参照することはできるが、コントローラーで定義したテンプレート変数は参照することができない、ということが起こっているのかもしれない。よくわからないが、コントローラで生成したテンプレート変数はメインのテンプレート上で別の変数に格納した上で部分テンプレートに渡すのが重要だということがわかった。

感想

やっぱりコントローラーのことが全然わかってない。やっぱり『Ruby on Rails5 アプリケーションプログラミング』の第6章早くやるべき!