意外と簡単。HTML5のデスクトップ通知を実装してみる

こんにちは、MUGENUPの倉成です。 最近はWebアプリでもデスクトップ通知が出来るものが増えていますよね。 今日はそんなデスクトップ通知の実装を取り上げてみようと思います。

便利なライブラリ

デスクトップ通知はブラウザによって実装が異なり、各ブラウザの対応は手間がかかるので、今回はクロスブラウザ対応を簡単にできるHTML5-Desktop-Notificationsを使います。 他のデスクトップ通知のライブラリにはnotifyもあり、こちらもHTML5-Desktop-Notificationsと同じくらいのStarが付いているようです。

使い方

さて、ここからはHTML5-Desktop-Notificationsの使い方をREADMEにそって

  1. ブラウザ対応状況の確認
  2. ユーザーに通知の許可を求める
  3. 通知を発行

の3段階で説明していきたいと思います。

なお、本記事はこのコミット時のコードを対象にしており、今後の開発により変更が発生する場合があります。

また、HTML5-Desktop-NotificationsのサンプルコードはAngular.jsベースとなっており、馴染みのない方も多いと思うので、gistにサンプルコードを書いてみました、こちらも合わせて見ていただければと思います。

https://gist.github.com/kuranari-tm/e8d8b6411b90da10910e

Step1(ブラウザが通知に対応しているかチェック)

まずは以下のコードでブラウザがデスクトップ通知に対応しているか確認しましょう。 コードは

notify.isSupported // ブラウザが対応していればtrue, そうでなければfalse

です。なおnotifyHTML5-Desktop-Notificationsで定義されているグローバル変数です。

Step2(ユーザーに通知を求める)

デスクトップ通知は、ユーザーから通知の許可をもらわなければ通知が発行できません。 次は、notify.permissionLevel()ドメインに対する通知の許可状況をチェックします。

許可・拒否の状態は

notify.PERMISSION_DEFAULT // 通知が許可されていない
notify.PERMISSION_GRANTED // 通知が許可されている
notify.PERMISSION_DENIED  // 通知が拒否されている

の3状態で、PERMISSION_DEFAULTとなっている場合は

notify.requestPermission()

で、ユーザーから通知の許可をもらいましょう。

f:id:mgnup:20140820000117p:plain

DEFAULTDENIEDの違いですが、notify.requestPermission()を実行時にDEFAULTでは上のような「デスクトップ通知の表示を許可しますか?」のメッセージが表示されますが、DENIEDではこのメッセージは表示されず、ユーザーがブラウザの設定を変更するまでは通知機能を使用することが出来ません。

その場合、例えばChromeでは以下の箇所からドメインに対する通知の許可をユーザーに行ってもらわなければなりません。

f:id:mgnup:20140820000105p:plain

通知の設定はドメイン単位で保存されるため、初回一度だけ許可をもらえば、その後は通知を自由に発行することが出来ます*1

Step3(通知を発行)

さて、Step2までで設定が終了したので、notify.createNotificationで通知を発行します。

notify.createNotification(String title [, Object options])

この関数を実行すると、MacChrome(version.36)では以下のような表示がされます。

f:id:mgnup:20140820000110p:plain

optionsに渡せるパラメータは

body, icon, tag, timeout

の4つです。

第一引数のtitle、そしてオプションのbody,iconは上の通知画像を見れば、大体どこに対応するか分かるかと思います。ただし、iconが必須パラメータになっていることには注意しましょう。

tagはユニークな値を設定することで、複数のタブでページを開いていた場合に、開いているタブと同じ数の通知が発行されることを防ぐことが出来ます。

timeoutはREADMEには通知が閉じるまでの時間を設定できると書いてありますが、README通りに設定を行っても求める挙動が実現できません。 一定時間経過後に自動で閉じる設定をするには

notify.config({autoClose: 1000}); // 1000[ミリ秒後]に通知を閉じる

とする必要があります。issueに上がっているようにみえるのですが、ちょっとハマりどころです。

なお、通知をクリックした時に何らかの処理をしたいといった処理は本家では実装されていませんが、Fork先では実装されているものもあるようなので、必要があればこちらを参考に機能を追加しても良いかもしれません。

終わりに

gistのサンプルコードでもjsは30行程度で、とても簡単にデスクトップ通知(しかもクロスブラウザ対応まで)を実装することが出来ました。

Webサービスにデスクトップ通知があることで使い勝手が格段に良くなるケースも少なく無いと思うので、意外と簡単に実装できてしまうデスクトップ通知機能、ぜひ一度使ってみてください!

おまけ:ローカルで通知が表示されない場合

セキリティ設定の影響でChromeなど幾つかのブラウザではサーバーを通さないローカルのjsが動作しないことがあります。 その場合は以下のサイトを参考に

$ python -m SimpleHTTPServer 8080

コマンド1つで今すぐWebサーバを起動させるためのワンライナー(Ruby or Python) - 元RX-7乗りの適当な日々

とし、ブラウザからlocalhost:8080にアクセスすると、簡単にサーバー経由で動作を確認することが出来ます。

*1:逆に、一度でもDENIEDの状態にされるとブラウザの設定から許可状態にしてもらわなければならないので、やや面倒なことになります。

【Rails】after_createが発動するタイミングはいつでしょう?

MUGENUPの倉成です。 今回はRailsのCallbackであるafter_createafter_commitの処理順番を改めて確認し、処理順番を誤解していた事によって僕が遭遇した問題ついて記事を書こうと思います。

シチュエーション

ブログ記事の新規投稿があった時、購読者に対してメールを送信する。

ブログの投稿時に通知のメールを送信するような場合、記事の投稿に必要な最低限の処理のみを行い、購読者へのメール送信などリアルタイムな処理が必要でないものは非同期で処理することで、レスポンス速度を向上させることが出来ます。

f:id:mgnup:20140804095733j:plain

非同期処理を行うためのライブラリとしてはresquesidekiqdelayed_jobなど幾つかのものがありますが、弊社ではこの用途にresqueを使っているので、この記事では特にresqueを取り上げて説明します*1

実装

さて、上の要件の実装として、僕は以下のようなコードを書きました。

# app/models/article.rb
class Article < ActiveRecord::Base
  # 記事が作成されたらメール送信タスクをqueueに積む
  after_create do
    Resque.enqueue(SendMail, id)
  end
end
# app/workers/send_mail.rb
class SendMail
  @queue = :send_mail
  def self.perform(article_id)
    # 投稿された記事をDBから読み込む
    article = Article.find(article_id)
    # ブログの購読者にメールを送信
    # ....
    # ....
  end
end

self.perform(article_id)の第一引数article_idResque.enqueue(SendMail, id)の第二引数idと対応しており、 新規記事のIDが渡されることになります。

さて、実際にアプリケーションを立ち上げて実行してみるとself.perform内部で

article = Article.find(article_id)で稀にNoRecordFoundが発生する

という現象が起きていました。

