並列処理でWebコンテンツをダウンロードする方法 - Groovy, Scala, C#, Java, Ruby

複数のWebコンテンツ(HTMLや画像など)をダウンロードする際に 1件ずつ処理していたのでは非効率です。
というわけで、並列的にWebコンテンツをダウンロードするプログラムを Groovy, Scala, C#, Java, Ruby で実装してみました。

主な仕様は以下で、外部ライブラリを使用せずに実装しました。

  • 実行時の第1引数で出力先ディレクトリを指定
  • ダウンロード対象の URL を標準入力で指定(改行区切りで複数指定)
  • URL 内のファイル名を出力ファイル名として使用
実行例
groovy download_web.groovy destdir < urls.txt

サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20110925/

Groovy の場合

Groovy 1.8 では GPars が同梱されているので、GPars による並列コレクションを使えば簡単に実装できます。
GParsExecutorsPool.withPool に渡したクロージャ内のコレクションで並列処理用のメソッド(下記の eachParallel)が使えるようになります。

  • Groovy 1.8.2
download_web.groovy
import groovyx.gpars.GParsExecutorsPool

def dir = args[0]

GParsExecutorsPool.withPool {
//並列数を固定化するなら以下のようにする
//GParsExecutorsPool.withPool(5) {

    System.in.readLines() eachParallel {u ->
        def url = new URL(u)

        try {
            def file =  new File(dir, new File(url.file).name)

            url.withInputStream {input ->
                file.bytes = input.bytes
            }

            println "downloaded: $url => $file"
        }
        catch (e) {
            println "failed: $url, $e"
        }
    }
}

Scala の場合

Scala 2.9 では並列コレクションが使えます。
コレクションに対して par メソッドを呼び出すと並列コレクション化され、後は foreach 等を実行すれば並列に処理されます。

ただし、デフォルトでは JVM が使用できるプロセッサ数※までしか並列化されないようなので、今回のような用途では並列数が少ないかもしれません。

※ scala.collection.parallel.availableProcessors で数値を参照可
   実際には java.lang.Runtime.getRuntime().availableProcessors() の値が設定されている

なお、ファイル保存処理を簡単に実装するため、JavaSE 7 で導入された java.nio.file.Files クラス等を使用しています。

  • Scala 2.9.1(JavaSE 7 依存)
download_web_scala
import scala.io.Source

import java.io.File
import java.net.URL
import java.nio.file.{Paths, Files}
import java.nio.file.StandardCopyOption._

val dir = args(0)

val using = (st: InputStream) => (block: InputStream => Unit) => try {block(st)} finally {st.close()}

Source.stdin.getLines.toArray.par.foreach {u =>
    val url = new URL(u)
    val filePath = Paths.get(dir, new File(url.getFile()).getName())

    try {
        using (url.openStream()) {stream =>
            Files.copy(stream, filePath, REPLACE_EXISTING)
        }

        printf("downloaded: %s => %s\n", url, filePath)
    } catch {
        case e: Exception => printf("failed: %s, %s\n", url, e)
    }
}

C# の場合

.NET Framework 4 では並列タスクが使えます。
Parallel.ForEach にコレクションとその処理内容を渡せば並列化されます。

DownloadWeb.cs
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;

public class DownloadWeb
{
    public static void Main(string[] args)
    {
        var urls = Console.In.ReadToEnd().Split(new string[]{Environment.NewLine}, StringSplitOptions.RemoveEmptyEntries);

        var dir = args[0];

        Parallel.ForEach(urls, (u) => {
            var url = new Uri(u);

            try {
                var filePath = Path.Combine(dir, Path.GetFileName(url.LocalPath));
                new WebClient().DownloadFile(url, filePath);

                Console.WriteLine("downloaded: {0} => {1}", url, filePath);
            }
            catch (Exception e) {
                Console.WriteLine("failed: {0}, {1}", url, e);
            }
        });
    }
}

Java の場合

Java の場合、今のところ並列コレクション等の仕組みが用意されていないようなので Concurrency Utilities を使って実装しました。
ファイルの保存処理には JavaSE 7 で導入された java.nio.file.Files クラス等を使用しています。

下記では URL クラスの代わりに URI を使っていますが、特に深い理由は無く Scala のサンプルと同様に URL クラスを使っても問題ありません。


なお、Paths.get() の引数に URI を渡せますが、現バージョンでは "http://・・・" から作成した URI を渡す事はできませんでした。(java.nio.file.FileSystemNotFoundException: Provider "http" not installed となる)

  • JavaSE 7
DownloadWeb.java
import java.io.*;
import java.net.URI;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;

