Rails4をHerokuに(クラウドに一歩一歩近づく)

前回、Google App EngineでGuestbookを作ったので、これと同じことをRuby on Rails 4で、しかもやはりPaaSのHerokuにデプロイしてみました。

まずはRubyを2.0.0にし、Rails4をインストール

もともとrvmを使って、Rubyは2.0.0をインストールしていたのですが、デフォルトではsslに対応していません(Gemfileの1行目のhttpsをhttpに変更すれば良いらしいですが)。したがって、再度インストールし直しました。

Ruby2.0.0-p0

https://gist.github.com/jfirebaugh/4007524
にある、オプション1でインストールしました。すなわち下のよう。

brew update
brew install openssl
brew link --force openssl #これが必要かは不明?
rvm get head
rvm install 2.0.0-p0 --with-openssl-dir=`brew --prefix openssl`
rvm use 2.0.0-p0
ruby --version
ruby 2.0.0p0 (2013-02-24 revision 39474) [x86_64-darwin12.2.1]
Rails 4
gem install rubygems-update
gem -v
2.0.2
rvm --create 2.0.0@rails4
rvm --default use 2.0.0@rails4
gem install rails --version 4.0.0.beta1 --no-ri --no-rdoc

herokuコマンドをインストール

以前は、gemでインストールしていたのですが、現在はHerokuから提供されているので、これをインストールしました。
https://toolbelt.heroku.com/osx
これで、準備は終了。

PostgreSQLのインストールとgem install pg

PostgreSQL9.2

http://www.enterprisedb.com/products-services-training/pgdownload#osx
からダウンロードして、インストール。

gem install pg
export PATH=$PATH:/Library/PostgreSQL/9.2/bin
gem install pg #これではインストールできず
env ARCHFLAGS="-arch x86_64" gem install pg -- --with-pg-config=/Library/PostgreSQL/9.2/bin/pg_config

「/Library/Postgresql/9.2/bin」にパスを通しておいた方が便利。

PostgreSQLの設定

どうやらHerokuでは(実際はよくわかっていない)、DBのユーザを作ったりパスワードを設定したり出来ないように思えるので、ローカルでもアプリケーション名と同じ名前のユーザーで、パスワードなしでアクセスできるようにするために、pg_hba.confを以下のように変更する。

local   all             all                                     trust           
# IPv4 local connections:                                                       
host    all             all             127.0.0.1/32            trust           
# IPv6 local connections:                                                       
host    all             all             ::1/128                 trust           

sudo su postgres -c '/Library/Postgresql/9.2/bin/pg_ctl start -D /Library/Postgresql/9.2/data'


それから、ついでにユーザを作成(前述したように、Herokuではアプリケーション名と同じPostgreSQLユーザが利用されるようなので)、データベースも(ローカルはdevelopment環境なので、「myfirst_development」)を作成する。

psql
create user myfirst CREATEDB CREATEUSER;
create database myfirst_development;

railsコマンドでアプリをひな形を作成

rails new myfirst --edge --database=postgresql  --skip-index-html
...
  Your bundle is complete! Use `bundle show [gemname]` to see where a bundled gem is installed.
cd myfirst
rails generate scaffold member guest_name:string content:text created_at:datetime
Heroku にtilt (1.3.5)がないらしい

gem moduleのtilt (1.3.5)がHerokuにないらしく、デプロイしようとするとエラーになるので、myfirstディレクトリーのルートにある「Gemfile.lock」内の

  tilt (1.3.5)

  tilt (1.3.3)

に変更する。

Gemfileの修正
  gem 'rails',     github: 'rails/rails'

gem 'rails', '4.0.0.beta1'
ruby '2.0.0'

に、またunicornを利用するために

  # gem 'unicorn'

のコメントを外します。
そしておきまりの

  bundle install
unicornの設定ファイルunicorn.rb
worker_processes 1
timeout 30
preload_app true

before_fork do |server, worker|
# Replace with MongoDB or whatever
if defined?(ActiveRecord::Base)
ActiveRecord::Base.connection.disconnect!
Rails.logger.info('Disconnected from ActiveRecord')
end

# If you are using Redis but not Resque, change this
old_pid = "#{server.config[:pid]}.oldbin"
    if old_pid != server.pid
      begin
        sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU
        Process.kill(sig, File.read(old_pid).to_i)
      rescue Errno::ENOENT, Errno::ESRCH
      end
    end

sleep 1
end

after_fork do |server, worker|
  if defined?(ActiveRecord::Base)
    ActiveRecord::Base.establish_connection 
  end
end
テーブルなどを作成
rake db:migrate
==  CreateMembers: migrating ==================================================
-- create_table(:members)
   -> 0.0062s
==  CreateMembers: migrated (0.0063s) =========================================
config/routes.rb

にrootを追加

Myfirst::Application.routes.draw do
  resources :members
  root :to => "members#index"
ローカルで確認
bundle exec unicorn_rails -E development -c config/unicorn.rb

git
git init
git add .
git commit -m 'initial commit'
herokuアプリの新規作成

herokuのメンバー登録が終わっていない場合は、http://www.heroku.com/に行って、登録を済ませます(実に簡単に登録でいます!)。
また「herokuコマンド」は初回のみメールアドレスとパスワードを聞いています。

heroku create myfirst20130308

これでHerokuにおけるアプリの新規作成は終了。

herokuへのデプロイ
git push heroku master
heroku run rake db:migrate
サーバでの確認

http://myfirst20130308.herokuapp.com/
で、ローカルと同様に確認できます。

Google App Engine & Goolge Cloud SQL(MySQL 5.5)& Python