after_createという名のとおり記事は既にcreateされていて、レコードは存在しているはずです。その証拠に既にidは与えられていますよね。

なぜでしょう。

after_createはDBへのコミット直前に実行される

不思議に思い、出力されたログを見てみると、ResqueからのSELECTBEGIN ~ COMMITの内部で実行されていました。

DBはINSERTを実行しても、COMMITされるまでは、レコードは永続化されません。 つまりRailsがDBにCOMMITする前に、外部プロセスであるResqueのプロセスがSELECTをしていたため、NoRecordFoundが発生していまうという状態になっていました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
  /* COMMIT前にResqueからのSELECTが発行される */
  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1
COMMIT

これは、after_createがDBへのコミット直前に実行されることが原因です*2

after_commitを使う

原因がわかりました。 after_createの代わりに、コミット直後に実行されるafter_commit on: :createを使いましょう。

これでCOMMITのあとにResqueの処理が実行されるため、NoRecordFoundが発生することもなくなりました。

BEGIN
  INSERT INTO `articles` (`col1`, `col2`) VALUES ('..', '..')
COMMIT
/* COMMIT後にResqueからのSELECTが発行される */
SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1

BEGIN ~ COMMITの外側でSELECTが実行されていることが、ログでも確認できます。

NoRecordFoundの原因は

稀にNoRecordFoundが発生する状態だったのは「COMMITの前に外部プロセスであるResqueのSELECTが発生するケースがあったため」というのが結論です。なお常に発生するでは無いのは、Resqueが新規ジョブをポーリングする間隔により、Resqueの処理がCOMMIT後に行われることもあったためです。

今回のようにRailscallbackをトリガーにして外部プロセスからDBにアクセスする場合はafter_commitを使うようにしましょう。

なお,同様の注意点は http://apidock.com/rails/ActiveRecord/Callbacks/after_createにも書いてありました*3

おわりに

今回は「after_createはDBにCOMMITされた後に呼び出される」という間違った認識によって遭遇した問題について記事を書かせていただきました。 ResqueなどRails外のプロセスを使う場合にこの記事を思い出していただけたら幸いです。

*1:今回は説明のため使用しませんがメール送信を非同期で行うならresque_mailerが便利です。

*2:コールバックの順番はこちらの記事にとても詳しくまとまっています http://techracho.bpsinc.jp/baba/2013_08_23/12670

*3:Rails 2.3.8のドキュメントですが,この注意点に関しては今のバージョンでも変わらずに使えると思います

初めてのVimプラグイン開発とMUGENUPエンジニアのエディタ事情

初めまして!MUGENUPアルバイトの倉成です。こちらで書く初めての記事として、今回は僕が初めて作ったVimプラグインMUGENUPエンジニアのエディタ事情について紹介しようと思います。

僕自身はMUGENUPでアルバイトを始めてから、Emacs, SublimeText, RubyMineといろいろなエディタに手を出していましたが、現在はVimに落ち着いています。

もちろん、それぞれのエディタには一長一短があり「他で出来たことがこっちでは出来ない」というようなことはよくありますよね。

後置記法を支援するプラグイン

そんな中でも、RubyMineを使っていた時に便利だと思った「bodyが1行のif, unless, while, until文を後置記法に変換する*1」ことがVimではできず、軽く調べてもプラグインが見つからなかったので、自分で作ってみることにしました*2

f:id:mgnup:20140730202756g:plain

Github: https://github.com/kuranari-tm/backend_if

内部の処理は比較的簡単で、カーソル行が一行化・複数行化できるかを判定し、もし変換可能であれば正規表現等を使って置換を行っています。

このプラグインif cond thenのようにthenが入っているとうまく動かなかったり、コードを整形するのにnormal! 2k3==と強引なことをやっていたりと、不十分な部分は多いのですが、普段の開発で使えるプラグインを開発できたのが何よりの成果でした。便利なプラグインを使うのももちろんよいですが、自給自足で必要な物を作る楽しみは何物にも代えがたいですよね。

if文の一行化・複数行化はよく行うと思うので、if文を後置化するのにddkPJ==jddなどしている方、ぜひこのbackend_ifsplitjoinを使ってみてください!

MUGENUPエンジニアのエディタ事情

さてさて、エンジニア同士の会話で「エディタ何使っていますか?」というのは鉄板ネタだと思うので、MUGENUPエンジニアのエディタ事情について少し紹介します。

現在MUGENUPでは8人のエンジニアで開発をしているのですが、使用エディタは

と圧倒的にSublime Textが人気です。

確かに、新人さんやIDEしか使ったことがない人がメンバーに加わる時には「とりあえずSublime使ってみよう!」ってなりますもんね。 学習コストが比較的低い割に、高機能で拡張性もあり、とてもよいエディタだと思います。

ということでSublime勢の勢力に押され、社内でVimを使っているのは少数派となってしまっているのですが、サーバー設定の場面などではVim(Vi)が使えたほうが何かと便利だったりするので、これからも少しずつ良さを広めていけたらなと思います。

おわりに

今回は僕が初めて作ったプラグインの紹介、そしてMUGENUPエンジニア陣のエディタ事情について書かせていただきました。

if文を一行化・複数行化する機能は初めてのプラグイン開発にちょうどいい難易度に感じたのでEmacsSublime textなど他のエディタを使っている方も同様なプラグインを作ってみてはいかがでしょうか。

GithubへのPull Requestもお待ちしています!

*1:RubyMineでは後置記法が可能な文を複数行で書いていると、警告とともに1行化するボタンが表示されていました。警告を出すこと自体はRubyMine以外のエディタでもRubocopを使用することで可能です。

*2:実は後からしっかり調べたらsplitjoinというプラグインが見つかりました、各種言語に対応しているので実際に使うならこちらのほうがオススメです。

Yammer に投稿したLGTMな画像を、GitHub に POST する Chrome 拡張を作ってみた

皆さん、こんにちは。MUGENUP の osada です。

今回のテーマは、

  1. Chrome 拡張を作ってみた
  2. JS の BLOB として、画像をダウンロードする
  3. GitHub 経由で、S3 に POST する

の3点です。

読者ターゲットは、 画像は S3 に置きたいけど、管理はしたい 人や、 <input type="file"> を使わずに画像をPOSTしたい 人です。

注: 一部画像にモザイクを掛けてあります。copyrightを守ります

Yammer から GitHub

開発部では、社内コミニケーションツールとして、Yammer を使っています。 Yammer は社内専用の Facebook のようなSNSです。 そこに LGTM な画像置き場を作っているのですが、Yammer の URL をそのまま GitHub に投稿しても表示できません。 Yammer は会員制でクローズドなので GitHub はアクセスすることができないからです。

そこで今までは、

  1. yammer から ローカルPC にダウンロード
  2. ローカルPC から GitHub にアップロード

という2手順を踏んで、LGTM に使っていました。

f:id:mgnup:20140727210649p:plain

しかしこの手順は意外と面倒くさい!