public class DownloadWeb {
    public static void main(String[] args) throws Exception {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        ExecutorService exec = Executors.newCachedThreadPool();
        //並列数を固定化するなら以下のようにする
        //ExecutorService exec = Executors.newFixedThreadPool(5);

        final String dir = args[0];
        String url = null;

        while ((url = reader.readLine()) != null) {
            final URI uri = URI.create(url);
            final Path filePath = Paths.get(dir, new File(uri.getPath()).getName());

            exec.submit(new Runnable() {
                @Override
                public void run() {
                    try (InputStream in = uri.toURL().openStream()) {
                        Files.copy(in, filePath, StandardCopyOption.REPLACE_EXISTING);
                        System.out.printf("downloaded: %s => %s\n", uri, filePath);
                    } catch (Exception e) {
                        System.out.printf("failed: %s, %s\n", uri, e);
                    }
                }
            });
        }
        //ダウンロード終了まで待機
        exec.shutdown();
    }
}

Ruby の場合

Ruby の場合も今のところ並列コレクション等の仕組みが用意されていないみたいなので Queue と Thread を使って実装してみました。

スレッド数を固定化しているので、Java で Executors.newFixedThreadPool(数値) を使ったケースや Groovy で GParsExecutorsPool.withPool(数値) {・・・} を使ったケースと同様の処理になると思います。

download_web.rb
require "thread"
require "uri"
require "net/http"

#並列数(スレッド数)
poolSize = 5

dir = ARGV[0]

q = Queue.new
#キューに URL を設定
$stdin.readlines.each {|l| q.push(l.chomp)}

threads = []
poolSize.times do
    threads << Thread.start(q) do |tq|
        #キューが空になるまでループ
        while not q.empty?
            #キューから URL 取り出し
            u = q.pop(true)

            begin
                url = URI.parse(u)
                filePath = File.join(dir, File.basename(url.path))

                res = Net::HTTP.get_response(url)
                open(filePath, 'wb') {|f| f.puts res.body}

                puts "downloaded: #{url} => #{filePath}"
            rescue => e
                puts "failed: #{url}, #{e}"
            end
        end
    end
end

#ダウンロード終了まで待機
threads.each {|t| t.join}

LINQやコレクションAPIを使ってCSVファイルからデータ抽出 - C#, F#, Scala, Groovy, Ruby の場合

id:fits:20110702 や id:fits:20110709 にて、SQL を使ってデータ抽出した処理を LINQ やコレクション API を使って実施し直してみました。(ただし、今回は station_g_cd でのソートを実施していない等、以前使った SQL と完全に同じではありません)

今回は C#, F#, Scala, Groovy, RubyJRuby)で実装していますが、どの言語も似たような API を用意しており、同じように実装できる事が分かると思います。

以前使った SQL
SELECT *
FROM (
    SELECT
        pref_name,
        station_g_cd,
        station_name,
        count(*) as lines
    FROM
      CSVREAD('m_station.csv') S
      JOIN CSVREAD('m_pref.csv') P ON S.pref_cd = P.pref_cd
    GROUP BY station_g_cd, station_name
    ORDER BY lines DESC, station_g_cd
)
WHERE ROWNUM <= 10

csv ファイルの内容は id:fits:20110702 を参照。
サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20110718/

C# の場合(LINQ)

C# 4.0 で LINQ を使って実装してみました。
実装内容は以下の通り。

  • File.ReadAllLines(・・・) で行単位のコレクションを取得
  • Skip でヘッダー行を無視
  • join で都道府県を結合
  • group by でグループ化
    • 匿名型を使ってグルーピング
    • 戻り値の型は IEnumerable>
  • order by でソート
  • Take を使って 10件取得

LINQ を使うと SQL 風に実装できます。

listup_station.cs
using System;
using System.Linq;
using System.IO;
using System.Text;

class ListUpStation
{
    public static void Main(string[] args)
    {
        //都道府県の取得
        var plines = File.ReadAllLines("m_pref.csv", Encoding.Default);
        var prefs = 
            from pline in plines.Skip(1)
                let p = pline.Split(',')
            select new {
                PrefCode = p[0],
                PrefName = p[1]
            };

        //路線数の多い駅の抽出
        var slines = File.ReadAllLines("m_station.csv", Encoding.Default);
        var list = (
            from sline in slines.Skip(1)
                let s = sline.Split(',')
            join p in prefs on s[10] equals p.PrefCode
            group s by new { 
                StationName = s[9],
                PrefName = p.PrefName, 
                StationGroupCode = s[5]
            } into stGroup
            orderby stGroup.Count() descending
            select stGroup
        ).Take(10);

        //結果出力
        foreach(var s in list)
        {
            Console.WriteLine("{0}駅 ({1}) : {2}", s.Key.StationName, s.Key.PrefName, s.Count());
        }
    }
}
実行結果
> csc listup_station.cs
> listup_station.exe
新宿駅 (東京都) : 12
横浜駅 (神奈川県) : 11
東京駅 (東京都) : 10
渋谷駅 (東京都) : 10
池袋駅 (東京都) : 9
大宮駅 (埼玉県) : 9
新橋駅 (東京都) : 7
大船駅 (神奈川県) : 7
上野駅 (東京都) : 7
千葉駅 (千葉県) : 7

