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章早くやるべき!

rails作業日誌

前書き

今日は『Ruby on Rails5 アプリケーションプログラミング』での学習ができなかったので作業日誌です。あと、ここに書いてあることは単なる学習記録なので事実とは関係がない場合があります。ご了承ください。

外部キー制約の追加について

そもそも外部キーとは?

外部キーについては以前も取り上げたが、正確に理解していないようだったので、整理しておく。外部キーは例えばcompaniesテーブルに従属するstaffsテーブルにおける、company_idのことである。ただ、staffsテーブルが単にcompany_idを持っているだけではそれは外部キーではない。それが外部キー制約を持っていて初めて外部キーになる。大事なのは制約の方。

外部キー制約とは?

外部キー制約とは、そのカラムに参照先のテーブルの主キーの値以外を入れてはいけないという制約のこと。その制約のかかっているカラムのことを外部キーと呼ぶ。参照先テーブル_idという名前にすると自動的に外部キーになるわけではない。なので必ずしも参照先テーブル_idという名前でなくても良い。ただ、railsのdefaultではそうなっているのでそうしておいたほうがいい。

外部キーの設定方法

外部キーを設定するのに必要なものは、参照先テーブルの主キーと参照先テーブル_idという名前のカラムと外部キー制約の3つということになる。scaffoldを組む際には

rails generate scaffold staff company:references

のようにするとstaffとcompanyの主従関係が自動的に生成され(companyモデルには別途has_many等の記述が必要)上の3つのうち主たるテーブル以外の2つが勝手に出来上がる。

外部キー制約の追加

既存のテーブルに後から外部キーを設定するには、マイグレーションファイルを作って

class AddCompanyIdToStaffs < ActiveRecord::Migration[5.2]
  def change
    add_reference :staffs, :company, foreign_key: true
      end
end

のように定義してマイグレーションする。そうすればcompany_idとその外部キー制約が出来上がる。
add_referenceは

add_reference(テーブル名, リファレンス名 [, オプション])

のように使用する。
その3つのうちの外部キー制約のみ設定したい場合はadd_foreign_keyを使う。同様にマイグレーションファイルを作って、

class AddForeignKeyToStaffs < ActiveRecord::Migration[5.2]
  def change
    add_foreign_key :staffs, :companies
  end
end

のようにする。add_foreign_keyは

add_foreign_key :対象のテーブル, :指定先のテーブル

のように使う。これはcompany_idという名前のカラムがあることを前提にしているので、ない場合はエラーになる。

scaffoldでのnewの流れとbcrypt

scaffoldは便利だけど自動でできてしまうだけに流れがよくわからなくなる時があるので整理した(『Ruby on Rails5 アプリケーションプログラミング』第3章の復習)。あとパスワードでbcryptをハッシュ化している。
GET /companies/newでurlを受けると

resources :companies

のルートで内部的に定義されているのでcompaniesコントローラーのnewメソッドが処理される。

def new
  @company = Company.new
end

ここでCompanyモデルが初期化されテンプレート変数@companyに格納される。
次にviews/companies/new.html.erbが表示される。メインテンプレートであるnew.html.erb内に

render 'form', company: @company

の記述があり、部分テンプレートviews/companies/_form.html.erbを呼び出していることがわかる。また、第二引数をcompany: @companyとすることで、部分テンプレートでテンプレート変数@companyをcompanyパラメータとして受け取れるようにしている。
呼び出した_form.html.erbを見てみると

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

とある。これで@companyがdbにあるときはコントローラーのupdateにとび、ないときにはcreateにとぶという定義をしている。今回はnewなのでcreateに飛ぶ。createではpostで受け取ったパラメータからprivateメソッドのcompany_paramsを実行する。パスワードをハッシュ化する場合はそこで:password, :password_confirmationを受け取っていて、これらが一致した場合のみモデル内のhas_secure_passwordメソッドを受けることによってハッシュ化されると思われる(モデルにはhas_secure_passwordを記述しておかなければならない)。そしてインスタンス変数@companyに格納されて、password_digestにsaveされる。passwordがハッシュ化されたものがpassword_digestである。パスワードのハッシュ化はbcryptのインストールとdbにpassword_digestカラムがあることが前提となっている。

enumとformについて

モデルにenum

enum status: { draft:0, published:1, deleted:2 }

のように定義して、form

  <div class="field">
    <%= form.label :status %>
    <%= form.number_field :status %>
  </div>

のようにformを作ると、viewから送信するときに

'1' is not a valid status

のようなエラーが出る(これは1を入力した場合)。エラーメッセージをよくみると1に''がついている。数値で入力しているのに受け取るときになぜか文字列になってしまうらしい。enum以外のinteger型は大丈夫なのになんでだろう?これを回避するためにはformをセレクトボックスかラジオボタンにしてvalueを設定しておけば良いとわかった。

  <div class="field">
    <%= form.label '下書き' %>
    <%= form.radio_button :status, :draft %>
    <%= form.label '公開' %>
    <%= form.radio_button :status, :published %>
  </div>

のようにして保存することができました。見てわかるようにform.radio_buttonの第二引数にはenumの数値に対応する値をとります。

今日の感想

わかったつもりでわかってないことがいっぱいある。
作業の計画はなるべく細かく具体的に立てたほうが良い。当たり前なんだけど。徹底していきたい。

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

前書き

今日も第5章モデルについて学習していきたいと思います。あと、ここに書いてあることは単なる学習記録なので事実とは関係がない場合があります。ご了承ください。

ページ範囲

p.291-311

学習ポイント

コールバックについて

あるモデル操作に対してそれに付随する処理はコールバックとして定義することで同じようなコードがモデルやコントローラに分散するのを防ぐことができる。

マイグレーションについて

テーブルレイアウトを作成・変更するためにマイグレーションという機能がある。この機能は開発の途中での構成の変化にも対応できる。マイグレーションファイルには必ず頭にタイムスタンプが振られる。rails側ではこの値を元にそれがすでに実行済みかどうかを判断している。railsではこのマイグレーションファイルをタイムスタンプによって一元的に管理しているため、テーブルの状態を特定の時点まで戻したりすることが可能。バージョンの管理ができるためとても便利。

マイグレーションファイルの作成について

ターミナルから下記のようにコマンドすることでマイグレーションファイルを自動的に作成することができる。

rails generate migration ChangePhoneOfStaffs

20180813110400_change_phone_of_staffsのようなマイグレーションファイルができる。20180813110400の部分がタイムスタンプ。ここではinteger型で定義してしまった電話番号フィールドをstring型に変更したいので下記のようにファイルの内容を変更する。

class ChangePhoneOfStaffs < ActiveRecord::Migration[5.2]
  def change
    change_column :staffs, :phone, :string
  end
end

そして

rails db:migrate

マイグレーションを実行すると実際にstaffsテーブルのphoneカラムのデータ型がstringになる。これはdb/schema.rb内の記述が書き換わっていることから確認できる。
また、コマンドで指定するファイル名について若干の命名規則があり、

Add〇〇Toテーブル名 〇〇:データ型


のようにすることで生成されるファイルの内容が下記のようになる。

class Add〇〇ToStaff < ActiveRecord::Migration[5.2]
  def change
    add_column :テーブル名, :〇〇, :カラム名
  end
end

感想

マイグレーションは単にテーブルを簡単に用意するためのものだと思っていたので仕組みを知ることができ良かった。自分で考えるテーブルの設計は基本間違っているのでどんどん便利に使えるようになりたい。