色々あるPaaSで無料で試せる(http://http://server.kensapo.com/cloud.html)というとどうしても「Google App Engine」になってしまいます。しかし、DB的にビッグテーブルしかないとなると、相当これまでのコーディングスタイルを変えざるを得ないので、二の足を踏んでいたのですが、よく調べてみると、とっくにGoogle Cloud SQL(MySQL5.5)が使えるようになっているではありませんか(気づくのに遅すぎ!)。しかも2013年の6月まで無料です!

そこで今回、GoogleのTutorial(https://developers.google.com/appengine/training/cloud-sql/application_with_local_mysql?hl=ja)にあるGustbookを作成してみました、その時の備忘録です(というか、ほとんどTutorialそのまんまです)。

  1. Google App Engine
  2. Google APIs Console

以上の2つのサイトでそれぞれ、アプリケーションの登録、Google Cloud SQLの登録およびそれをどのアプリケーションの使うのかという設定を行います。

アプリの登録

Googleアカウント等を登録していない人はhttp://symfoware.blog68.fc2.com/blog-entry-252.htmlあたりを見て、まずは登録する必要があります。ちなみに、GoogleのアカウントとGoogle App Engineのアカウントは同じでOKですが、手続きは必要です。

Google Cloud SQLインスタンス作成

  • http://libro.tuyano.com/index3?id=1062003(→このサイトではbillingでチャージしなくてはならないようなことが書いていますが、現在はそのような必要はないようです)や本家のhttps://developers.google.com/cloud-sql/docs/before_you_begin?hl=jaに詳しく書いていますので詳細はこちらをご覧ください。概略は次のような感じです。
    • https://code.google.com/apis/consoleに行く
    • プロジェクトがない場合はプロジェクト名を指定して、プロジェクトを作成します。例えば「myApplication201303」など。
    • 左側画面の「service」をクリックして、右側の画面のサービスの画面からGoogle Cloud SQLをONにする
    • 左側の「Billing」を選択して、カードの登録をします。無料なのですが、登録だけは必要のようです。登録後数十分待ちます(数時間という人もいます)。私の場合は、何度か試しているうちに(すぐに反映すると勘違いしていたもので・涙)、反映されました。
    • 右側のGoogle Cloud SQLのメニューをクリックします。
    • 右の方にある「New Instance...」をクリックします。
      • Name:データベース名を入力。例えば「sqltest」。
      • Size:サイズは無料のD0を選択するのがポイントです。これが無料のサイズだからです(現在は赤い字で無料はD0だよ、って書いてあります)。
      • Replication Mode:データ書き出しのモード指定。とりあえず、Synchronousを選択。
      • Pricing Plan:D0を選んでいるので、選べないようになっています。
      • Authorized Application:上記で登録したGoogle App Engineで登録したアプリを選択(sqltestfirst)。
      • Create instanceをクリックして作成。結果、
      • その結果、そのGoogle Cloud SQLインスタンスID(上記の例だと)「myApplication201303::sqltest」となり、後にこれが必要になります。

開発環境の整備

とりあえずローカルでアプリ作成

CREATE USER 'hoge'@'localhost' IDENTIFIED BY 'fuga';
GRANT ALL PRIVILEGES ON *.* TO 'hoge'@'localhost' WITH GRANT OPTION;
create database & table
create database guestbook charset utf8;
use guestbook;
CREATE TABLE entries
  (id int not null auto_increment primary key,
   guest_name varchar(255),
   content varchar(255),
   created_at timestamp)
  ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into entries (guest_name, content) values ('chikkun', 'Myself');
  • ローカルのアプリを作成するディレクトリを適当に作成し、そのトップでapp.yamlという名前のファイルを作成します。
cd
mkdir sqltest
cd sqltest
emacs app.yaml
  • app.yamlの中身。application:には先程Google App Engineで登録したアプリ名を入れます。また、今回はpython2.7なのでそれを指定し、pythonスクリプトはmain.applicationとし、テンプレートにはjinja2を使います。
application: sqltestfirst
version: 1
api_version: 1
runtime: python27
threadsafe: true

handlers:
- url: /.*
  script: main.application

libraries:
- name: jinja2
  version: latest
  • main.pyをトップに作成
import os
import logging

from google.appengine.api import rdbms
from google.appengine.ext import webapp

import jinja2

template_path = os.path.join(os.path.dirname(__file__))

jinja2_env = jinja2.Environment(
    loader=jinja2.FileSystemLoader(template_path)
)

CLOUDSQL_INSTANCE = ''#デプロイの時に必要となる。
DATABASE_NAME = 'guestbook'
USER_NAME = 'hoge'
PASSWORD = 'fuga'


def get_connection():
    return rdbms.connect(instance=CLOUDSQL_INSTANCE, database=DATABASE_NAME, user=USER_NAME, password=PASSWORD, charset='utf8')

class MainHandler(webapp.RequestHandler):
    def get(self):
        conn = get_connection()
        cursor = conn.cursor()
        cursor.execute('SELECT guest_name, content, created_at FROM entries '
                       'ORDER BY created_at DESC limit 20')
        rows = cursor.fetchall()
        conn.close()
        template_values = {"rows": rows}
        template = jinja2_env.get_template('index.html')
        self.response.out.write(template.render(template_values))


class GuestBook(webapp.RequestHandler):
    def post(self):
        conn = get_connection()
        cursor = conn.cursor()
        cursor.execute('INSERT INTO entries (guest_name, content) '
                       'VALUES (%s, %s)',
                       (self.request.get('guest_name'),
                        self.request.get("content")))
        conn.commit()
        conn.close()
        self.redirect("/")


application = webapp.WSGIApplication(
    [
        ("/", MainHandler),
        ("/sign", GuestBook),
    ],
    debug=True
)
  • 「index.html」という名前の jinja2テンプレートをトップに作成。
<!DOCTYPE html>
<html>
<head>
<title>CloudSQL Tutorial</title>
</head>
<body>
<h2>Guestbook</h2>
<form action="sign" method="POST">
  Name: <input name="guest_name"><br>
  <textarea name="content" rows="3" cols="60"></textarea><br>
  <input type="submit" value="Sign Guestbook">
</form>
<hr>
{% for row in rows %}
  <p>{{ row[0]|e }} wrote:
    <blockquote>
      {{ row[1]|e }}
    </blockquote>
    at {{ row[2]|e }}
  </p>
  <hr>
{% endfor %}
  • 開発ウェブサーバの起動(mysqlのオプションは自分の環境で要変更)
展開ディレクトリ/google_appengine/dev_appserver.py --mysql_socket=/var/lib/mysql/mysql.sock
  1. アプリの確認

ローカルにあるアプリのデプロイ

  1. Google Cloud SQLインスタンスは作ったけれど、まだTableを作成していないので(ユーザなんかも)、上記のローカルで行ったSQLを行う。それには下のように、左のGoogle Cloud SQLSQL Promptタブの中のTextAreaで行う。

  • ローカルのapp.yaml内のCLOUDSQL_INSTANCEを実際のものに書き換えます。他もローカルと違う場合は変更します。
CLOUDSQL_INSTANCE = 'myApplication201303::sqltest'
DATABASE_NAME = 'guestbook'
USER_NAME = 'hoge'
PASSWORD = 'fuga'

Googleへデプロイ

appcfg.pyでデプロイします。

  • コマンドを叩く
cd ..
展開ディレクトリ/google_appengine/appcfg.py update guestbook/
  • メールアドレスとパスワードを入力。メールアドレスはGoogle App Engineを登録した際のGoogleのアカウント用のメールアドレスです。パスワードをも同様。

これで終了です。

http://sqltestfirst.appspot.com/

で確認できます(はず(^_^;)。

7種類の言語のパフォーマンス

例えば、WEBアプリを作成しようとすると、まずは言語が決まり、その言語のフレームワークを決め、その上で、DBなどを決めて、コーディングが始まるのだと思うのですが、そのWEBアプリケーションの中に複数の言語を混在させることは希です。

まあ、当然です。複数の言語をやっていると、プログラマーは混乱して、Javaをコーディングしているのに変数に「$」をつけて、Eclipseが怒っている、なんてことが起こるからです(それは僕ですw)。

ただ、一方で、ある処理をしたいときに、どうしても選択した言語ではパフォーマンスが出ないとか、画像処理をしたいのに目的のような処理ができるライブラリがみつからないとか、そのような面倒な局面というのが時々あります。

そこで、今回は各言語において、色々な処理の各言語のパフォーマンスを計ってみて、今後のパフォーマンスの知見にしようと最近チマチマ試験をしてみました。そうすれば、もしかしたら今後役に立つかも(本当に「かも」ですが)というわけです。

ただ、色々な条件、例えば、言語自体のコンパイルオプションの違い、プログラムのロジックなど様々な要因で結果が変わる可能性がありますの、あくまでも、ざっくりとしたものになってしまいますが、ご容赦下さい。

さて、試験で留意したのは次のことです。

  1. 実験機はIntel(R) Core(TM) i7-3770 @ CPU 3.5GHz
  2. 実験機のメモリーは16G
  3. 実験機のOSはCentOS6.3
  4. 言語は次の7つ。各言語は比較的新しいヴァージョンにする(Javaは1.6内で)。
    1. Sun Java 1.6.0_37
    2. C++ 4.4.6
    3. Perl 5.16.1
    4. Python 3.3.0
    5. PHP 5.4.12
    6. Ruby 1.9.3
    7. Node.js 0.8.14
  5. DBの試験ではPostgreSQLを利用。
  6. C++とNode.jsはコーディング的に危ないかも(すみません、素人なので)
  7. C++コンパイルオプションに「-03」でコンパイル
  8. 4回試験して、1回目を捨てて、後半3回分のデータを記録。
  9. 以下の処理のパフォーマンスを計った。
    1. forループ内(10,000,000回)で j = j + 1を実行する。
    2. Forループ内(10,000,000回)で文字aの連結を実行する。
    3. 10,000,000個の配列に全て同じ文字列abを代入する。
    4. 1,000,000個のMap(連想配列)すべてに、文字列aとiを文字にキャストしたものをキーにして、iを文字にキャストしたものと文字列sakaiを連結したものを代入する。
    5. 100,000個の単純なビーンクラス(Person)を作って、それらを全て配列に代入する。
    6. 700,000行のファイルをオープンし、Aliceで始まる行を正規表現でチュエックし、カウントする作業を行う(不思議の国のアリスを70万行文複製したファイルを利用)。
    7. 10,000個のレコードをDBにインサート、その後そのレコード数を取得する。
  10. 計測はtimeコマンドのuserを、ただし、ファイルをオープンするものとDBにアクセスするのはrealも記録。

結果は、次のようです。

色々考察は出来るでしょうが、とりあえず第1印象だけでも。

  1. C++はさすがに速い。ただ、Mapや、クラスのインスタンス生成、また、正規表現などはそれほどでもない。
  2. 全般的には、思ったよりPHPが検討している(これは予想外!)。
  3. Perlはファイルをオープンして、正規表現等の処理は、やはり、得意。
  4. Rubyは、僕のコーディングなどが駄目なことやコンパイルオプション等も考えられるが(rubyはpythonzでインストール)、思ったより遅い。
  5. Node.jsは、パフォーマンスとは無関係な話だけれど、処理をした後に結果を表示する際に、非同期なのでうまくいかなくて、相当苦労した(ふう、勉強しようっと!)。
  6. Node.jsは、もしかしたら、僕のコーディングに問題の可能性はあるが、外部ファイルなどにアクセスするものは遅い一方で、そうでない処理に関しては結構速い。

ソースや結果などはhttp://www.chikkun.com/performance.zipにあります。必要なら、ここからダウンロードして下さい。

Yet Another Small はてダラ暫定版

まだまだテスト不足な上に、ソースもあまり綺麗じゃありませんが、とりあえず暫定版を作成してみました。
仕様を考えてから(11/19)、10日以上たったのですが、なかなか時間が取れず、この程度しかできておりません(面目ない)。

の2つのモジュールが必要です。

perl -MCPAN -e shell
install Atompub::Client
install XML::Atom::Entry

してください。そして、以下のソースを、仮にyanhw.plとして保存し、以下のようなconfig.txtファイルをそのディレクトリに置き、2012-12-01.txtを作成してみてください。

perl yanhw.pl 2012-12-01.txt

すれば、登録できます(保証できませんが・汗)。

ちなみに、2012-12-01.txtの1行目はタイトル行で「*」はいりません。送信後、ファイル内を書き換えるので、エディターで開いている場合は開き直してください。

config.txt

#
# はてなのユーザID(デフォルトは空)
id:chikkun530
#
# パスワード(デフォルトは空)
password:hogehoge
#
# ディレクトリ:YYYY-MM-DD.txtのファイルを置いておく場所(デフォルトは . )
txt_dir:~/Dropbox/blog/gijutu
#
# タッチファイル:送信時に更新されるファイル(デフォルトは touch.txt)
touch:~/Dropbox/blog/gijutu/hw.touch
#
# HTTPプロキシー(デフォルトは空)
#proxy:http://www.example.com:8080/
#
# クライアント(ローカル)側の文字コード(デフォルトは空)
#client_encoding:Shift_JIS
#
# フィルタコマンド
#filter:iconv -f euc-jp -t utf-8 %s


yanhw.pl

use strict;
my $VERSION = "0.0.1";

use File::Basename;
use Getopt::Std;
use Atompub::Client;
use XML::Atom::Entry;
use utf8;
use FileHandle;
use Data::Dumper;#for dbug
my $enable_encode = eval('use Encode; 1');

# Prototypes.
sub login();
sub update_diary_entry($$$$$);
sub delete_diary_entry($);
sub doit_and_retry($$);
sub replace_image_name($);
sub replace_image_name_string($);
sub draft_it($$$$$);
sub insert_id($$);
sub delete_it($);
sub post_it($$$$$);
sub send_image();
sub get_timestamp();
sub print_debug(@);
sub print_message(@);
sub read_title_body($);
sub find_image_file($);
sub replace_timestamp($);
sub error_exit(@);
sub load_config();

# Hatena user id (if empty, I will ask you later).
my $username = '';
# Hatena password (if empty, I will ask you later).
my $password = '';
# Hatena group name (for hatena group user only).
my $groupname = '';

# AtomPub client
my $client = Atompub::Client->new;


# Default file names.
my $touch_file = 'touch.txt';
my $config_file = 'config.txt';
my $target_file = '';


# Filter command.
# e.g. 'iconv -f euc-jp -t utf-8 %s'
# where %s is filename, output is stdout.
my $filter_command = '';

# Proxy setting.
my $http_proxy = '';

# Directory for "YYYY-MM-DD.txt".
my $txt_dir = ".";#last / is no need
my $image_dir = '.';

# target file name
my $fname = "";
# Client and server encodings.
my $client_encoding = 'UTF-8';#overriding is possible
my $server_encoding = 'UTF-8';#overriding is impossible

# Hatena URL.
my $hatena_url = 'http://d.hatena.ne.jp';
my $draft_url = '';#after login will be assigned
my $post_url = '';#after login will be assigned
my $image_url = 'http://f.hatena.ne.jp/atom/post';

my @suffixlist = ("jpg","JPG","png","PNG","gif","GIF");
my $hatena = XML::Atom::Namespace->new(hatena => 'http://www.hatena.ne.jp/info/xmlns#');

# Other variables.
my $delete_title = ":delete:";
my $delete_regex = qr/.*$delete_title$/i;
my $draft_title = "draft:";
my $draft_regex = qr/^$draft_title.+/i;

my $delete_flag = 0;
my $draft_flag = 0;
my $update_flag = 0;
my $blog_id = "";
my $image_flag = 0;
my @images = ();
my %image_ids = ();

my $rkm; # session id for posting.

# Handle command-line option.
my %cmd_opt = (
    'u' => "",  # "username" option.
    'p' => "",  # "password" option.
    'g' => "",  # "groupname" option.
    'f' => "",  # "file" option.
    'M' => 0,   # "no timestamp" flag.
    'n' => "",  # "config file" option.
);

$Getopt::Std::STANDARD_HELP_VERSION = 1;
getopts("du:p:g:f:Mn:", \%cmd_opt) or error_exit("Unknown option.");

if ($cmd_opt{d}) {
    print_debug("Debug flag on.");
    &VERSION_MESSAGE();
}

# Override config file name (before load_config).
$config_file = $cmd_opt{n} if $cmd_opt{n};

# Override global vars with config file.
load_config() if -e($config_file);

# Override global vars with command-line options.
$username = $cmd_opt{u} if $cmd_opt{u};
$password = $cmd_opt{p} if $cmd_opt{p};
$groupname = $cmd_opt{g} if $cmd_opt{g};
$target_file = $cmd_opt{f} if $cmd_opt{f};



# Change $hatena_url to Hatena group URL if ($groupname is defined).
if ($groupname) {
    $hatena_url = "http://$groupname.g.hatena.ne.jp";
}

# Start.
&main;

# no-error exit.
exit(0);


# Main sequence.
sub main {
    my $count = 0;
    my @files;

    # Setup file list.
    if ( $cmd_opt{f} ) {

        # Do not check timestamp.
        push( @files, $cmd_opt{f} );
        print_debug("main: files: option -f: @files");
    }
    else {
        while ( glob("$txt_dir/*.txt") ) {

            # Check timestamp.
            next if ( -e ($touch_file) and ( -M ($_) > -M ($touch_file) ) );
            push( @files, $_ );
        }
        print_debug("main: files: current dir ($txt_dir): @files");
    }

    # Process it.
    login();
    $draft_url = "$hatena_url/$username/atom/draft";
    $post_url = "$hatena_url/$username/atom/blog";

    print_debug("draft_url:$draft_url");
    print_debug("post_url:$post_url");

    for (@files) {

        # Check file name.
        $draft_flag  = 0;
        $delete_flag = 0;
        $update_flag = 0;
        $image_flag  = 0;
        @images      = ();
        next unless (/\b(\d\d\d\d)-(\d\d)-(\d\d)\.txt$/);

        my ( $year, $month, $day ) = ( $1, $2, $3 );
        my $date  = $year . $month . $day;
        $fname = $_;

        # Check if it is a file.
        next unless ( -f $fname );

        # Login if necessary.

        # Replace "*t*" unless suppressed.
        replace_timestamp($fname) unless ( $cmd_opt{M} );

        # Read title and body.
        my ( $title, $body ) = read_title_body($fname);

        # Find image files.
        #my $imgfile = find_image_file($_);

        if ($delete_flag) {
            # Delete entry.
            print_message("Delete $year-$month-$day.");
            delete_diary_entry($date);
            print_message("Delete OK.");
            sleep(1);
            $count++;
            next;
        }

#       print_message("Post $year-$month-$day.  " . ($imgfile ? " (image: $imgfile)" : ""));
        send_image();

        update_diary_entry( $year, $month, $day, $title, $body );
        print_message("Post OK.");

        sleep(1);
        $count++;
    }

    if ( $count == 0 ) {
        print_message("No files are posted.");
    }
    else {
        unless ( $cmd_opt{f} ) {

            # Touch file.
            open( FILE, "> $touch_file" ) or error_exit("touch_file error, $!:$touch_file");
            print FILE get_timestamp;
            close(FILE);
        }
    }
}


# Delete.
sub delete_it($) {
    my ($date) = @_;
    print_debug("delete_it: $date");

    my $entry = XML::Atom::Entry->new;
    my $entry_uri;
    if ($draft_flag) {
        $entry_uri = $client->deleteEntry( $draft_url . "/$blog_id", $entry );
    }
    else {
        $entry_uri = $client->deleteEntry( $post_url."/$date" . "/$blog_id", $entry );
    }
    print_debug("delete_it: ");

    # (Note that delete error != post error)
    if ( !$entry_uri ) {
        print_debug("delete_it: returns 0 (ERROR).");
        return 0;
    }
    else {
        print_debug("delete_it: returns 1 (OK).");
        return 1;
    }
}

#write the blog_id in the target file
sub insert_id($$) {
    my ( $filename, $epoch ) = @_;

    # Read.
    open( FILE, $filename ) or error_exit("cannot open target file(1:insert image id), $!: $filename");
    my $contents = "";
    my $linenum  = 0;
    while ( my $line = readline FILE ) {
        $linenum++;
        if ( $linenum == 2 && !$update_flag ) {
            if ($draft_flag) {
                $contents .= "<!--epoch=$epoch-->\n";
            }
            else {
                $contents .= "<!--blog_id=$epoch-->\n";
            }
        }
        foreach my $im ( keys(%image_ids) ) {
            while ( $line =~ /$im/gi ) {
                #f:id:hatenadiary:20041007101545j:image
                $line =~ s/$im/$image_ids{$im}/gi;
                print_debug("replace_image_name: $im");
            }
        }

        $contents .= $line;
    }
    close(FILE);
    print_debug("insert_id: $filename");
    open( FILE, "> $filename" ) or error_exit("2:cannot open target file(insert image id), $!: $filename");
    print FILE $contents;
    close(FILE);
}
#replace the image filename with the image syntax in the target file
sub replace_image_name($) {
    my ($filename) = @_;

    # Read.
    open( FILE, $filename ) or error_exit("cannot open target file, $!: $filename");
    my $contents = "";
    while ( my $line = readline FILE ) {
        foreach my $im ( keys(%image_ids) ) {
            while ( $line =~ /$im/gi ) {

                #f:id:hatenadiary:20041007101545j:image
                $line =~ s/$im/$image_ids{$im}/gi;
                print_debug("replace_image_name: $im");
            }
        }
        $contents .= $line;
    }
    close(FILE);
    print_debug("replaced image name: $filename");
    open( FILE, "> $filename" ) or error_exit("cannot open target file, $!: $filename");
    print FILE $contents;
    close(FILE);
}
#send image
sub send_image(){
    if($image_flag){
        print_debug("image file sending...");
        for my $im (@images){
            local $/; # slurp mode
            my $fh = FileHandle->new("$image_dir/$im")
                or error_exit("cannnot open $im: $!");
            my $image = $fh->getline;
            my ($name,$path,$suffix) = fileparse($im, @suffixlist);
            my $entry = XML::Atom::Entry->new;
            $entry->content($image);
	    my $s = lc($suffix);
            $entry->content->type("image/$s");
            print_debug("content_type: image/$s");
            $entry->title($im);
            my $image_id = $client->createEntry($image_url, $entry) or
                error_exit("image file not send:$client->errstr");
            my $res = XML::Atom::Entry->new(Stream => \$client->{response}->content);
            $image_ids{$im} = $res->get($hatena, "syntax");
            print_debug("post image: $im : $image_id:$image_ids{$im}");
        }
    }
}

sub replace_image_name_string($) {
    my ($body) = @_;
    foreach my $im ( keys(%image_ids) ) {
        while ( $body =~ /$im/gi ) {
            #f:id:hatenadiary:20041007101545j:image
            $body =~ s/$im/$image_ids{$im}/gi;
        }
    }
    return $body;
}

#post the blog
sub post_it($$$$$) {
    my ( $year, $month, $day, $title, $body ) = @_;
    my $entry;
    print_debug("post_it: $year-$month-$day.");

    if ($image_flag) {
	$body = replace_image_name_string($body);
    }

    $entry = XML::Atom::Entry->new;
    $entry->title($title);
    $entry->content($body);

    my $entry_uri = "";
    if ( !$update_flag ) {
        $entry->updated("$year-$month-$day");
        $entry_uri = $client->createEntry( $post_url, $entry );
        $entry_uri =~ /(\d+)$/;

        warn $client->errstr || '\n';

        insert_id( $fname, $1 );
        print_debug("blog_id:$1 blog created($entry_uri)");
    }
    else {
        $entry_uri =
          $client->updateEntry( $post_url . "/$year$month$day/$blog_id",
            $entry );
        if ($image_flag) {
            replace_image_name($fname);
        }
        warn $client->errstr || '\n';
        print_debug("blog_id:$1 blog updated($entry_uri)");
    }
    print_debug("post_it");

    # Check the result. OK if the location ends with the date.
    if ($entry_uri) {
        print_debug("post_it: returns 1 (OK).");
        return 1;
    }
    else {
        print_debug("post_it: returns 0 (ERROR).");
        return 0;
    }
}

#post the draft blog
sub draft_it($$$$$) {
    my ( $year, $month, $day, $title, $body ) = @_;

    print_debug("draft_it: $year-$month-$day.");
    $title =~ s/$draft_title//;
    if ($image_flag) {
	$body = replace_image_name_string($body);
    }
    my $entry = XML::Atom::Entry->new;
    $entry->title($title);
    $entry->content($body);

    my $entry_uri = "";
    if ( !$update_flag ) {
        $entry_uri = $client->createEntry( $draft_url, $entry );
        $entry_uri =~ /(\d+)$/;
        insert_id( $fname, $1 );
        print_debug("entry_id:$1 draft created");
    }
    else {
        $entry_uri = $client->updateEntry( $draft_url . "/$blog_id", $entry );
        print_debug("entry_id:$1 draft updated");
    }
    #http://d.hatena.ne.jp/chikkun530/atom/draft/1353480488
    warn $client->errstr || '\n';
}

# read title and body, and check (draft|read) and (create|update)
sub read_title_body($) {
    my ($file) = @_;

    # Execute filter command, if any.
    my $input = $file;
    if ($filter_command) {
        $input = sprintf( "$filter_command |", $file );
    }
    print_debug("read_title_body: input: $input");
    if ( not open( FILE, $input ) ) {
        error_exit("cannot open, $!:$input");
    }
    my $title = <FILE>;    # first line.
    chomp($title);

    #タイトル行の最後に「:delete:」があったら、削除する
    if ( $title =~ /$delete_regex/ ) {
        $delete_flag = 1;
        print_debug("delete blog");
    }

    # タイトル行の最初に「draft:」があったら、それは「下書き保存」
    if ( $title =~ /$draft_regex/ ) {
        $draft_flag = 1;
        print_debug("draft blog");
    }
    my $second = <FILE>;

    # すでに登録している場合は、以下のような「<!--epoch=***-->」とか「<!--blog_id=***-->」が2行目にある。
    # TODO 登録されていないのに「ある」場合と、登録されているのに「ない」場合の仕様決め
    if (   $second =~ /<! *--epoch *= *(\d+) *-->/
        || $second =~ /<! *--blog_id *= *(\d+) *-->/ )
    {
        $update_flag = 1;
        $blog_id     = $1;
        print_debug("update blog");
    }

    my $body = "";    #join('', <FILE>); # rest of all.
    while (<FILE>) {
        while ( $_ =~ /(\w+\.(?:jpg|png|gif))/ig ) {
            if ( -e "$image_dir/$1" ) {
                push( @images, $1 );
                $image_flag = 1;
                print_debug("Image File:$1 exists in the $file");
            }
        }
        $body .= $_;
    }

    $body = $second . $body;
    close(FILE);

    # Convert encodings.
    if ( $enable_encode and ( $client_encoding ne $server_encoding ) ) {
        print_debug("Convert from $client_encoding to $server_encoding.");
        Encode::from_to( $title, $client_encoding, $server_encoding );
        Encode::from_to( $body,  $client_encoding, $server_encoding );
    }

    return ( $title, $body );
}


# Login.
sub login() {
    if ($http_proxy) {
        $client->proxy($http_proxy);
        print_debug("login: proxy for http: $http_proxy");
    }

    # Ask username if not set.
    unless ($username) {
        print "Username: ";
        chomp( $username = <STDIN> );
    }

    # Ask password if not set.
    unless ($password) {
        print "Password: ";
        chomp( $password = <STDIN> );
    }

    $client->username($username);
    $client->password($password);
    print_message("Login OK.");
}

# Update entry.
sub update_diary_entry($$$$$) {
    my ( $year, $month, $day, $title, $body ) = @_;

    if ( !$draft_flag ) {

        # Post.
        doit_and_retry( "update_diary_entry: POST.",
            sub { return post_it( $year, $month, $day, $title, $body ) } );
    }
    else {
        doit_and_retry( "draft_diary_entry: POST.",
            sub { return draft_it( $year, $month, $day, $title, $body ) } );
    }
}

# Delete entry.
sub delete_diary_entry($) {
    my ($date) = @_;

    # Delete.
    doit_and_retry( "delete_diary_entry: DELETE.",
        sub { return delete_it($date) } );
}

# Do the $funcref, and retry if fail.
sub doit_and_retry($$) {
    my ( $msg, $funcref ) = @_;
    my $retry = 0;
    my $ok    = 0;

    while ( $retry < 2 ) {
        $ok = $funcref->();
        if ( $ok or not $cmd_opt{c} ) {
            last;
        }
        print_debug("try_it: $msg");
        print_message("Something wrong! Retry login--retry $retry.");
        $retry++;
    }

    if ( not $ok ) {
        error_exit("giving up!.");
    }
}

# Get "YYYYMMDDhhmmss" for now.
sub get_timestamp() {
    my (@week) = qw(Sun Mon Tue Wed Thu Fri Sat);
    my ( $sec, $min, $hour, $day, $mon, $year, $weekday ) = localtime(time);
    $year += 1900;
    $mon++;
    $mon  = "0$mon"  if $mon < 10;
    $day  = "0$day"  if $day < 10;
    $hour = "0$hour" if $hour < 10;
    $min  = "0$min"  if $min < 10;
    $sec  = "0$sec"  if $sec < 10;
    $weekday = $week[$weekday];
    return "$year$mon$day$hour$min$sec";
}

# Show version message. This is called by getopts.
sub VERSION_MESSAGE {
    print <<"EOD";
Yet Another Hatena Diary Writer Version $VERSION
Copyright (C) 2012 by Chiku Kazuro.
EOD
}

# Debug print.
sub print_debug(@) {
    if ( $cmd_opt{d} ) {
        print "DEBUG: ", @_, "\n";
    }
}

# Print message.
sub print_message(@) {
    print @_, "\n";
}

# Error exit.
sub error_exit(@) {
    print "ERROR: ", @_, "\n";
    exit(1);
}

# Read title and body.

# Replace "*t*" with timestamp.
sub replace_timestamp($) {
    my ($filename) = @_;

    # Read.
    open( FILE, $filename ) or error_exit("timestamp replace, $!: $filename");
    my $file = join( '', <FILE> );
    close(FILE);

    # Replace.
    my $newfile = $file;
    $newfile =~ s/^\*t\*/"*" . time() . "*"/gem;

    # Write if replaced.
    if ( $newfile ne $file ) {
        print_debug("replace_timestamp: $filename");
        open( FILE, "> $filename" ) or error_exit("timestamp replace,$!: $filename");
        print FILE $newfile;
        close(FILE);
    }
}

# Show help message. This is called by getopts.
sub HELP_MESSAGE {
    print <<"EOD";

Usage: perl $0 [Options]

Options:
    --version       Show version.
    --help          Show this message.
    -d              Debug. Use this switch for verbose log.
    -u username     Username. Specify username.
    -p password     Password. Specify password.
    -T seconds      Timeout. Default value is 180.
    -g groupname    Groupname. Specify groupname.
    -f filename     File. Send only this file without checking timestamp.
    -M              Do NOT replace *t* with current time.
    -n config_file  Config file. Default value is $config_file.

Config file example:
#
# $config_file
#
id:yourid
password:yourpassword
# txt_dir:/usr/yourid/diary
# touch:/usr/yourid/diary/hw.touch
# proxy:http://www.example.com:8080/
# client_encoding:Shift_JIS
# filter:iconv -f euc-jp -t utf-8 %s
EOD
}

# Load config file.
sub load_config() {
    print_debug("Loading config file ($config_file).");
    if ( not open( CONF, $config_file ) ) {
        error_exit("Can't open $config_file.");
    }
    while (<CONF>) {
        chomp;
        if (/^\#/) {

            # skip comment.
        }
        elsif (/^$/) {

            # skip blank line.
        }
        elsif (/^id:([^:]+)$/) {
            $username = $1;
            print_debug("load_config: id:$username");
        }
        elsif (/^g:([^:]+)$/) {
            $groupname = $1;
            print_debug("load_config: g:$groupname");
        }
        elsif (/^password:(.*)$/) {
            $password = $1;
            print_debug("load_config: password:********");
        }
        elsif (/^proxy:(.*)$/) {
            $http_proxy = $1;
            print_debug("load_config: proxy:$http_proxy");
        }
        elsif (/^client_encoding:(.*)$/) {
            $client_encoding = $1;
            print_debug("load_config: client_encoding:$client_encoding");
        }
        elsif (/^filter:(.*)$/) {
            $filter_command = $1;
            print_debug("load_config: filter:$filter_command");
        }
        elsif (/^txt_dir:(.*)$/) {
            $txt_dir = glob($1);
            print_debug("load_config: txt_dir:$txt_dir");
        }
        elsif (/^touch:(.*)$/) {
            $touch_file = glob($1);
            print_debug("load_config: touch:$touch_file");
        }
        else {
            error_exit("Unknown command '$_' in $config_file.");
        }
    }
    close(CONF);
}
__END__

Yet Another Small はてダラ

ごく簡単な仕様

元祖はてダラとの違い

「元祖はてダラ」と同じなんですが(というか、実際には縮小版なんですが)、AtomPubではできそうにないことがいくつかあります。

  • Cookieを使って、ログインしたことにしてしまうcオプション
  • いわゆる「ちょっとした更新」のチェックマークであるtオプション
  • ユーザーエイジェントの変更であるaオプション
  • タイムアウトであるTオプション
  • グループでのダイアリーにするgオプション(なぜかグループの方ではAtomPubが使えないらしい)

それから、「元祖はてダラ」にはないものは次の2つ。

  • 下書き等を登録するようにするために、タイトル行にdraft:を入れるようにした。
  • 更新か新規作成かは「<!--blog_id=xxxxx-->」か「<!--epoch=1353413527-->」が2行に存在しているかどうかで判断することにした(暫定)。

変更したものは次の2つ。

  • 画像は本文中にファイル名を書いておき、その画像ファイルがあった場合は、それをはてなフォトライフにアップし、その写真IDを取得して、ファイル名をそのIDに書き換える。したがって、フォトライフの容量無いでれば、何枚でもアップできるようになる(png,jpg,gifが対象)。
  • 削除はタイトル行の最後に:delete:を書き込むようにした。
実際の仕様
  1. 2012-11-20.txtのような「YYYY-MM-DD.txt」形式のファイルを対象にする。
  2. スクリプトを実行した時にtouchファイルを作成し、タイムスタンプを保存しておき、上記の対象のファイルが存在しても、そのファイルのタイムスタンプがtouchファイルのタイムスタンプと比較して古ければ、そのファイルは対象にしない。
  3. 1行目がタイトル。
  4. 2行目が「*」で始まる部分が小見出しになる。
  5. 1行目が「:delete:」で終わっている場合、その日の日記を削除する。
  6. 画像ファイルは「元祖はてダラ」の方式とは違い、文章中に「[^\w+(jpg|png|gif)$]---大文字小文字区別せず」という1行があったら、そのファイルを「はてなフォトライフ」にその画像をアップロードし、そのファイル名を「f:id:hatenadiary:20041007101545j:image\<」という文言にもとの文を書き換えて、その場所にその画像を表示させるようにします。アップロードできる最大の画像数は、設定で変えられるようにする。注意すべきは、これを元のファイル名にすれば再度アップロードが行われるので、画像を更新したいような場合はこの方法で可能ですが、もとのファイルを削除したりする機能はありません(要検討)。
  7. 冒頭が「draft:」で始まっているタイトル行の場合は対象になるファイルを「下書き保存」する。下書き保存は登録した時のエポック(epoch)で管理していて、「d.hatena.ne.jp/chikkun530/draft?epoch=1353413527」という感じなので<!--epoch=1353413527--> という独立した行があった場合は、更新とする(それらを削除したり、そのepochが「はてなダイアリー」になかったりしたら、新規登録する)。
  8. 正式登録・更新の場合も下書きと同様に、最初に登録した際に<!--blog_id=xxxxx-->を保存しておき、それで判断する。
  9. コマンドオプション
    1. u ユーザ名
    2. p パスワード
    3. d デバッグ表示
    4. help ヘルプメッセージ表示
    5. version version表示
    6. f 送信ファイルの指定
    7. M見出しスタンプの置換禁止
    8. n 設定ファイルの指定(デフォルトはconfig.txt)
  10. 設定ファイルで上記設定が優先する
TODO
  1. global変数が多すぎなので、もう少し丁寧にソースを見直す
  2. はてなダイアリーでの日記の表示モードの違いでURLが違ったりしないか要調査
  3. 画像ファイルを管理すべきかどうかを検討
  4. エラー処理ももう少し検討

はてダラ実験

http://www.hyuki.com/techinfo/hatena_diary_writer.html

にある「はてダラ」を実験してみました。この「はてダラ」はずいぶん前から
Perlの本などでお世話になっている結城浩さんの作品なんですね。

さてさて

perl -MCPAN -e 'install Crypt::SSLeay'

で必要なモジュールをインストールしておいて、適当なディレクトリーにこの
サイトにあるPerlのプログラムをhw.plとして保存し、に2012-11-18.txtのよう
なファイルを作成し、ここに書いてあるような文章を書いて

perl hw.pl

でしっかり登録できました。

ただ、僕の場合、一度最終的にブラウザで確認したので(Facebookへ通達するこ
ともあり)、下書き機能がこの「はてダラ」にはないので、このプログラムに次
のような関数を追加してみました(相当いい加減なので参考にしない方が良いと
思う・汗)。

sub draft_it($$$$$$) {
    my ($year, $month, $day, $title, $body, $imgfile) = @_;

    print_debug("draft_it: $year-$month-$day.");

    $user_agent->cookie_jar($cookie_jar);

    my $r = $user_agent->simple_request(
        HTTP::Request::Common::POST("$hatena_url/$username/edit",
            Content_Type => 'form-data',
            Content => [
                mode => "draftenter",
                timestamp => get_timestamp,
                year => $year,
                month => $month,
                day => $day,
                trivial => $cmd_opt{t},
                rkm => $rkm,
		draft => "下書き保存",
                # Important:
                # If (entry does exists) { append empty string (i.e. nop) }
                # If (entry does not exist) { create empty entry }
                title => "testtttttttttt",
                body => "*temporary\nsakai",
                date => "",
#                date => "$year-$month-$day",
            ]
        )
    );

    my $r = $user_agent->simple_request(
        HTTP::Request::Common::POST("$hatena_url/$username/edit",
            Content_Type => 'form-data',
            Content => [
                mode => "draftenter",
                rkm => $rkm,
		dummy => 1,
                timestamp => get_timestamp,
                year => $year,
                month => $month,
                day => $day,
                trivial => $cmd_opt{t},
		section => 0,
		draft => "下書き保存する",
                title => $title,
                body => $title . "\n" . $body,
                date => "$year$month$day",
                image => [
                    $imgfile,
                ]
            ]
        )
    );
}

です。

最近Javascriptだらけで、なかなかうまく行かなかったのと、はてなダイアリー
でUTF-8場合とEUCの場合のページが混在していて---僕がはてなダイアリー
初心者だからあまりルールを知らないせいかもしれませんが---下書き保存のペー
ジはEUCだったので、EUCでプログラムを保存しておかなければダメでした。

オプションにD(Draftの意味)も追加し、

getopts("Dtdu:p:a:T:cg:f:Mn:", \%cmd_opt) or error_exit("Unknown option.");

Dオプションの場合は対象のファイルを下書き保存するようにしました。

そして、update_diary_entry関数を

sub update_diary_entry($$$$$$) {
    my ($year, $month, $day, $title, $body, $imgfile) = @_;

    if(!$cmd_opt{D}){
	if ($cmd_opt{t}) {
	    # clear existing entry. if the entry does not exist, it has no effect.
	    doit_and_retry("update_diary_entry: CLEAR.", sub { return post_it($year, $month, $day, "", "", "") });
	}

	# Make empty entry before posting.
	doit_and_retry("update_diary_entry: CREATE.", sub { return create_it($year, $month, $day) });

	# Post.
	doit_and_retry("update_diary_entry: POST.", sub { return post_it($year, $month, $day, $title, $body, $imgfile) });
    } else {
	doit_and_retry("draft_diary_entry: POST.", sub { return draft_it($year, $month, $day, $title, $body, $imgfile) });
    }
}

とし

perl hw.pl -D

で、ドラフト保存ができました。

ただ、僕は画像ファイルをつけたものを下書き保存したかったり、AtomPubを
利用しないとその辺実現できそうにない感じなので、この結城さんの「はてダ
ラ」の仕様を参考にしつつ、自分で作ってみようかな、と思う今日この頃でし
た。

初めてのはてな


今夜16日 南の風 晴れ
晴れ 波 0.5メートル


Emacsから「はてな」に投稿するには

http://hatena-diary-el.sourceforge.jp/

http://d.hatena.ne.jp/amt/20060115/HatenaHelperMode

を参考に、

(require 'hatena-diary-mode)
(setq hatena-usrid "chikkun530")
(setq hatena-twitter-flag t)

(require 'hatenahelper-mode)
(global-set-key "\C-xH" 'hatenahelper-mode)

(add-hook 'hatena-diary-mode-hook
	  '(lambda ()
	     (hatenahelper-mode 1)))

http://d.hatena.ne.jp/yokochie/20070518/1179486027

のPDFを手元に置いておかないと、おじさんは覚えられません・涙。

天気予報を取得するelisp(とってもざっくりだけど)

上の天気は以下の関数で挿入しました。

(require 'w3m)

(defun kyo()
  (interactive)
  (setq w3m-async-exec nil)
  (w3m-browse-url "http://www.jma.go.jp/jp/yoho/319.html")
  (search-forward-regexp "東京地方" nil t)
  (next-line 2)
  (setq st "")
  (let *1
  (setq st (replace-regexp-in-string "[0-9][0-9]-.+$" "" st))
  (setq st (replace-regexp-in-string "^[  ]+" "" st))
  (insert st)
  )


(defun asu()
  (interactive)
  (setq w3m-async-exec nil)
  (w3m-browse-url "http://www.jma.go.jp/jp/yoho/319.html")
  (search-forward-regexp "明日" nil t)
  (setq st "")
  (let *2
  (setq st (replace-regexp-in-string "[0-9][0-9]-.+$" "" st))
  (setq st (replace-regexp-in-string "^[  ]+" "" st))
  (insert st)
  )

*1:point (line-beginning-position))) (forward-paragraph) (setq st (buffer-substring point (point)))) (kill-buffer (current-buffer

*2:point (line-beginning-position))) (forward-paragraph) (setq st (buffer-substring point (point)))) (kill-buffer (current-buffer