F# の場合

F# 2.0.0 では LINQ を使わずコレクションを使って実装してみました。
実装内容は以下の通り。

  • File.ReadAllLines(・・・) で行単位のコレクションを取得
  • Seq.skip でヘッダー行を無視
  • 都道府県を Map.ofSeq で Map 化
  • Seq.groupBy でグループ化
    • レコード定義を使ってグルーピング
  • List.sortWith でソート
    • sortWith を使うために List.ofSeq を使って List 化
  • Seq.take を使って 10件取得

なお、グルーピングの際にレコード定義を使ってますが、Tuple を使っても特に問題ありません。

listup_station.fsx
open System
open System.IO
open System.Text

//レコード定義
type Station = {
    StationName: string
    PrefName: string
    StationGroupCode: string
}

//都道府県の取得
let prefMap = File.ReadAllLines("m_pref.csv", Encoding.Default)
                |> Seq.skip 1
                |> Seq.map (fun l -> 
                        let items = l.Split(',')
                        (items.[0], items.[1])
                    )
                |> Map.ofSeq

//路線数の多い駅の抽出
let lines = File.ReadAllLines("m_station.csv", Encoding.Default)
let list = lines 
            |> Seq.skip 1 
            |> Seq.map (fun l -> l.Split(',')) 
            |> Seq.groupBy (fun s -> 
                    {
                        StationName = s.[9]
                        PrefName = Map.find s.[10] prefMap
                        StationGroupCode = s.[5]
                    }
                ) 
            |> List.ofSeq 
            |> List.sortWith (fun a b -> Seq.length(snd b) - Seq.length(snd a)) 
            |> Seq.take 10

//結果出力
for s in list do
    let st = fst s
    stdout.WriteLine("{0}駅 ({1}) : {2}", st.StationName, st.PrefName, Seq.length((snd s)))
実行結果
> fsi listup_station.fsx
新宿駅 (東京都) : 12
横浜駅 (神奈川県) : 11
東京駅 (東京都) : 10
渋谷駅 (東京都) : 10
池袋駅 (東京都) : 9
大宮駅 (埼玉県) : 9
新橋駅 (東京都) : 7
大船駅 (神奈川県) : 7
上野駅 (東京都) : 7
千葉駅 (千葉県) : 7

Scala の場合

Scala 2.9.0.1 もコレクションで実装してみました。
実装内容は以下の通り。

  • Source.fromFile(・・・).getLines() で行単位のコレクションを取得
  • drop でヘッダー行を削除
  • 都道府県を toMap で Map 化
  • groupBy でグループ化
    • ケースクラスを使ってグルーピング
  • sortWith でソート
  • take を使って 10件取得

なお、グルーピングでケースクラスを使ってますが、F# と同様に Tuple を使っても問題ありません。

listup_station.scala
import scala.io.Source

case class Station(val stationName: String, val prefName: String, val stationGroupCode: String)

//都道府県の取得
val prefMap = Source.fromFile("m_pref.csv").getLines().drop(1).map {l =>
    val items = l.split(",")
    items(0) -> items(1)
}.toMap

//路線数の多い駅の抽出
val lines = Source.fromFile("m_station.csv").getLines()
val list = lines.drop(1).toList.map(_.split(",")).groupBy {s =>
    Station(s(9), prefMap.get(s(10)).get, s(5))
}.toList.sortWith {(a, b) => 
    a._2.length > b._2.length
} take 10

//結果出力
list.foreach {s =>
    printf("%s駅 (%s) : %d\n", s._1.stationName, s._1.prefName, s._2.length)
}
実行結果
> scala listup_station.scala
新宿駅 (東京都) : 12
横浜駅 (神奈川県) : 11
東京駅 (東京都) : 10
渋谷駅 (東京都) : 10
池袋駅 (東京都) : 9
大宮駅 (埼玉県) : 9
大船駅 (神奈川県) : 7
京都駅 (京都府) : 7
新橋駅 (東京都) : 7
千葉駅 (千葉県) : 7

Groovy の場合

Groovy 1.8.0 もコレクションで実装してみました。
実装内容は以下の通り。

  • new File(・・・).readLines() で行単位のコレクションを取得
  • tail でヘッダー行以外を取得
  • 都道府県を collectEntries で Map 化
  • groupBy でグループ化
    • 配列を使ってグルーピング
  • sort でソート
  • List の getAt(Range) を使って 10件取得(asList()[0..9] の箇所)
    • getAt(Range) を使うために entrySet() で取得した Set を asList() で List 化