結局、LGTM.in拡張機能 LGTMでめでたさを伝えるChrome拡張をつくった - Thinking-megane を使ってしまい、 このままでは Yammer は、ただの画像置き場になってしまう! ということで、今回、Chrome 拡張を作ることにしました。

Chrome拡張の動作

動作は大きく3つに分かれます

  1. Yammer API で、投稿を取得(JSON)し、サムネイル画像をレンダリングする
  2. 選択したサムネイル画像を、BLOB形式でダウンロードする
  3. 画像をGitHubを経由してS3にアップロードする

f:id:mgnup:20140727210702p:plain

鍵となるのは、下記の2点です。

  1. 画像を BLOB として扱うこと
  2. GitHub は画像を3段階でアップロードすること

では、1つずつ解説していきます。

Chrome 拡張の構造

と、その前に、Chrome 拡張の構造を解説します。

この Chrome 拡張は、下記の3つから成り立っています。

  • chrome拡張を定義する、manifest.json
  • chrome拡張側の、アイコンをクリックしてポップアップする、popup.htmlyammer.js
  • 表示中のページで読み込まれる content.js

f:id:mgnup:20140727210908p:plain

chrome 拡張と、表示中のタブ間は、完全に独立しているため、 yammer.js と content.js は、メッセージで通信することになります。

Yammer API で、投稿を取得(JSON)し、サムネイル画像をレンダリングする

chrome 拡張側、アイコンをクリックすると、popup.html を開き、yammer.js を読み込んで投稿を取得します。

f:id:mgnup:20140727212549p:plain

function loadAndAppendGroupImages($body, groupID){
    var url = "https://www.yammer.com/api/v1/messages/in_group/"+ groupID + ".json";
    $.getJSON(url)
        .done(function(data, status, xhr){
            var images = [];
            data.messages.forEach(function(message){
                if(message.attachments){
                    message.attachments.forEach(function(attachment){
                        if(image = createImage(attachment)){
                            images.push(image);
                        }
                    });
                }
            });
            var image_tags = [];
            images.forEach(function(image){
                image_tags.push(toHTML(image));
            });
            $body.replaceWith(image_tags);
        })
        .fail(function(xhr, status){
            $body.replaceWith("<div style='width: 200px'>読み取れません。ログインしていないか、ネットワークが違います</div>");
        });
}

この json は、messages の array が返ってきます。 この中で画像の情報は attachments に含まれています。 ここから、サムネイルURLとダウンロードURLを取り出して、popup.html にレンダリングします。

なお、createImagetoHTML は、取り出しと、HTML整形です。

function createImage(attachment){
    return {
        thumbnail_url: attachment.thumbnail_url,
        download_url: attachment.download_url
    };
}