なお、a <=> b は a.compareTo(b) と同じです。

listup_station.groovy
//都道府県の取得
def prefMap = new File("m_pref.csv").readLines() tail() collectEntries {
    def items = it.split(",")
    [items[0], items[1]]
}

//路線数の多い駅の抽出
def list = new File("m_station.csv").readLines() tail() collect {
    it.split(",")
} groupBy {
    [it[9], prefMap[it[10]], it[5]]
} sort {a, b -> 
    b.value.size <=> a.value.size
} entrySet() asList()[0..9]

//結果出力
list.each {
    println "${it.key[0]}駅 (${it.key[1]}) : ${it.value.size}"
}
実行結果
> groovy listup_station.groovy
新宿駅 (東京都) : 12
横浜駅 (神奈川県) : 11
東京駅 (東京都) : 10
渋谷駅 (東京都) : 10
池袋駅 (東京都) : 9
大宮駅 (埼玉県) : 9
新橋駅 (東京都) : 7
大船駅 (神奈川県) : 7
上野駅 (東京都) : 7
千葉駅 (千葉県) : 7

Ruby の場合

RubyJRuby 1.6.3)では csv モジュールを使わずに実装してみました。
実装内容は以下の通り。

  • IO.readlines(・・・) で行単位のコレクションを取得
  • drop でヘッダー行を削除
  • chop で改行文字を削除
  • 都道府県を Hash[] で Hash 化
  • group_by でグループ化
    • 配列を使ってグルーピング
  • sort でソート
  • take を使って 10件取得
listup_station.rb
#都道府県の取得
prefMap = Hash[IO.readlines("m_pref.csv").drop(1).map {|l| l.chop.split(',')}]

#路線数の多い駅の抽出
list = IO.readlines("m_station.csv").drop(1).map {|l|
    l.chop.split(',')
}.group_by {|s|
    [s[9], prefMap[s[10]], s[5]]
}.sort {|a, b|
    b[1].length <=> a[1].length
}.take 10

#結果出力
list.each do |s|
    puts "#{s[0][0]}駅 (#{s[0][1]}) : #{s[1].length}"
end
実行結果
> jruby listup_station.rb
新宿駅 (東京都) : 12
横浜駅 (神奈川県) : 11
渋谷駅 (東京都) : 10
東京駅 (東京都) : 10
大宮駅 (埼玉県) : 9
池袋駅 (東京都) : 9
新橋駅 (東京都) : 7
大船駅 (神奈川県) : 7
京都駅 (京都府) : 7
岡山駅 (岡山県) : 7

サムネイル画像の作成 - ImageMagick, GraphicsMagickのコマンドとJava, .NETの標準API

サムネイル画像を以下のような方法で作成してみました。

細かいパラメータ指定などは行わず、サイズ 2048x1536, ファイルサイズ 1.4MB の JPEG ファイル(sample.jpg)のサムネイル画像(サイズ 100x75)を作成します。

ちなみに、今回作成した Java .NET のサンプルは ImageMagick 等に比べると処理時間・画質・ファイルサイズ等の点で劣るので、あまり実用的とは言えないかもしれません。

サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20110508/

ImageMagick, GraphicsMagick によるサムネイル画像作成

ImageMagickGraphicsMagickは以下のようなコマンドを使ってサムネイル画像を作成する事ができます。

  • convert コマンド (ImageMagick)
  • gm convert コマンド (GraphicsMagick)

ImageMagick と GraphicsMagick で同じコマンドライン引数が使用でき、今回は以下のような引数を使用しました。

-thumbnail [横幅] [入力ファイル] [出力ファイル]

横幅だけを指定すれば元の画像の縦横比のままでサムネイル画像が作成されます。(横x縦の指定も可能)

使用したソフトウェアは以下の通りです。

ImageMagick によるサムネイル画像作成例
> convert -thumbnail 100 sample.jpg sample_1.jpg
GraphicsMagick によるサムネイル画像作成例
> gm convert -thumbnail 100 sample.jpg sample_3.jpg

両者とも処理時間は 0.2秒程度でしたが、出力ファイルサイズが異なりました。(ImageMagick 5.71KB, GraphicsMagick 2.57KB)

ただし、これはデフォルトの圧縮レベルに違いがあるためで -quality 75 を指定して ImageMagick を実行し直してみたところ、GraphicsMagick と同じ 2.57KB になりました。

ファイルサイズから圧縮レベルのデフォルト値を予想すると以下のようになります。(JPEGファイルの場合)

  • ImageMagick は "-quality 96" 程度
  • GraphicsMagick は "-quality 75" 程度

また、"-define jpeg:size=[横幅]" を指定して実行してみたところ、ImageMagick では処理時間が少し短くなり(0.1秒程度)、ファイルサイズが少し減少しました。(GraphicsMagick の方は特に変化無し)

ImageMagick で -define を指定したサムネイル画像作成例
> convert -define jpeg:size=100 -thumbnail 100 sample.jpg sample_2.jpg

Java 標準APIによるサムネイル画像作成

上記 ImageMagick 等と同等の処理を Java の ImageIO を使って実装すると下記のようになります。

Thumbnail.java
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.image.AffineTransformOp;
import java.io.File;
import javax.imageio.ImageIO;

/**
 * サムネイル用画像作成
 * java Thumbnail [変換後の横幅] [入力JPEGファイル] [出力JPEGファイル]
 */
public class Thumbnail {
    public static void main(String[] args) throws Exception {
        //入力JPEG読み込み
        BufferedImage input = ImageIO.read(new File(args[1]));

        //変換後の横幅
        int toWidth = Integer.parseInt(args[0]);

        double rate = (double)toWidth / input.getWidth();

        int toHeight = (int)(input.getHeight() * rate);

        //出力用イメージ
        BufferedImage output = new BufferedImage(toWidth, toHeight, input.getType());
        //サイズ変換定義
        AffineTransformOp at = new AffineTransformOp(AffineTransform.getScaleInstance(rate, rate), null);

        //サムネイル画像作成(サイズ変換)
        at.filter(input, output);

        //サムネイル画像出力
        ImageIO.write(output, "jpg", new File(args[2]));
    }
}
実行例
> java Thumbnail 100 sample.jpg sample_5.jpg

処理時間は 2.2秒程度でファイルサイズは 3.58KB、画質はイマイチでした。

.NET 標準APIによるサムネイル画像作成

.NET の API を使った C# 版は以下のようになります。

Thumbnail.cs
using System;
using System.Drawing;

/**
 * サムネイル用画像作成
 * Thumbnail.exe [変換後の横幅] [入力画像ファイル] [出力画像ファイル]
 */
class Thumbnail
{
    static void Main(string[] args)
    {
        //画像読み込み
        var inputImg = new Bitmap(args[1]);

        //変換後の横幅
        int toWidth = int.Parse(args[0]);

        double rate = (double)toWidth / inputImg.Width;

        //変換後の縦幅
        int toHeight = (int)(inputImg.Height * rate);

        //サムネイル画像作成
        var outputImg = inputImg.GetThumbnailImage(toWidth, toHeight, () => true, new IntPtr());

        //サムネイル画像出力
        outputImg.Save(args[2]);
    }
}
実行例
> Thumbnail 100 sample.jpg sample_6.jpg

処理時間は 1.8秒程度でファイルサイズが 22.7KB になり、解像度 96dpi で 32bit カラーになってしまっていました。(元ファイルは 72dpi で 24bit)

まとめ

結果をまとめると以下のようになります。(処理時間は厳密な測定をしておらず目安程度です)

番号 方法 処理時間 ファイルサイズ 画質 出力ファイル名
1 ImageMagick 0.2秒 5.71KB sample_1.jpg
2 ImageMagick -define 指定あり 0.1秒 5.60KB sample_2.jpg
3 ImageMagick -quality 75 0.2秒 2.57KB sample_7.jpg
4 GraphicsMagick 0.2秒 2.57KB sample_3.jpg
5 Java 標準API 2.2秒 3.58KB sample_5.jpg
6 C# 標準API 1.8秒 22.7KB sample_6.jpg

3 と 4 のファイルサイズが等しいことから、ImageMagick と GraphicsMagick で圧縮レベルのデフォルト値が異なっている事が予想されます

PHP, C# での Excel準拠 CSV ファイルのパース処理

前回 id:fits:20101129 の続きで、Excel の仕様に準拠した CSV ファイル(改行・カンマ・ダブルクォーテーションを要素内に含む)をパースするサンプルの PHPC# 版です。

使用する CSV ファイルや出力結果の例は、前回 id:fits:20101129 を参照。

サンプルのソースは http://github.com/fits/try_samples/tree/master/blog/20101204/

PHP の場合

PHP では標準で用意されている fgetcsv を使います。

parse_csv.php
<?php
if (($h = fopen($argv[1], "r")) !== FALSE) {
    while (($r = fgetcsv($h)) !== FALSE) {
        echo "$r[0] : $r[2]\n";
    }
    fclose($h);
}

str_getcsv というのもありますが、これは 1行分の文字列を処理するために使います。

以下の環境で実行してみました。

  • PHP 5.3.3 Win32 VC9
実行例
> php parse_csv.php test.csv