function toHTML(image){
    return "<a href='" + image.download_url +  "'>" +
        "<img src='" + image.thumbnail_url + "'>" +
        "</a>" +
        "<br>";
}
  • yammer api で取得できる json
{
  "threaded_extended": {},
  "messages": [
    {
      "id": ,
      ......
      "attachments": [
        {
          "id": xxxxx,
          "url": "https://www.yammer.com/api/v1/uploaded_files/xxxxx",
          "web_url": "https://www.yammer.com/mugenup.com/uploaded_files/xxxxx",
          "type": "image",
          "name": "xxxx.gif",
          "original_name": "xxxx.gif",
          "full_name": "xxxx",
          "description": "",
          "content_type": "image/gif",
          "small_icon_url": "https://c64.assets-yammer.com/images/file_icons/types/picture_orange_39x50_icon.png",
          "large_icon_url": "https://c64.assets-yammer.com/images/file_icons/types/picture_orange_79x102_icon.png",
          "download_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/download",
          "thumbnail_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/version/20643071/thumbnail",
          "preview_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/preview/xxxx.gif",
          "large_preview_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/version/20643071/large_preview/xxxx.gif",
          "size": 1014474,
          ......
          "height": 278,
          "width": 500,
          "scaled_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/version/20643071/scaled/{{width}}x{{height}}",
          "image": {
            "url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/preview/xxxx.gif",
            "size": 1014474,
            "thumbnail_url": "https://www.yammer.com/api/v1/uploaded_files/xxxx/version/20643071/thumbnail"
          },
        }
      ],
    },......

選択したサムネイル画像を、BLOB形式でダウンロードする

popup.html に表示された画像一覧から、画像をクリックしたとき、 download_url を使って、画像をダウンロードします。

このダウンロード動作は拡張側ではなく、ページ側で行うため、download_urltab 側に メッセージとして送信します。

// 画像がクリックされたとき、ダウンロードURLを、コンテンツスクリプトに委譲
    $(document).on("click", "a", function(e){
        $this = $(this);
        $this.replaceWith("<img src='./imgs/loading.gif'>");

        var download_url = $this.attr("href");
        chrome.tabs.getSelected(null, function(tab) {
            chrome.tabs.sendRequest(
                tab.id,
                { download_url: download_url },
                function(response){
                    window.close();
                });
        });
    });

コンテンツスクリプト側では、addListener を使い、 メッセージをキャッチします。

chrome.extension.onRequest.addListener(
    function(request, sender, sendResponse) {
        var download_url = request.download_url;
        xhr = new XMLHttpRequest();
        xhr.open('get', download_url, true);
        xhr.responseType = 'blob';
        xhr.onload = function(){
            if(this.status == 200){
                var blob = this.response;
                var ContentDisposition = xhr.getResponseHeader("Content-Disposition");
                blob.filename = ContentDisposition.match(/filename="([^"]+)"/)[1];
                postGithub(blob);
            }
        };
        xhr.send();
    }
);

ここで大事なのは、画像としてダウンロードすることです。

よって、xhr.responseType = 'blob'; を指定しています。

blob というのは、バイナリ・ラージ・オブジェクトのことであり、コレを使うことで、response をバイナリとして扱うことができます。

(ここだけ、jQuery ではなく、XMLHttpRequest を使っているのは、 jQueryresponseType を指定する方法が不明だったためです)

画像をGitHubを経由してS3にアップロードする

GitHub の画像のアップロードは、大変優れた方法です。 GitHub側では、レコードを管理し、大きなバイナリデータはS3に直接アップロードさせます。

(ご存じの方は3ウェイ・ハンドシェイク を 思い浮かべていただければ、わかりやすいと思います。)

GitHub へのアップロードは下記の動作で実行されます。

  1. GitHub に post する
  2. GitHub は、S3 へのアップロードに必要な情報を返す
  3. その情報を使い、S3 にアップロードする
  4. GitHub に put して、データを更新する(?)

1つずつ解説します

1. GitHub に post する

name, size, content_typeGitHub に post します。

        function postGithub(blob){
            var formData = new FormData();
            formData.append("name", blob.filename);
            formData.append("size", blob.size);
            formData.append("content_type", blob.type);

            var url = "https://github.com/upload/policies/assets";
            $.ajax({
                url: url,
                type: "post",
                headers: {"X-CSRF-Token": csrf},
                data: formData,
                processData: false,
                contentType: false
            }).done(function(data){
                postS3(data, blob);
            });
        }

ここでは、ファイル自体は post していないことに注目してください

2. GitHub は、S3 へのアップロードに必要な情報を返す

すると、GitHub は、S3へのアップロードに必要な情報を返してきます。 (一部の箇所は、xxxxx で隠しています)

{
  "upload_url": "https://s3.amazonaws.com/github-cloud",
  "header": {},
  "asset": {
    "id": xxxxx,
    "name": "xxxxx.jpg",
    "size": 59192,
    "content_type": "image/jpeg",
    "href": "https://cloud.githubusercontent.com/assets/xxxxx/3711249/fd7cc29e-14ca-11e4-9914-0ca5b063b0c5.jpg",
    "original_name": "xxxxx.jpg"
  },
  "form": {
    "key": "assets/xxxxx/3711249/fd7cc29e-14ca-11e4-9914-0ca5b063b0c5.jpg",
    "AWSAccessKeyId": "xxxxx",
    "acl": "public-read",
    "policy": "xxxxx",
    "signature": "xxxxx",
    "Content-Type": "image/jpeg",
    "Cache-Control": "max-age=31557600",
    "x-amz-meta-Surrogate-Control": "max-age=31557600",
    "x-amz-meta-Surrogate-Key": "user-xxxxx"
  },
  "asset_upload_url": "/upload/assets/xxxxx"
}

3. S3 にアップロードする

返された json の、form に含まれるデータと、 実際の画像データのBLOBデータを formData 化して、 S3にアップロードします。

        function postS3(data, blob){
            var formData = new FormData();
            for ( var key in data.form ) {
                formData.append(key, data.form[key]);
            }
            formData.append("file", blob);

            var asset_upload_url = data.asset_upload_url;
            $.ajax({
                url: data.upload_url,
                type: "post",
                headers: {"X-CSRF-Token": csrf},
                data: formData,
                processData: false,
                contentType: false
            }).done(function(data){
                putGitHub(data, asset_upload_url);
            });
        }

4. GitHub に put して、データを更新する(?)

実は、この put がなくても、url にアクセスすれば、画像は見られます。 よって、この put が何のデータを更新しているのか不明なのですが、 何かのデータを更新しているに違いありません(!?)。

(個人的には、S3へのアップロードが完了したフラグを更新しているのではないかと思っています)

        function putGithub(data, asset_upload_url){
            $.ajax({
                url: asset_upload_url,
                type: "put",
                headers: {"X-CSRF-Token": csrf},
                processData: false,
                contentType: false
            }).done(function(data, status, xhr){
                writeLGTM(data);
            });
        }

そして、最後に、アップロードされた画像データのURLを textarea に貼り付けて終了です。

        function writeLGTM(data){
            var lgtm = "![LGTM](" + data.href + ")";
            var oldMessage = $("textarea[name='comment[body]']").val();
            if(oldMessage != ""){
                lgtm = oldMessage + "\n" + lgtm;
            }
            $("textarea[name='comment[body]']").val(lgtm);
            sendResponse({});
        }

画像をアップロード、というと、一回のPOSTで全部処理してしまいそうですが、 RESTful を遵守することで、3アクションにはなっていますが、シンプルな構成になっています。 さすが GitHub といったところでしょうか。

まとめ

さて今回はYammer に投稿したLGTMな画像を、GitHub に POST する Chrome 拡張を作ってみた というテーマでした。

動作は概要図を再掲します。

f:id:mgnup:20140727210702p:plain

今回わかったことは、下記の3点です。

  1. Chrome 拡張 は表示中のページに介入できる
  2. ajax で画像を扱うときは、xhr.responseType = 'blob'; を使うと便利
  3. GitHub の画像アップロードは、3アクションの RESTful で美しい

まず、Chrome 拡張 は表示中のページに介入できる というのは、本当に怖いことです。 yammerや、github へのログイン認証についての、説明が無いことに気づかれたかもしれません。 Chrome拡張はブラウザのセッションを使うことができるので、ログインさえしていれば、拡張側では処理が必要ないのです。 自分がログインしているサービスに対して、バックグラウンドで、Chrome拡張が通信することができてしまいます。 例えば、Facebookにログインしている Chrome に、勝手に投稿させる という javascript は簡単に書くことができそうです。野良拡張には本当に気をつけましょう

気をつけ方としては、manifest.json"permissions": を確認することです。 ここに記載があるサイトに関して、Chrome拡張は権限を持つので、オカシイな?と思ったら、使うのを止めましょう(そもそも使わないですよね)。 (一番安心なのは、ソースコードを全部読んでしまうことです。unzip で解凍することができます)

unzip hoge.crx

次に、responseType ですが、普段ajaxjQuery を使うことが多いため、今回初めて知りました。xhr.responseType を使うと、返り値を制御するとができますが、jQuery ではどうやると良いのでしょうか?

最後に、GitHub の画像の扱いはスマートでした。DBとストレージが異なる、というのは、昨今当たり前なので、大変参考になりました。皆さんもこのように実装されてはいかがでしょうか?(もうやってる?)

ということで、Chrome拡張を作ってみた、というお話でした。

拡張は、html+css+js なので、WEBエンジニアならとても簡単に作ることができます。是非、作ってみることをおすすめします。きっと新しい発見があると思います。

ruby は メソッドに return が不要なのではなく、元々そうなっているだけ

皆さん、こんにちは。MUGENUP の osada です。 いきなりですが、問題です。

def if_expression(flag)
  if flag
    "NG"
  else
    "OK"
  end
end

Ruby最後に評価された値が返る と言われていますね。 では、上記のメソッド

if_expression false

の返り値は、何ですか?






はい、正解です。OKが返ってきますね。

ちょっと長いので、リファクタリングしましょう。

def if_modifier(flag)
  "OK"
  "NG" if flag
end

さてもう一度。

if_modifier(false)

の返り値は、何ですか? Ruby最後に評価された値が返る と言われていましたよね?






はい。正解です。nil が返ってきますね。

え、"OK"じゃないのかって?

いいえ、後置if は if とは全く違います

右辺の条件が成立する時に、左辺の式を評価してその結果を返します。 条件が成立しなければ nil を返します。 (http://docs.ruby-lang.org/ja/2.1.0/doc/spec=2fcontrol.html)

という流れでreturn を書けばこんな混乱しないのにという話がでました。

いやいや、return とか不要じゃない?という立場でこの記事を書いています。

ruby最後に評価される値 が返る、と言われますが、それはどのような意味なのでしょうか?

そんな本日は ruby の挙動についてのお話です。 ターゲットは、アセンブラに興味がある人です(アセンブラを使いこなす人は、対象外でお願いします (笑))

if と、if 修飾子 (後置 if) の違い

まず if と if 修飾子を使ったコードを書きます。

# return_if.rb

def if_expression(flag)
  if flag
    "NG"
  else
    "OK"
  end
end

def if_modifier(flag)
  "OK"
  "NG" if flag
end

そしておもむろに、ディスアセンブラを書きます(!)。

# disasm.rb
iseq = RubyVM::InstructionSequence.compile_file ARGV.first, false
print iseq.disasm

さきほどのファイルを食わせます

[master]~/projects/ruby_test/practice/if_expression: ruby disasem.rb return_if.rb
== disasm: <RubyVM::InstructionSequence:<main>@return_if.rb>============
0000 putspecialobject 1                                               (  20)
0002 putspecialobject 2
0004 putobject        :if_expression
0006 putiseq          if_expression
0008 send             <callinfo!mid:core#define_method, argc:3, ARGS_SKIP>
0010 pop
0011 putspecialobject 1                                               (  28)
0013 putspecialobject 2
0015 putobject        :if_modifier
0017 putiseq          if_modifier
0019 send             <callinfo!mid:core#define_method, argc:3, ARGS_SKIP>
0021 leave
== disasm: <RubyVM::InstructionSequence:if_expression@return_if.rb>=====
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  21)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"                                            (  22)
0009 jump             13                                              (  21)
0011 putstring        "OK"                                            (  24)
0013 leave
== disasm: <RubyVM::InstructionSequence:if_modifier@return_if.rb>=======
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  30)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"
0009 jump             12
0011 putnil
0012 leave