C# の場合

C# というか .NET では、Microsoft.VisualBasic.FileIO.TextFieldParser を使えば、今回のような CSV ファイルを処理できます。
デフォルト設定だとファイルの文字コードUTF-8 で処理されてしまうので、エンコードを指定しています。

parse_csv.cs
using System;
using System.Text;
using Microsoft.VisualBasic.FileIO;

class CSVParse
{
    public static void Main(string[] args)
    {
        using (var reader = new TextFieldParser(args[0], Encoding.Default))
        {
            //区切り文字を設定する必要あり
            reader.SetDelimiters(",");

            while (!reader.EndOfData)
            {
                var r = reader.ReadFields();
                Console.WriteLine("{0} : {1}", r[0], r[2]);
            }
        }
    }
}

以下の環境でビルド実行してみました。

実行例
> csc /r:Microsoft.VisualBasic.dll parse_csv.cs
> parse_csv.exe test.csv

Entity Framework Feature CTP 4 で MySQL 使用 - モデル間に一対多の関連

ADO.NET Entity Framework Feature Community Technology Preview 4 (以下 EF CTP 4)を使って、MySQL に接続するコンソールアプリのサンプルを作成してみました。

EF CTP 4 は ADO.NET Entity Framework(.NET Framework 4 に含まれている)に対する機能拡張で "コード・ファースト開発" を実施するためのものです。

コード・ファースト開発は、コーディングを中心に CoC(Convention over Configuration)に基づいた開発を可能にします。(Grails の GORM のようにプレーンなモデルクラスから、DB テーブル定義の自動作成等が可能)

使用した環境は以下の通り。

サンプルのソースは http://github.com/fits/try_samples/tree/master/blog/20101117/

なお、今回は以下のページを参考にしました。

事前準備

まず、以下をダウンロードしてインストールしておきます。

モデル・コンテキストクラスの作成

モデルクラスとコンテキストクラスを作成します。
DB への操作を実施するためのコンテキストクラスは DbContext のサブクラスである必要がありますが、モデルクラスは POCO(plain old clr object)で書けます。

注目する点は以下。

  • コンテキストクラスに DB への永続化を行うモデルクラスを DbSet 型のプロパティとして定義
  • モデル間の関連は相手先モデル型のプロパティを定義

以下のサンプルでは Publisher と Book が一対多の関連を持つように定義しています。(プライマリキー用のフィールドは必須なので注意)

MySQLSample.cs(モデル・コンテキストクラス定義)
・・・
using System.Data.Entity;
・・・
namespace Fits.Sample
{
    ・・・
    //モデルクラス
    public class Publisher
    {
        public int PublisherId { get; set; }
        public string Name { get; set; }
        public string Address { get; set; }
        //複数 Book への関連を定義
        public ICollection<Book> Books { get; set; }
    }

    //モデルクラス
    public class Book
    {
        public int BookId { get; set; }
        public string Title { get; set; }
        //Publisher への関連を定義
        //(PublisherId というプロパティは不要)
        public Publisher Publisher { get; set; }
    }

    //コンテキストクラス
    public class BookManager : DbContext
    {
        public DbSet<Publisher> Publishers { get; set; }
        public DbSet<Book> Books { get; set; }
    }
}

データ追加・検索処理の作成

コンテキストクラスを使ってデータの追加や検索を実施します。

データの追加はコンテキストクラスのプロパティにモデルオブジェクトを Add し、SaveChanges するだけです。

検索は DbSet が用意しているメソッドを使う事になりますが、今回は LINQ 形式で実装してみました。

なお、対多の関連にあるプロパティの内容も予めロードしておくには、LINQ で Include(プロパティ名) を指定します。(Include が無いと対多のプロパティは null になる)


また、Database.SetInitializer() にて RecreateDatabaseIfModelChanges を設定しておけば、モデルの変更時に自動的にテーブルを再作成してくれるようになります。(今回のサンプルでは最初のテーブル定義作成時にも設定が必要でした。ただし、最初に作るだけなら CreateDatabaseOnlyIfNotExists でいいかも)

MySQLSample.cs(データ操作)
・・・
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
・・・
namespace Fits.Sample
{
    class MySQLSample
    {
        static void Main(string[] args)
        {
            //モデル変更時にテーブルを再作成するための設定
            Database.SetInitializer<BookManager>(new RecreateDatabaseIfModelChanges<BookManager>());
            AddData();
            SelectData();
        }

        //データ追加
        private static void AddData()
        {
            using (var manager = new BookManager())
            {
                var p1 = new Publisher
                {
                    Name = "テスト1",
                    Address = "神奈川県・・・"
                };
                manager.Publishers.Add(p1);
                manager.Publishers.Add(new Publisher
                {
                    Name = "test2",
                    Address = "東京都・・・"
                });

                manager.Books.Add(new Book
                {
                    Title = "Entity Framework CTP4",
                    Publisher = p1
                });
                manager.Books.Add(new Book
                {
                    Title = "MySQL",
                    Publisher = p1
                });
                //DBへの保存
                manager.SaveChanges();
            }
        }

        //データ検索
        private static void SelectData()
        {
            using (var manager = new BookManager())
            {
                //Include で Books の内容をロードするように指定
                //Include の指定が無いと Books プロパティの値が null になる
                var res = from p in manager.Publishers.Include("Books")
                          where p.Books.Count > 0
                          select p;

                res.ToList().ForEach(p =>
                {
                    Console.WriteLine("publisher: {0}", p.Name);
                    p.Books.ToList().ForEach(b => Console.WriteLine("book: {0}", b.Title));
                    Console.WriteLine();
                });
            }
        }
    }
    ・・・
}

DB接続文字列の設定

アプリケーション構成ファイルに MySQL への接続文字列を設定します。(Visual C# を使うのなら、App.config を追加してその中に設定)

name 属性にコンテキストクラスの名称を使用する点に注意。

MySQLSample.exe.config(接続先の MySQLlocalhost
<configuration>
  <connectionStrings>
    <add name="BookManager" connectionString="database=booktest;uid=root;charset=utf8" providerName="MySql.Data.MySqlClient" />
  </connectionStrings>
</configuration>

本来であれば、この接続文字列だけで DB 作成も自動で行ってもらえるような気がするのですが、MySQL に booktest が無いと実行時に ProviderIncompatibleException が発生するため、booktest の DB 定義は事前に作成しておきます。

booktest DB の作成
> mysql -u root
・・・
mysql> create database booktest character set utf8;
・・・

ビルドと実行結果

ビルドは以下のように Microsoft.Data.Entity.CTP.dll を参照に指定して実行。(Visual C# では「参照」に追加すればよい)

csc.exe を使ったビルド
> csc /r:"<EF CTP4 のインストール先ディレクトリ>\Binaries\Microsoft.Data.Entity.CTP.dll" MySQLSample.cs
実行結果
> MySQLSample.exe
publisher: テスト1
book: MySQL
book: Entity Framework CTP4

実行すると booktest 内に 3つのテーブルが自動的に作成され、データが登録されている事が確認できます。

  • booktest データベース
    • books テーブル
    • publishers テーブル
    • edmmetadata テーブル

ちなみに、上記の books・publishers テーブル定義をエクスポートしてみると、以下のようになりました。
books に PublisherId が定義されている点に注目。

books・publishers のテーブル定義
CREATE TABLE `books` (
  `BookId` int(11) NOT NULL AUTO_INCREMENT,
  `PublisherId` int(11) DEFAULT NULL,
  `Title` varchar(4000) CHARACTER SET utf8 DEFAULT NULL,
  PRIMARY KEY (`BookId`),
  KEY `PublisherId` (`PublisherId`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin1 AUTO_INCREMENT=3 ;

CREATE TABLE `publishers` (
  `Address` varchar(4000) CHARACTER SET utf8 DEFAULT NULL,
  `Name` varchar(4000) CHARACTER SET utf8 DEFAULT NULL,
  `PublisherId` int(11) NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`PublisherId`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin1 AUTO_INCREMENT=3 ;

Sinatra風にASP.NET - ASP.NETルーティング機能を使って

ASP.NET MVC を使えば、ASP.NETRails っぽい事ができますが、JSON を返すだけの処理とかに ASP.NET MVC を使うのは大げさすぎる感じがしています。

そのため、ASP.NETSinatra 風のフレームワークが無いか調べてみたのですが、なかなか良さそうなのが見つかりません。
いろいろ調べていく内に、ASP.NET ルーティングの機能で似たような事ができそうな気がしたので少し試してみました。

使用した環境は以下。(ただし、IDE を使わず自前でファイルを作成)

サンプルソースは http://github.com/fits/try_samples/tree/master/blog/20100920/ です。

ASP.NET ルーティング

ASP.NET ルーティングは URL のパターンとその処理方法を指定できる機能で、ASP.NET MVC でも利用されており、Web.config などに特別な設定をする必要も無いので、比較的容易に利用できると思います。

基本的には、Global.asax ファイルの Application_Start イベントで RouteTable クラスの Routes(static プロパティ)に URL のパターンと処理クラスを格納した Route オブジェクトを追加する事で設定を行います。

ASP.NET ルーティングの例(Global.asax.cs)
・・・
using System.Web.Routing;

namespace SampleApp
{
    public class Global : System.Web.HttpApplication
    {
        protected void Application_Start(object sender, EventArgs e)
        {
            //ルーティング定義の追加
            RouteTable.Routes.Add(new Route(
                "{category}/{action}/{id}", new SampleRouteHandler()
            ));
        }
    }
}

URL パターンに一致すると該当する処理クラスが実行されます。

Route のインスタンス化で使用している URL パターン "{category}/{action}/{id}" は Sinatra における "/:category/:action/:id" です。

なお、Route で指定する URL パターンは / から始めない点に注意です。(実行時にエラーが発生します)

Sinatra 風に実装する ASP.NET ルーティング

それでは、Sinatra 風に ASP.NET ルーティングを実装できるようにするため、URL パターンとラムダ式を指定すれば RouteTable.Routes に Route が追加されるような仕組みを作る事にします。

なお、URL のパターン指定は ASP.NET ルーティングのものをそのまま使用するようにし、Sinatra の構文には準拠しません。


とりあえず、GET と POST に対応し ASP.NET ルーティングへの設定を行うクラスの実装は以下のようになりました。Func を使うことで RequestContext を引数にし string を戻り値にしたラムダ式を指定できるようにし、 Get や Post を System.Web.HttpApplication に対する拡張メソッドとして定義しました。

また、ASP.NET ルーティングでは、Route オブジェクトの Constraints で URL パラメータの値を制限したりできるようになっています。(今回は HTTP のメソッド GET や POST を指定するために利用)

CustomRouting.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Routing;

namespace Fits.Sample.Web.Routing
{
    //ASP.NET ルーティングへの設定を行うクラス
    public static class CustomRouting
    {
        //Get メソッドを HttpApplication への拡張メソッドとして定義
        public static void Get(this HttpApplication app, string pattern, Func<RequestContext, string> proc)
        {
            Action("GET", pattern, proc);
        }

        //Post メソッドを HttpApplication への拡張メソッドとして定義
        public static void Post(this HttpApplication app, string pattern, Func<RequestContext, string> proc)
        {
            Action("POST", pattern, proc);
        }

        public static void Action(string methodType, string pattern, Func<RequestContext, string> proc)
        {
            //ルーティングの設定を追加
            RouteTable.Routes.Add(new Route(pattern, new CustomRouteHandler(proc))
            {
                //HTTP Method による制約を指定
                Constraints = new RouteValueDictionary{{"httpMethod", new HttpMethodConstraint(methodType)}}
            });
        }

        private class CustomRouteHandler : IRouteHandler
        {
            private Func<RequestContext, string> proc;

            public CustomRouteHandler(Func<RequestContext, string> proc)
            {
                this.proc = proc;
            }

            public IHttpHandler GetHttpHandler(RequestContext requestContext)
            {
                return new CustomHttpHandler(this.proc, requestContext);
            }
        }

        private class CustomHttpHandler : IHttpHandler
        {
            private Func<RequestContext, string> proc;
            private RequestContext reqCtx;

            public CustomHttpHandler(Func<RequestContext, string> proc, RequestContext reqCtx)
            {
                this.proc = proc;
                this.reqCtx = reqCtx;
            }

            public bool IsReusable
            {
                get { return true; }
            }

            public void ProcessRequest(HttpContext context)
            {
                //proc の処理を実行
                string res = this.proc(this.reqCtx);

                if (res != null)
                {
                    //proc の処理結果を HTTP レスポンスに出力
                    context.Response.Write(res);
                }
            }
        }
    }
}

上記クラスを使って Global.asax 内で ASP.NET ルーティングの定義を行った例が以下です。

Global.asax.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.SessionState;

using Fits.Sample.Web.Routing;

namespace Fits.Sample.Web
{
    public class Global : System.Web.HttpApplication
    {
        protected void Application_Start(object sender, EventArgs e)
        {
            // test/:index への POST に対する定義
            //(例) "test/1" への POST で WebForm1.aspx にリダイレクト
            this.Post("test/{index}", ctx =>
            {
                ctx.HttpContext.Response.Redirect("/WebForm1.aspx");
                return null;
            });

            // /:name/:index への GET に対する定義
            //(例) "test/1" への GET で "hello test - 1" という文字列を表示
            this.Get("{name}/{index}", ctx =>
            {
                var param = ctx.RouteData.Values;
                return string.Format("hello {0} - {1}", param["name"], param["index"]);
            });
        }

    }
}

なお、デフォルトで存在するファイルが優先される点に注意。
上記例で "test/default.html" を GET した場合、test/default.html ファイルが存在していればそのファイルの内容が表示される事になります。(ファイルが無ければルーティングの設定が適用され "hello test - default.html" と表示される)

一応、Sinatra っぽくなったかなと思います。
ASP.NET ルーティングは初めて使いましたが比較的容易に使えるので、色々と活用できそうな気がしています。