初めの ブロックはメソッド定義なので飛ばしまずが、 なんとなく気分はわかっていただけると思います。

2番目の if_expressonブロックと、 3番目の if_modifier ブロックを見ていくことにしましょう。

if_expression

2番めのブロックは、if_expression のコードです。

== disasm: <RubyVM::InstructionSequence:if_expression@return_if.rb>=====
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  21)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"                                            (  22)
0009 jump             13                                              (  21)
0011 putstring        "OK"                                            (  24)
0013 leave
  • 0番でflag 変数から値をとりだします。
  • 3番で、それが nil, false でなければ、11番に飛びます。

if なのに、branchunless というのは面白いですね。 これは、否定を確認した方が速いからでしょう。

flagfalse のとき、

  • 11番は、"OK"を スタックに積みます。
  • 13番で leave します。

flagtrue のとき、

  • 5番で、jump 7します。
  • 7番で "NG" を スタックに積みます。
  • 9番で jump 13 します。
  • 13番で leave します。

if_modifier

3番めのブロックは、if_modifier のコードです。

== disasm: <RubyVM::InstructionSequence:if_modifier@return_if.rb>=======
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  30)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"
0009 jump             12
0011 putnil
0012 leave
  • 0番でflag 変数から値をとりだします。
  • 3番で、それが nil, false でなければ、11番に飛びます。
  • 11番で putnilnil をスタックに積みます
  • 12番で lreave します。

はい、成立しないときは、nil が返りますね。仕様ですから仕方ないですよね。

あれ? "OK" は、どこに行ったのでしょう? putstring すらないとか、かわいそうですね。

return が不要な理由

ruby に return が不要な理由は、もうお分かりですね。

というかそもそも、return ではなくleaveですね。 ruby としては、メソッドの元から去る(leave)だけで、 値を返し(return)ているつもりはなさそうです。

return は単に、メソッドから去るタイミングを指定しているだけなので、 最後に書く必要はない、ということなのでしょう。

あれ?そんなのどの言語も一緒じゃん! と思われません?

ではここで、LL界の委員長 python にご出座いただきましょう。

python は return が必須です!

pythonは return が必須です。

def if_expression(flag):
    if flag:
        return "NG"
    else:
        return "OK"

import dis

dis.dis(if_expression)
[master]~/projects/python_test: python if_expression.py
  2           0 LOAD_FAST                0 (flag)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 ('NG')
              9 RETURN_VALUE

  5     >>   10 LOAD_CONST               2 ('OK')
             13 RETURN_VALUE
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE

左端はソース行なので、無視してください。

  • 0番で flag から ロードします
  • 3番で、それが FALSE なら、 10番にジャンプします

これも、falseをチェックするのはお決まりですね。

flagtrue のとき、

  • 6番で "NG" をスタックに積みます
  • 9番で、RETURN_VALUEします( return だよ!やったね!)

flagtrue のとき、

  • 10番で、"OK" をスタックに積みます。
  • 13番で、RETURN_VALUEします

あれ?2行残っていますね。

  • 14番で、Noneを スタックに積みます。
  • 17番で、RETURN_VALUEします

これは、python の関数が、return がないときはNoneを返す という仕様になっているからです。

pythonインデントがブロックを表すので、 この関数自体の返り値が書かれていません。 よって、最後に None を返すコードが暗黙的に追加されています。

さて、動作はrubyとほとんど同じですね。

では、return を書かないとどうなるか見てみましょう。

def if_expression(flag):
    if flag:
        "OK"
    else:
        "NG"

import dis
dis.dis(if_expression)
[master]~/projects/python_test: python if_expression.py
 17           0 LOAD_FAST                0 (flag)
              3 POP_JUMP_IF_FALSE        9

 18           6 JUMP_FORWARD             0 (to 9)

 20     >>    9 LOAD_CONST               0 (None)
             12 RETURN_VALUE

何もない……。

スタックに積んだ物が返る、というのはどの言語でも一緒ですが、 return をどのように取り扱うかで、コードが異なる、ということですね。

まとめ

  • if 修飾子 は 成立しないとき nil を返します
  • メソッドは最後にスタックに積まれた値を返します
  • RubyVM::InstructionSequenceVM のコードが読めます
  • ifbranchunless を判定します
  • rubyreturn は、メソッドの返りのタイミングの指定です。
  • pythonreturn は、関数の返り値の指定です。

ということで、ruby にとって、returnメソッドの返り値の指定ではなく、 どのタイミングでメソッドからleaveするかの指定、という意味なので、 実質returnが不要になる、ということのようです。

さて、rubyreturn 必要ですか

メソッドの値を返す場合は、必ずreturnを使用する。

Rubyコーディング規約

それと return なしのほうがちょっとだけ効率もいい。 それはなぜかというと RubyHackingGuide でも見てくれればわかるのだが、 return は結局 longjmp だからだ。

LoveRubyNet Wiki: RubyCodingStyle

Avoid return where not required.

styleguide/RUBY-STYLE at master · chneukirchen/styleguide

……あれ〜??

(おまけ) 人生、宇宙、すべての答え

ruby の ソースを追いかけていて、人生の答えを知りました。 さすが ruby ですね。

/**
  @c joke
  @e The Answer to Life, the Universe, and Everything
  @j 人生、宇宙、すべての答え。
 */
DEFINE_INSN
answer
()
()
(VALUE ret)
{
    ret = INT2FIX(42);
}

http://svn.ruby-lang.org/cgi-bin/viewvc.cgi/trunk/insns.def?view=markup

参考

RailsプロジェクトのRSpec3.0.0へのアップデート

皆さん、こんにちわ。MUGENUPの narikazu です。6月2日にRSpec 3.0.0がリリースされました(Myron Marston » RSpec 2.99.0 and 3.0.0 have been released!)。RSpec 3.0.0での変更点はMyron Marston » Notable Changes in RSpec 3あるいは、その日本語訳のRSpec 3の重要な変更 - 有頂天Rubyをご参照下さい。

ここでは、弊社Railsプロジェクト(Rails 3.2.18)でのRSpec3.0.0へのアップデート手順を記述いたします。この記事が皆さんのアップデート時の参考になれば幸いです。

アップデート手順

アップデート手順は以下の記事を参考に致しました。

正直、ほとんど上記記事と同じ手順を踏んでいます(汗)。しかし、異なる点もあるので、以下手順の大まかな流れをご説明します。

  1. 現在のテストが全てsuccessすることを確認
  2. rspec-railsのバージョンを2.99にアップデート
  3. 2.99の状態でテストを実行
  4. テスト結果のdeprecationを全て修正(transpecと必要なGemのインストールおよび手作業)
  5. rpsec-railsのバージョンを3.0.1にアップデート
  6. 3.0.1の状態でテストを実行
  7. テスト結果のdeprecationを全て修正(transpecと手作業)

以下では、各手順について詳細に記述していきます。

現在のテストが全てsuccessすることを確認

弊社では、CircleCIへブランチをpushすることをトリガーにテストを実行しています。以前はローカル環境にて並列でテストを高速でブン回すparallel_testsを使用しておりました。CircleCIはテスト結果がGithub上で表示されるので、とても便利ですね〜。少し話は逸れましたが、私の場合はRSpecアップデート用のブランチを作成し、pushしている間にrspec-railsのバージョンを上げる作業をしていました。

rspec-railsのバージョンを2.99にアップデート

Gemfileでrspec-railsのバージョンを2.99.0と指定し、bundle update rspecを実行します(bundle update rspec-railsではないので注意)。

# Gemfile
group :test, :development do
  gem 'rspec-rails', '~> 2.99.0'
  # ...
end

2.99の状態でテストを実行

RSpec 2.99でどのようなdeprecation出るか確認するために、全体テストを行います。私の場合はRSpec 2.99アップデートのcommitを積んでGithubにブランチをpushし、CircleCIがテストをブン回してくれました。

テスト結果のdeprecatedを全て修正(transpecと必要なGemのインストールおよび手作業)

2.99で出たdeprecationを放置して、3.0.0に上げるとそのテストは3.0.0に準拠した記述方法ではないのでテストは落ちてしまいます。よって、出てきたdeprecationは全てこのタイミングで修正しましょう。ちなみに、私は258個のdeprecationがありました。

2.99でのdeprecationの修正は以下の流れで行いました。

  1. RSpec3.0で外部化されたGemのインストール
  2. GemのTranspecを使って全自動でRSpec3.0系の記述方法に移行
  3. Transpecで対応できなかった箇所を手作業で修正

以下で各手順について詳細にご説明します。

RSpec3.0で外部化されたGemのインストール

`mock_model` is deprecated. Use the `rspec-activemodel-mocks` gem instead.

上記のdeprecationが出力された方はいらっしゃらないでしょうか?RSpec2.0系で使用できたmock_modelはRSpec3.0では使用できなくなり、rspec-activemodel-mocksとして外部Gemになりました。よって、このgemをGemfileに記述し、bundle installでインストールしましょう。

# Gemfile
group :test do
  gem 'rspec-activemodel-mocks'
  # ...
end

また下記のdeprecationが出力された方もいらっしゃるのではないでしょうか?

`expect(collection).to have(1).attachment` is deprecated. Use the rspec-collection_matchers gem or replace your expectation with something like `expect(collection.size).to eq(1)`

RSpec 2系でcollectionの数を検証するためにexpect(object).to have(2).itemsという構文がありました。RSpec 3ではこの構文はなくなり、expect(object.items.size).to eq 2と書く必要があります。個人的には、RSpec 2系の書き方は自然言語の英語のように書けて気に入っているので、従来の書き方を続けたいと思いました。そのような人のために、rspec-collection_matchersというGemが存在します。これもGemfileに記述し、bundle installを使ってインストールしましょう。

# Gemfile
group :test do
  gem "rspec-collection_matchers"
  # ...
end

弊社のRailsプロジェクトでは使用していませんでしたが、its記法もRSpec 3では使用できなくなり外部Gem化(rspec/rspec-its)しているようです。必要な方はインストールするとよいでしょう!

GemのTranspecを使って全自動でRSpec3.0系の記述方法に移行

RSpec2系を3.0の記述方法にコマンド一発で自動で移行してくれる神Gem transpecがあります。こちらをインストールし、コマンドでサクッと3.0の記述方法に書き変えましょう。インストールはgem i transpecで行います。使い方も簡単で私の場合はtranspec --no-explicit-spec-type --keep have_itemsというコマンドを打って一気に書き換えました。

注意していただきたいのは--no-explicit-spec-type--keep have_itemsオプションをつけていることです。

--no-explicit-spec-typeオプション

2系ではspecファイルが配置されているディレクトリから自動的にspecのタイプ(model, controller, feature等)を判別してくれました。しかし、RSpec 3ではこの機能がオフになっています。よって、Rspec 3ではspecタイプを明示する必要があります。引き続き配置しているディレクトリに応じてspecタイプを自動判別させるためには、spec_helper.rbinfer_spec_type_from_file_location!オプションを付けます。Transpecは自動的にspecタイプのメタデータを追加します。しかし、2系と同じ挙動が気に入っているので、--no-explicit-spec-typeオプションを指定して実行することで、specタイプのメタデータ追加が無効になり、代わりにinfer_spec_type_from_file_location!を自動的に追加します。

--keep have_itemsオプション

expect(object).to have(2).items構文を引き続き使用するべくrspec-collection_matchersのGemを入れた方は、Transpecでhave().item記法を変更させないように--keep have_itemsオプションを付けましょう。

Transpecで対応できなかった箇所を手作業で修正

いくつかTranspecで書換えを行った後もdeprecationが残っていたので、手で書き換えました。ここでは全てのdeprecationを修正しておきましょう。

rpsec-railsのバージョンを3.0.1にアップデート

2.99.0にアップデートした時と同様にGemfileでrspec-railsのバージョンを3.0.1と指定し、bundle update rspecを実行します(bundle update rspec-railsではないので注意)。

# Gemfile
group :test, :development do
  gem 'rspec-rails', '~> 3.0.1'
  # ...
end

3.0.1にアップデート後、再度全テストを通します。

テスト結果のdeprecationを全て修正(transpecと手作業)

3.0.1アップデート後にテストを実行すると以下のエラーで落ちました。

`raise_too_low_error': You are using capybara 2.1.0. RSpec requires version >= 2.2.0. (RSpec::Support::LibraryVersionTooLowError)

Gemのcapybaraのバージョンが低いと言われたので、bundle update capybaraでアップデートしました。

また以下のようなdeprecationが表示されました。

The Fuubar formatter uses the deprecated formatter interface not supported directly by RSpec 3.  To continue to use this formatter you must install the `rspec-legacy_formatters` gem, which provides support for legacy formatters or upgrade the formatter to a compatible version.

これはテストの進捗状況を可視化するgem fuubar(Ruby - Rspecのテスト進捗状況が可視化できるFuubarがいい感じ! - Qiita)のバージョンが低いため表示されます。Rspec 3に対応した2.0.0.rc1を指定しbundle update fuubarでアップデートしました。

# Gemfile
group :test, :development do
  gem 'fuubar', '~> 2.0.0.rc1'
end

そして、再度transpecを用いてdeprecationを消していきます。 なぜかというと2系のstub_chain記法がallow(obj).to receive_message_chain()という記法になるのですがRspec 3.0からでないと使えないため、3.0にアップデート後もtranspecを使用します。前回と同様transpec --no-explicit-spec-type --keep have_itemsコマンドを実行致しました。

これでほとんどのdeprecationが消えました。

また今回のRailsプロジェクトでは本来は通るfeatureテストが時々落ちることがあるので、一度落ちたテストをもう一度実行するモンキーパッチ(sorah / auto_retry.rb)を使わせていただいていました。しかし、Rspec3.0アップデートのapi変更でエラーが出るようになっていたので、少し書き換えました。以下のコードにより、featureスペックかit "hoge", auto_retry: true do ... endとしているテストは落ちるともう一度だけテストを実行してくれます。

# spec/support/auto_retry.rb

module RSpec
  module Core
    class Example
      def run_with_retry(example_group_instance, reporter)
        @retrying_count = 0
        succeeded = run_without_retry(example_group_instance, reporter)

        unless succeeded
          return finish_without_retry(reporter) unless retry_needed?

          $stderr.puts "[RETRY] #{self.location}: #{self.full_description}"
          @retrying_count += 1

          # Initialize itself (originally this has done in ExampleGroup#run_example)
          @exception = nil
          # before_all_ivarsをbefore_context_ivarsに書き換え
          example_group_instance = example_group_instance.class.new.tap do |group|
            group.class.set_ivars(group, group.class.before_context_ivars)
          end

          succeeded = run_without_retry(example_group_instance, reporter)
        end

        succeeded
      end

      alias_method :run_without_retry, :run
      alias_method :run, :run_with_retry

      private

      def finish_with_retry(reporter)
        if (@retrying_count || 0) < 1 && @exception
          false
        else
          finish_without_retry(reporter)
        end

      end

      alias finish_without_retry finish
      alias finish finish_with_retry

      def retry_needed?
        # 元々は@optionsで[:auto_retry]としていたが、metadata[:auto_retry]に書き換え
        metadata[:auto_retry] == true || file_path =~ /spec\/features/
      end
    end
  end
end

その他にはguardのバージョンが低くて動かないようになっていたので、最新のものにアップデートしRspec3でも動くようにしました。

まとめ

以上、弊社のRailsアプリでのRspec3.0へのアップデート手順を記述してみました。作業自体は2〜3時間で終わりました。transpecという神Gemがあるので思ったよりも大変ではなかったですので、皆さんもアップデートしてはいかがでしょうか?

またここで網羅できなかったことおよび、基本的な手順は以下の記事から得ることができます。

【MySQL】`SELECT id FROM news ORDER BY published_at DESC` と `SELECT * FROM news ORDER BY published_at DESC` では結果が異なることについて

皆さん、こんにちは。 暖かくなってきたので Tシャツとサンダルで通勤しちゃったりする方、 MUGENUP の osada です。 服装に気を使わなくてよい(わけではないのですが)というのは、 エンジニアの利点の一つですよね。

さて、先日、こんな現象が持ち込まれました。

「下の2つのコードで、結果が異なるんですが……?」

News.order("published_at DESC").map(&:id)
News.order("published_at DESC").select(:id).map(&:id)

今日はMySQL の order で * は特別な動きをする(ようだ)というお話です。

対象は、上記の2つの結果が異なる理由が分からない人です。

Rails ではなく、MySQL レベルの挙動の違いだった

まず疑ったのが、Rails で挙動を書き換えているんじゃないか? ということです。 select をハックして、SQLレベルでは、order が書き換わっているのでは?

しかし、結果はNOでした。

[2] pry(main)> News.order("published_at DESC").map(&:id)
  News Load (4.4ms)  SELECT `news`.* FROM `news` ORDER BY published_at DESC
=> [10, 11, 12, 13, 14, 15, 16, 9, 8, 7, 6, 5, 4, 3, 2, 1]
[3] pry(main)> News.order("published_at DESC").select(:id).map(&:id)
  News Load (0.5ms)  SELECT id FROM `news` ORDER BY published_at DESC
=> [15, 14, 13, 12, 11, 10, 16, 9, 8, 7, 6, 5, 4, 3, 2, 1]

試しに、発行されたSQLを、db console から叩きましたが、結果は同じでした。

10 から 15 までの順番が、全く逆順になっているのです。

そこに注目してみると、 id が 10 から 15 のレコードの、published_at は 値が同じ でした (これはバグだったので、プルリクで止めました)。

そういえば、ORDER BY で対象カラムの値が同じとき、MySQL はどのような挙動を示すのでしょうか?

MySQLオプティマイザトレース

幸いにも、MySQLには、実行計画を見る仕組みが備わっていました。

[D14] MySQL 5.6時代のパフォーマンスチューニング *db tech showcase 2013 Tokyo

こちらの 17ページ の通りにやってみます。

mysql> set session optimizer_trace='enabled=on,one_line=off';
Query OK, 0 rows affected (0.17 sec)

mysql> set session optimizer_trace_max_mem_size=102400;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT id FROM `news` ORDER BY published_at DESC;
......

mysql> select * from information_schema.optimizer_trace\g;
......
| SELECT `news`.id FROM `news` ORDER BY published_at DESC | {
  "steps": [
    {
      "join_preparation": {
        "select#": 1,
        "steps": [
          {
            "expanded_query": "/* select#1 */ select `news`.`id` AS `id` from `news` order by `news`.`published_at` desc"
          }
        ]
      }
    },
    {
      "join_optimization": {
        "select#": 1,
        "steps": [
          {
            "table_dependencies": [
              {
                "table": "`news`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [
                ]
              }
            ]
          },
          {
            "rows_estimation": [
              {
                "table": "`news`",
                "table_scan": {
                  "rows": 16,
                  "cost": 1
                }
              }
            ]
          },
          {
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ],
                "table": "`news`",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "access_type": "scan",
                      "rows": 16,
                      "cost": 4.2,
                      "chosen": true,
                      "use_tmp_table": true
                    }
                  ]
                },
                "cost_for_plan": 4.2,
                "rows_for_plan": 16,
                "sort_cost": 16,
                "new_cost_for_plan": 20.2,
                "chosen": true
              }
            ]
          },
          {
            "attaching_conditions_to_tables": {
              "original_condition": null,
              "attached_conditions_computation": [
              ],
              "attached_conditions_summary": [
                {
                  "table": "`news`",
                  "attached": null
                }
              ]
            }
          },
          {
            "clause_processing": {
              "clause": "ORDER BY",
              "original_clause": "`news`.`published_at` desc",
              "items": [
                {
                  "item": "`news`.`published_at`"
                }
              ],
              "resulting_clause_is_simple": true,
              "resulting_clause": "`news`.`published_at` desc"
            }
          },
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "table_scan"
              }
            ]
          }
        ]
      }
    },
    {
      "join_execution": {
        "select#": 1,
        "steps": [
          {
            "filesort_information": [
              {
                "direction": "desc",
                "table": "`news`",
                "field": "published_at"
              }
            ],
            "filesort_priority_queue_optimization": {
              "usable": false,
              "cause": "not applicable (no LIMIT)"
            },
            "filesort_execution": [
            ],
            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 14544,
              "sort_mode": "<sort_key, additional_fields>"
            }
          }
        ]
      }
    }
  ]
} |                                 0 |                       0 |
.....

凄い色々でてきました。 一つ一つ見ていくと、大変興味深そうですが、 今回は、違いだけに注目したいので、 さっそくもう一つのSQLの実行計画も確認します。

mysql> SELECT * FROM `news` ORDER BY published_at DESC;

mysql> select * from information_schema.optimizer_trace\g;

すると、ある一点だけが異なりました。

            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 10908,
              "sort_mode": "<sort_key, rowid>"
            }

filesort_summarysort_mode です

SQL sort_mode
SELECT id FROMnewsORDER BY published_at DESC; "<sort_key, additional_fields>"
SELECT * FROMnewsORDER BY published_at DESC; "<sort_key, rowid>"

SELECT * のときは、sort_key の他に rowid が使用されるようです。 つまり、published_at が同じときは、rowid でソートされるのですね。

MySQL の rowid ?

この row_id については、ググると下記の情報が見つかりました。

8.2.1.15 ORDER BY Optimization

The sort_mode value provides information about the algorithm used and the contents of tuples in the sort buffer:

  • <sort_key, rowid>: Sort buffer tuples contain the sort key value and row ID of the original table row. Tuples are sorted by sort key value and the row ID is used to read the row from the table.
  • <sort_key, additional_fields>: Sort buffer tuples contain the sort key value and columns referenced by the query. Tuples are sorted by sort key value and column values are read directly from the tuple.

  • <sort_key, rowid>: ソートバッファタプル には、ソートキー と テーブルの行id が含まれる. タプルは ソートキーと、テーブルから読まれた行IDによって、ソートされる。

  • <sort_key, additional_fields>: ソートバッファタプルには、ソートキーと、クエリで参照されるカラムが含まれる。タプルは、タプルから直接読み取られたソートキーとカラムによってソートされる。

……なかなか難しいですね!

これ以上の資料が見つからなかったため、挙動の類推でしかないのですが、 rowid のときは、元のテーブルの順序をそのまま維持する という動作なのではないかと思います。

実際、自分で比較アルゴリズムを書くならば、 同値のときに、わざわざ swap するプログラムにはしないと思うのです。

# 昇順 (後ろほど、値が大きい)

for(i = 0, length = tuples.length; i < length - 1; i++){
  if(tuples[i + 1] < tuples[i]){
    tmp = tuples[i];
    tuples[i] = tuples[i + 1];
    tuples[i + 1] = tuples[i];
  } else if(tuples[i + 1] == tuples[i]){
    # わざわざここに swap を書いたりしない。
  }
}

一方、クエリ上に カラムが存在した場合、当然ながらそれはソートします。

ということで、* と、それ以外で、結果が異なる、ということになったようです。

インデックスを貼るとどうなるか?

さてfilesortをみたのなら、当然のようにインデックスを貼りたくなるはずです。

mysql> alter table news add index published_at_idx (published_at);
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0


mysql> SELECT id FROM `news` ORDER BY published_at DESC;
......
mysql> select * from information_schema.optimizer_trace\g;
......
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "index_scan"
              }
            ]
          },
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": true,
                "order_direction": "desc",
                "index": "published_at_idx",
                "plan_changed": true,
                "access_type": "index_scan"
              }
            }
          }
......
mysql> SELECT * FROM `news` ORDER BY published_at DESC;
......
mysql> select * from information_schema.optimizer_trace\g;
......
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "table_scan"
              }
            ]
          },
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": false,
                "order_direction": "undefined",
                "index": "unknown",
                "plan_changed": false
              }
            }
          }
......
            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 10908,
              "sort_mode": "<sort_key, rowid>"
            }

* ではインデックスが使われず、id ではインデックスが使われました。 一体どうゆうことなんでしょうか?

MySQL :: MySQL 5.1 リファレンスマニュアル :: 6.2.12 ORDER BY最適化

によれば、index が貼られた カラムの order ならば、index が使われるはずですが……?

次のクエリではインデックスを使用して ORDER BY部分を解決します。

SELECT * FROM t1
  ORDER BY key_part1,key_part2,... ;

レコードを全件取得しているので、テーブルスキャンが最適だ、ということなのでしょうか? 試しに LIMIT 10 をつけてみると、plan_changed が発動していました。 なるほど、絞込をするときには、インデックスが使われるのですね。

mysql> SELECT * FROM `news` ORDER BY published_at DESC LIMIT 10;
......
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": true,
                "order_direction": "desc",
                "index": "published_at_idx",
                "plan_changed": true,
                "access_type": "index_scan"
              }
            }           

単に ORDER BY でインデックスを貼っても効果がないこともあるようなので、 必ず確認して使っていくようにしたいものです。

このindex 周りは奥が深いので、別の機会に、とさせていただこうと思います。 「待てないよ!」という方は、漢(オトコ)のコンピュータ道 が絶賛、オススメです!

まとめ

  • MySQLORDER BY は、ソート指定カラムの値が同値のときに、クエリによって、結果が異なることがあります。
  • SELECT * FROM のときは、rowid が使われるので、同値のときは、テーブルへ INSERT された順がそのままです。
  • SELECT some_column FROM のときは、ORDER BY の指定カラム以外に、some_column がソート対象に含まるため、それらがソートに使われます。
  • ORM を使っている人は、意識しづらいので、気をつけましょう。
  • 同値が疑われるときは、必ず id でも order しましょう. News.order("published_at DESC").order("id DESC").map(&:id)

ということで、思わぬところで、order 順が違ってしまった、という記事でございました。

後書き

なお、使用しているMySQLは、5.6.17 です

Server version: 5.6.17 Homebrew

紙幅の関係上、order の内容を文字列 にしましたが、arel_tableを使ったほうがせくしーです。

News.order(News.arel_table[:published_at].desc)

ところで、「Oracle には row_id があり、MySQL にはないので、テーブルの最後のレコードを見つけるのが大変だ」 という話を、昔同僚としたことがあるのですが、MySQL はいつから row_id が使えるようになったのでしょうか? Oracle に吸収されてからですか?