俺の備忘録

個人的な備忘録です。

javaのZonedDateTimeクラスのサマータイム切り替わり前後の挙動を検証してみた

はじめに

前回はCalendarクラスでサマータイム切り替わり前後の挙動を検証した。 記事を書いた後に気づいたが、Java8で時間まわりの新しいAPIがサポートされていたようだ。 新しいAPIでは、従来のDateとCalendarクラスを合体させたようなZonedDataTimeクラスというものがあり、今回はこのクラスのサマータイム切り替わり前後の挙動を検証した。

ZonedDataTimeクラスのサマータイム切り替わり前後の挙動検証

標準時からサマータイムへの切り替わり前後

省略される時間帯の時刻にセットしてみる

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

ZonedDateTime zdt0159 = ZonedDateTime.of(2017, 3, 12, 1, 59, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/03/12 01:59 => 出力:" + formatter.format(zdt0159));

ZonedDateTime zdt0200 = ZonedDateTime.of(2017, 3, 12, 2, 00, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/03/12 02:00 => 出力:" + formatter.format(zdt0200));

ZonedDateTime zdt0230 = ZonedDateTime.of(2017, 3, 12, 2, 30, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/03/12 02:30 => 出力:" + formatter.format(zdt0230));

ZonedDateTime zdt0300 = ZonedDateTime.of(2017, 3, 12, 3, 00, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/03/12 03:00 => 出力:" + formatter.format(zdt0300));

実行結果

入力:2017/03/12 01:59 => 出力:2017/03/12 01:59:00 EST
入力:2017/03/12 02:00 => 出力:2017/03/12 03:00:00 EDT
入力:2017/03/12 02:30 => 出力:2017/03/12 03:30:00 EDT
入力:2017/03/12 03:00 => 出力:2017/03/12 03:00:00 EDT

省略される時間に設定した場合は+01:00される結果となった。 これは、従来のCalendarクラスと同じ結果である。

時刻を加算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

String before = "";
String after = "";

ZonedDateTime zdt0130 = ZonedDateTime.of(2017, 3, 12, 1, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0130);
after = formatter.format(zdt0130.plusHours(1));
System.out.println(before + " + 1時間 => " +  after);

ZonedDateTime zdt0230_yesterday = ZonedDateTime.of(2017, 3, 11, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230_yesterday);
after  =  formatter.format(zdt0230_yesterday.plusDays(1));
System.out.println(before + " + 1日 => " +  after);

ZonedDateTime zdt0300_yesterday = ZonedDateTime.of(2017, 3, 11, 3, 00, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0300_yesterday);
after  =  formatter.format(zdt0300_yesterday.plusDays(1));
System.out.println(before + " + 1日 => " +  after);

実行結果

2017/03/12 01:30:00 EST + 1時間 => 2017/03/12 03:30:00 EDT
2017/03/11 02:30:00 EST + 1日 => 2017/03/12 03:30:00 EDT
2017/03/11 03:00:00 EST + 1日 => 2017/03/12 03:00:00 EDT

省略される時間タイに入るように時間を加算してみると、省略される時間は飛ばされ、 03:00台の時刻になるようだ。

また、サマータイム前日の02:30から1日足すケースは従来のCalendarクラスとは挙動が異なっている。 (Calendarクラスの場合は、 2017/03/12 01:30 EST)になった。

時刻を減算してみる。

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

String before = "";
String after = "";

ZonedDateTime zdt0200 = ZonedDateTime.of(2017, 3, 12, 2, 0, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0200);
after = formatter.format(zdt0200.minusHours(1));
System.out.println(before + " - 1時間 => " +  after);

ZonedDateTime zdt0230 = ZonedDateTime.of(2017, 3, 12, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230);
after = formatter.format(zdt0230.minusHours(1));
System.out.println(before + " - 1時間 => " +  after);

ZonedDateTime zdt0300 = ZonedDateTime.of(2017, 3, 12, 3, 0, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0300);
after = formatter.format(zdt0300.minusHours(1));
System.out.println(before + " - 1時間 => " +  after);

ZonedDateTime zdt0200Tomo = ZonedDateTime.of(2017, 3, 13, 2, 0, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0200Tomo);
after = formatter.format(zdt0200Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

ZonedDateTime zdt0230Tomo = ZonedDateTime.of(2017, 3, 13, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230Tomo);
after = formatter.format(zdt0230Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

ZonedDateTime zdt0300Tomo = ZonedDateTime.of(2017, 3, 13, 3, 0, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0300Tomo);
after = formatter.format(zdt0300Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

実行結果

2017/03/12 03:00:00 EDT - 1時間 => 2017/03/12 01:00:00 EST
2017/03/12 03:30:00 EDT - 1時間 => 2017/03/12 01:30:00 EST
2017/03/12 03:00:00 EDT - 1時間 => 2017/03/12 01:00:00 EST
2017/03/13 02:00:00 EDT - 1日 => 2017/03/12 03:00:00 EDT
2017/03/13 02:30:00 EDT - 1日 => 2017/03/12 03:30:00 EDT
2017/03/13 03:00:00 EDT - 1日 => 2017/03/12 03:00:00 EDT

1時間引いた時は、省略される02:00台が飛ばされ、01:00台になる。 サマータイム開始の翌日から1日引いたときは、-23時間されている。 この結果はCalendarクラスのときと同じ結果である。

サマータイムから標準時への切り替わり前後

重複する時間帯前後に時刻をセットしてみる

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

ZonedDateTime zdt0030 = ZonedDateTime.of(2017, 11, 5, 0, 30, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/11/5 00:30 => 出力:" + formatter.format(zdt0030));

ZonedDateTime zdt0100 = ZonedDateTime.of(2017, 11, 5, 1, 00, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/11/5 01:00 => 出力:" + formatter.format(zdt0100));

ZonedDateTime zdt0130 = ZonedDateTime.of(2017, 11, 5, 1, 30, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/11/5 01:30 => 出力:" + formatter.format(zdt0130));

ZonedDateTime zdt0200 = ZonedDateTime.of(2017, 11, 5, 2, 00, 0, 0, ZoneId.of("America/New_York"));
System.out.println("入力:2017/11/5 02:00 => 出力:" + formatter.format(zdt0200));

実行結果

入力:2017/11/5 00:30 => 出力:2017/11/05 00:30:00 EDT
入力:2017/11/5 01:00 => 出力:2017/11/05 01:00:00 EDT
入力:2017/11/5 01:30 => 出力:2017/11/05 01:30:00 EDT
入力:2017/11/5 02:00 => 出力:2017/11/05 02:00:00 EST

重複する01:00台をセットした場合は、夏時間の01:00台になる。 Calendarクラスの場合は、標準時の01:00台になっていたので挙動が異なる。

時刻を加算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

String before = "";
String after = "";

ZonedDateTime zdt0030 = ZonedDateTime.of(2017, 11, 5, 0, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0030);
after = formatter.format(zdt0030.plusHours(1));
System.out.println(before + " + 1時間 => " +  after);

ZonedDateTime zdt0030_2 = ZonedDateTime.of(2017, 11, 5, 0, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0030_2);
after  =  formatter.format(zdt0030_2.plusHours(2));
System.out.println(before + " + 2時間 => " +  after);

ZonedDateTime zdt0130_yesterday = ZonedDateTime.of(2017, 11, 4, 1, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0130_yesterday);
after  =  formatter.format(zdt0130_yesterday.plusDays(1));
System.out.println(before + " + 1日 => " +  after);

ZonedDateTime zdt0230_yesterday = ZonedDateTime.of(2017, 11, 4, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230_yesterday);
after  =  formatter.format(zdt0230_yesterday.plusDays(1));
System.out.println(before + " + 1日 => " +  after);

実行結果

2017/11/05 00:30:00 EDT + 1時間 => 2017/11/05 01:30:00 EDT
2017/11/05 00:30:00 EDT + 2時間 => 2017/11/05 01:30:00 EST
2017/11/04 01:30:00 EDT + 1日 => 2017/11/05 01:30:00 EDT
2017/11/04 02:30:00 EDT + 1日 => 2017/11/05 02:30:00 EST

時を足すと、EDTの01:00台、ESTの01:00台の順に進む。 前日から1日を足すケースは02:00が境界になっているようで、 前日の01:30から1日足すと、24時間加算され、前日の02:30から1日足すと 25時間足されている。 この挙動はCalendarクラスと同じである。

時間を減算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)でテスト
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z");

String before = "";
String after = "";

ZonedDateTime zdt0230 = ZonedDateTime.of(2017, 11, 5, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230);
after = formatter.format(zdt0230.minusHours(1));
System.out.println(before + " - 1時間 => " +  after);

ZonedDateTime zdt0230_2 = ZonedDateTime.of(2017, 11, 5, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230_2);
after = formatter.format(zdt0230_2.minusHours(2));
System.out.println(before + " - 2時間 => " +  after);

ZonedDateTime zdt0030Tomo = ZonedDateTime.of(2017, 11, 6, 0, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0030Tomo);
after  =  formatter.format(zdt0030Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

ZonedDateTime zdt0100Tomo = ZonedDateTime.of(2017, 11, 6, 1, 00, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0100Tomo);
after  =  formatter.format(zdt0100Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);


ZonedDateTime zdt0130Tomo = ZonedDateTime.of(2017, 11, 6, 1, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0130Tomo);
after  =  formatter.format(zdt0130Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

ZonedDateTime zdt0230Tomo = ZonedDateTime.of(2017, 11, 6, 2, 30, 0, 0, ZoneId.of("America/New_York"));
before = formatter.format(zdt0230Tomo);
after  =  formatter.format(zdt0230Tomo.minusDays(1));
System.out.println(before + " - 1日 => " +  after);

実行結果

2017/11/05 02:30:00 EST - 1時間 => 2017/11/05 01:30:00 EST
2017/11/05 02:30:00 EST - 2時間 => 2017/11/05 01:30:00 EDT
2017/11/06 00:30:00 EST - 1日 => 2017/11/05 00:30:00 EDT
2017/11/06 01:00:00 EST - 1日 => 2017/11/05 01:00:00 EST
2017/11/06 01:30:00 EST - 1日 => 2017/11/05 01:30:00 EST
2017/11/06 02:30:00 EST - 1日 => 2017/11/05 02:30:00 EST

時を減算する場合は、ESTの01:00台、EDTの01:00台の順に遷移する。 標準時2日目から1日減算するケースでは、01:00が境界になっていて、 01:00より前だと25時間引かれ、01:00以降だと24時間引かれる結果となっている。 この挙動はCalendarクラスと同じである。

まとめ

まとめるとZonedDateTimeは以下の挙動らしい。

  • 省略される時間帯(02:00台)にセットすると、夏時間の03:00台として扱われる。Calendarクラスと同じ。  

  • 省略される時間帯に入るように時刻を加算すると、省略された時刻がスキップされる。 サマータイム前日の02:30から1日足した場合は、ZonedDateTimeでは、EDTの03:30になるが、Calendarでは、ESTの1:30であったため、挙動が異なる。   

  • 省略される時間帯に入るように時刻を減算した場合の挙動はCalendarクラスと同じ。   

  • 時刻をを重複される時間帯(01:00台)にセットすると、夏時間の01:00台として扱われる。Calendarクラスでは標準時の01:00台だったため、挙動が異なる。
      

  • 時刻を重複される時間帯(01:00台)に入るように加減算すると、時を加減算した場合は、重複する時間帯(01:00)がそれぞれ別の1時間として扱われる。 日を加減算場合は、境界前後で24時間加減算されるか25時間加減算されるかが切り替わる。この挙動はCalendarクラスと同じ。   

Calendarクラスと概ね同じように扱えるが、サマータイム切り替わり前後の時間の挙動が若干異なるため、新APIに乗り換る場合は注意が必要そうだ。

Calendarクラス(java)のサマータイム切り替わり前後の時刻の挙動を検証してみた

はじめに

日本国内だけならあまり問題になることはないが、海外をメインに売っている製品開発に携わっていると、度々タイムゾーンサマータイムの問題に悩まされる。 つい最近もサマータイムの問題に悩まされたため、 javaのCalendarクラスのサマータイム切り替わり前後の時刻の挙動についてメモしておく。

そもそもサマータイムって?

サマータイムとは、夏に時計の時間を1時間進める制度のこと。
なんか明るい時間を有効に使うために生まれた制度らしい。
サマータイムの開始や終了の時期や時刻、進める時間の量は地域等によって異なる。 アメリカのニューヨーク(東部標準時)を例に挙げると、夏時間は毎年3月第2日曜日の02:00から始まり、11月の第1日曜日の午前02:00に終了する。

図にしてみると、以下のように、標準時から夏時間に変わるときは02:00台がなくなるため、これにより時計が1時間早まる。逆に出るときは、01:00台が2回現れ、時計が1時間遅くなる。

f:id:magayengineer:20161218233243p:plain

サマータイムはIT屋さんには悩ましい問題である

普通の日本人なら、時計が1時間早まったり遅くなったりすることに対して「ふーん。」程度にしか思わないだろうが、IT屋さんにとっては非常に悩ましい問題である。
例えば、 - 毎日02:30にあるバッチを定期実行するようにスケジュール登録している場合
=> サマータイム開始日の省略される02:30にはバッチは実行されるの????

  • 毎日01:00にあるバッチを定期実行するようにスケジュール登録している場合 => サマータイムが終了する日は、夏時間の01:00に実行されるの?それとも標準時の01:00に実行されるの?

っと言った感じに、意図した通りにバッチやタスクが実行されるのか、しばしばよくわからなくなってしまうのである。

Calendarクラスのサマータイム切り替わり前後の挙動検証

標準時からサマータイムへの切り替わり前後

省略される時間帯の時刻を時刻にセットしてみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

Calendar Cal0159 = Calendar.getInstance();
Cal0159.set(2017, 2, 12, 1, 59, 0);
System.out.println("入力:2017/03/12 01:59 => 出力:" + sdFormat.format(Cal0159.getTime()));

Calendar Cal0200 = Calendar.getInstance();
Cal0200.set(2017, 2, 12, 2, 00, 0);
System.out.println("入力:2017/03/12 02:00 => 出力:" + sdFormat.format(Cal0200.getTime()));

Calendar Cal0230 = Calendar.getInstance();
Cal0230.set(2017, 2, 12, 2, 30, 0);
System.out.println("入力:2017/03/12 02:30 => 出力:" + sdFormat.format(Cal0230.getTime()));

Calendar Cal0259 = Calendar.getInstance();
Cal0259.set(2017, 2, 12, 2, 59, 0);
System.out.println("入力:2017/03/12 02:59 => 出力:" + sdFormat.format(Cal0259.getTime()));

Calendar Cal0300 = Calendar.getInstance();
Cal0300.set(2017, 2, 12, 3, 00, 0);
System.out.println("入力:2017/03/12 03:00 => 出力:" + sdFormat.format(Cal0300.getTime()));

実行結果

入力:2017/03/12 01:59 => 出力:2017/03/12 01:59 EST
入力:2017/03/12 02:00 => 出力:2017/03/12 03:00 EDT
入力:2017/03/12 02:30 => 出力:2017/03/12 03:30 EDT
入力:2017/03/12 02:59 => 出力:2017/03/12 03:59 EDT
入力:2017/03/12 03:00 => 出力:2017/03/12 03:00 EDT

省略される時間に設定した場合は+01:00されることがわかる。

時間を加算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

String before = "";

Calendar Cal0130 = Calendar.getInstance();
Cal0130.set(2017, 2, 12, 1, 30, 0);
before = sdFormat.format(Cal0130.getTime());
Cal0130.add(Calendar.HOUR_OF_DAY, 1);
System.out.println(before + " + 1時間 => " + sdFormat.format(Cal0130.getTime()));

Calendar Cal0230Yesterday = Calendar.getInstance();
Cal0230Yesterday.set(2017, 2, 11, 2, 30, 0);
before = sdFormat.format(Cal0230Yesterday.getTime());
Cal0230Yesterday.add(Calendar.DAY_OF_MONTH, 1);
System.out.println(before + " + 1日   => " + sdFormat.format(Cal0230Yesterday.getTime()));


Calendar Cal0300Yesterday = Calendar.getInstance();
Cal0300Yesterday.set(2017, 2, 11, 3, 0, 0);
before = sdFormat.format(Cal0300Yesterday.getTime());
Cal0300Yesterday.add(Calendar.DAY_OF_MONTH, 1);
System.out.println(before + " + 1日   => " + sdFormat.format(Cal0300Yesterday.getTime()));

実行結果

2017/03/12 01:30 EST + 1時間 => 2017/03/12 03:30 EDT
2017/03/11 02:30 EST + 1日   => 2017/03/12 01:30 EST
2017/03/11 03:00 EST + 1日   => 2017/03/12 03:00 EDT

1時間Addしたときは、省略される時間を飛ばして03:30台になったが、 サマータイムに入る前日の02:30から1日足した時は、03:30にはならずに +23時間の01:30になった。どういうことだってばよ。

時間を減算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

String before = "";

Calendar Cal0200 = Calendar.getInstance();
Cal0200.set(2017, 2, 12, 2, 0, 0);
before = sdFormat.format(Cal0200.getTime());
Cal0200.add(Calendar.HOUR_OF_DAY, -1);
System.out.println(before + " - 1時間 => :" + sdFormat.format(Cal0200.getTime()));

Calendar Cal0230 = Calendar.getInstance();
Cal0230.set(2017, 2, 12, 2, 30, 0);
before = sdFormat.format(Cal0230.getTime());
Cal0230.add(Calendar.HOUR_OF_DAY, -1);
System.out.println(before + " - 1時間 => :" + sdFormat.format(Cal0230.getTime()));

Calendar Cal0300 = Calendar.getInstance();
Cal0300.set(2017, 2, 12, 3, 00, 0);
before = sdFormat.format(Cal0300.getTime());
Cal0300.add(Calendar.HOUR_OF_DAY, -1);
System.out.println(before + " - 1時間   => :" + sdFormat.format(Cal0300.getTime()));


Calendar Cal0200Tomo = Calendar.getInstance();
Cal0200Tomo.set(2017, 2, 13, 2, 0, 0);
before = sdFormat.format(Cal0200Tomo.getTime());
Cal0200Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日 => " + sdFormat.format(Cal0200Tomo.getTime()));

Calendar Cal0230Tomo  = Calendar.getInstance();
Cal0230Tomo.set(2017, 2, 13, 2, 30, 0);
before = sdFormat.format(Cal0230Tomo.getTime());
Cal0230Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日 => " + sdFormat.format(Cal0230Tomo.getTime()));

Calendar Cal0300Tomo  = Calendar.getInstance();
Cal0300Tomo.set(2017, 2, 13, 3, 00, 0);
before = sdFormat.format(Cal0300Tomo.getTime());
Cal0300Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日   => " + sdFormat.format(Cal0300Tomo.getTime()));

実行結果

2017/03/12 03:00 EDT - 1時間 => :2017/03/12 01:00 EST
2017/03/12 03:30 EDT - 1時間 => :2017/03/12 01:30 EST
2017/03/12 03:00 EDT - 1時間   => :2017/03/12 01:00 EST
2017/03/13 02:00 EDT - 1日 => 2017/03/12 03:00 EDT
2017/03/13 02:30 EDT - 1日 => 2017/03/12 03:30 EDT
2017/03/13 03:00 EDT - 1日   => 2017/03/12 03:00 EDT

やはり1時間引いた時は、省略される02:00台が飛ばされた形の結果になり、 1日引いたときは、-23時間されている。

サマータイムから標準時への切り替わり前後

重複する時間帯前後に時刻をセットしてみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

Calendar Cal0030 = Calendar.getInstance();
Cal0030.set(2017, 10, 5, 0, 30, 0);
System.out.println("入力:2017/11/5 00:30 => 出力:" + sdFormat.format(Cal0030.getTime()));

Calendar Cal0100 = Calendar.getInstance();
Cal0100.set(2017, 10, 5, 1, 00, 0);
System.out.println("入力:2017/11/5 01:00 => 出力:" + sdFormat.format(Cal0100.getTime()));

Calendar Cal0130 = Calendar.getInstance();
Cal0130.set(2017, 10, 5, 1, 30, 0);
System.out.println("入力:2017/11/5 01:30 => 出力:" + sdFormat.format(Cal0130.getTime()));

Calendar Cal0200 = Calendar.getInstance();
Cal0200.set(2017, 10, 5, 2, 00, 0);
System.out.println("入力:2017/11/5 02:00 => 出力:" + sdFormat.format(Cal0200.getTime()));

実行結果

入力:2017/11/5 00:30 => 出力:2017/11/05 00:30 EDT
入力:2017/11/5 01:00 => 出力:2017/11/05 01:00 EST
入力:2017/11/5 01:30 => 出力:2017/11/05 01:30 EST
入力:2017/11/5 02:00 => 出力:2017/11/05 02:00 EST

重複する01:00台をセットした場合は、夏時間の01:00にはならずに 標準時の01:00台になるようだ。

CalendarにAddしてみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

String before = "";

Calendar Cal0030 = Calendar.getInstance();
Cal0030.set(2017, 10, 5, 0, 30, 0);
before = sdFormat.format(Cal0030.getTime());
Cal0030.add(Calendar.HOUR_OF_DAY, 1);
System.out.println(before + " + 1時間 => " + sdFormat.format(Cal0030.getTime()));

Calendar Cal0030_2 = Calendar.getInstance();
Cal0030_2.set(2017, 10, 5, 0, 30, 0);
before = sdFormat.format(Cal0030_2.getTime());
Cal0030_2.add(Calendar.HOUR_OF_DAY, 2);
System.out.println(before + " + 2時間 => " + sdFormat.format(Cal0030_2.getTime()));

Calendar Cal0130Yesterday = Calendar.getInstance();
Cal0130Yesterday.set(2017, 10, 4, 1, 30, 0);
before = sdFormat.format(Cal0130Yesterday.getTime());
Cal0130Yesterday.add(Calendar.DAY_OF_MONTH, 1);
System.out.println(before + " + 1日   => " + sdFormat.format(Cal0130Yesterday.getTime()));

Calendar Cal0230Yesterday = Calendar.getInstance();
Cal0230Yesterday.set(2017, 10, 4, 2, 30, 0);
before = sdFormat.format(Cal0230Yesterday.getTime());
Cal0230Yesterday.add(Calendar.DAY_OF_MONTH, 1);
System.out.println(before + " + 1日   => " + sdFormat.format(Cal0230Yesterday.getTime()));

実行結果

2017/11/05 00:30 EDT + 1時間 => 2017/11/05 01:30 EDT
2017/11/05 00:30 EDT + 2時間 => 2017/11/05 01:30 EST
2017/11/04 01:30 EDT + 1日   => 2017/11/05 01:30 EDT
2017/11/04 02:30 EDT + 1日   => 2017/11/05 02:30 EST

時を足すと、EDTの01:00台、ESTの01:00台の順に進む。 前日から1日を足すケースは02:00が境界になっているようで、 前日の01:30から1日足すと、24時間加算され、前日の02:30から1日足すと 25時間足されている。

時刻を減算してみる

テストコード

// 東部時間(アメリカ/ニューヨーク)にデフォルトタイムゾーンをセット
TimeZone tz = TimeZone.getTimeZone("America/New_York");
TimeZone.setDefault(tz);

SimpleDateFormat sdFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm z");
sdFormat.setTimeZone(tz);

String before = "";

Calendar Cal0230 = Calendar.getInstance();
Cal0230.set(2017, 10, 5, 2, 30, 0);
before = sdFormat.format(Cal0230.getTime());
Cal0230.add(Calendar.HOUR_OF_DAY, -1);
System.out.println(before + " - 1時間 => " + sdFormat.format(Cal0230.getTime()));

Calendar Cal0230_2 = Calendar.getInstance();
Cal0230_2.set(2017, 10, 5, 2, 30, 0);
before = sdFormat.format(Cal0230_2.getTime());
Cal0230_2.add(Calendar.HOUR_OF_DAY, -2);
System.out.println(before + " - 2時間 => " + sdFormat.format(Cal0230_2.getTime()));

Calendar Cal0030Tomo = Calendar.getInstance();
Cal0030Tomo.set(2017, 10, 6, 0, 30, 0);
before = sdFormat.format(Cal0030Tomo.getTime());
Cal0030Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日   => " + sdFormat.format(Cal0030Tomo.getTime()));

Calendar Cal0100Tomo = Calendar.getInstance();
Cal0100Tomo.set(2017, 10, 6, 1, 00, 0);
before = sdFormat.format(Cal0100Tomo.getTime());
Cal0100Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日   => " + sdFormat.format(Cal0100Tomo.getTime()));

Calendar Cal0130Tomo = Calendar.getInstance();
Cal0130Tomo.set(2017, 10, 6, 1, 30, 0);
before = sdFormat.format(Cal0130Tomo.getTime());
Cal0130Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日   => " + sdFormat.format(Cal0130Tomo.getTime()));

Calendar Cal0230Tomo = Calendar.getInstance();
Cal0230Tomo.set(2017, 10, 6, 2, 30, 0);
before = sdFormat.format(Cal0230Tomo.getTime());
Cal0230Tomo.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(before + " - 1日   => " + sdFormat.format(Cal0230Tomo.getTime()));

実行結果

2017/11/05 02:30 EST - 1時間 => 2017/11/05 01:30 EST
2017/11/05 02:30 EST - 2時間 => 2017/11/05 01:30 EDT
2017/11/06 00:30 EST - 1日   => 2017/11/05 00:30 EDT
2017/11/06 01:00 EST - 1日   => 2017/11/05 01:00 EST
2017/11/06 01:30 EST - 1日   => 2017/11/05 01:30 EST
2017/11/06 02:30 EST - 1日   => 2017/11/05 02:30 EST

時を減算する場合は、ESTの01:00台、EDTの01:00台の順に遷移する。 標準時2日目から1日減算するケースでは、01:00が境界になっていて、 01:00より前だと25時間引かれ、01:00以降だと24時間引かれる結果となっている。

まとめ

まとめると以下の挙動らしい。
- カレンダーを省略される時間帯(02:00台)にセットすると、夏時間の03:00台として扱われる。
- カレンダーを省略される時間帯に入るように時間をAddした場合は、時を足し引きした場合は、省略される時間帯(02:00)がスキップされる。日をたし引きした場合は、23時間たし引きされるケースと、24時間足し引きされるケースがある。
- カレンダーを重複される時間帯(01:00台)にセットすると、標準時の01:00台として扱われる。
- カレンダーを重複される時間帯(01:00台)に入るように時間を引くと、時を足し引きした場合は、重複する時間帯(01:00)がそれぞれ別の1時間として扱われる。 日を足し引きした場合は、境界前後で24時間加減算されるか25時間加減算されるかが切り替わる。

海外に売るソフトウェアを作る場合は、サマータイムにはくれぐれも気をつけましょう。

Pythonで簡易HTTP Proxy作成

サマリ

GUI(ブラウザ)とサーバ間でやり取りしているデータをどうにかして見やすくしたいと思い、HTTP Proxyプログラムを自作することにした。 ブラウザのデバッガについてるネットワーク監視機能でいいじゃんという声もあると思うが、 あれって検索しにくいんだよね。 ざーっと通信発生させて、その後、grepでガーッと検索したい。 ということで、Pythonで簡易HTTP Proxyサーバを作成してみた。

ここで載せるコードをベースにログ機能をもう少し強化してから実践投入する予定!

ポイント

作りはだいたい以下。
- ブラウザからリクエストを受け付けたら、転送処理に必要な情報を集めるために、HTTPのヘッダだけとりあえず全て読みだす。
- 転送先ホストとポートは、Hostヘッダから取得可能。
- 基本的にはブラウザと転送先ホストの通信を仲介するだけでOK。具体的にはブラウザ/転送先ホストからデータが送られてきたら、それをそのまま転送先ホスト/ブラウザに送っておけば大丈夫。
- httpsの場合も、ほとんど同じだが、こちらは通信を仲介する前に、ブラウザにHTTP/1.0 200 Connection establishedを返しておけばよいだけ。
- httpsかどうかは、MethodがCONNECTになっているのでそれで判断できる。

できていないこと(まだ怪しいこと)

  • httpsのサイトを覗いていると固まることが多々ある
  • Transfer-Encodingがchunkedの場合など動くか確認していない
  • (おそらく)他にもいっぱい

コード

以下、コード。 なお、コメントを頑張って英語で書いているがおそらく雰囲気しか合っていないので注意。

SimpleProxyServer.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import logging
import traceback
import socket
import select
import ConfigParser
import datetime


class HttpDataContainer:
    # Class for storing http data
    def __init__(self):
        self._date_time = datetime.datetime.now()
        self._request_data = ''
        self._response_data = ''

    def append_request_data(self, data):
        self._request_data += data

    def append_response_data(self, data):
        self._response_data += data

    @property
    def date_time(self):
        return self._date_time

    @property
    def request_data(self):
        return self._request_data

    @property
    def response_data(self):
        return self._response_data

    def get_request_header(self):
        return self._request_data[:self._request_data.find('\r\n\r\n') + 4]

    def get_response_header(self):
        return self._response_data[:self._response_data.find('\r\n\r\n') + 4]


class HttpHeaderHelper:
    # Class to analyze Http headers
    def __init__(self, header_raw_data):
        # constructor
        self._header_raw_data = header_raw_data
        self._header_dict = self.create_header_dict()

    def get_first_row(self):
        return self._header_raw_data[:self._header_raw_data .find('\r\n')]

    def get_http_method(self):
        # get http method
        return self._header_raw_data[:self._header_raw_data.find(' ')]

    def get_url(self):
        first_space = self._header_raw_data.find(' ')
        second_space = self._header_raw_data.find(' ', first_space + 1)
        return self._header_raw_data[first_space:second_space]

    def is_https(self):
        return self.get_http_method() == 'CONNECT'

    def get_forward_to(self):
        # get forward host and port
        host_header_value = self._header_dict.get('Host')
        if ':' in host_header_value:
            # Host and port number are specified
            return host_header_value.split(':')[0], int(host_header_value.split(':')[1])

        if self.is_https():
            # default https port number
            return host_header_value, 443

        # default http port number
        return host_header_value, 80

    def create_header_dict(self):
        # create http header dictionary
        header_dict = dict()

        for i, row in enumerate(self._header_raw_data.splitlines()):
            if i == 0:
                continue
            if ': ' in row:
                key = row[:row.find(': ')]
                value = row[row.find(': ') + 2:]
                header_dict[key] = value
        return header_dict

    def get_content_length(self):
        # get content-length
        if 'Content-Length' in self._header_dict:
            return int(self._header_dict['Content-Length'])
        return 0

    def is_chunked(self):
        # return chunked or not
        return 'Transfer-Encoding' in self._header_dict\
               and self._header_dict['Transfer-Encoding'] == 'chunked'

    @property
    def header_dict(self):
        return self._header_raw_data


class ProxyServer:
    # Proxy Server Class
    def __init__(self, config):
        # constructor
        self._host = ''
        self._port = config.get_default_port()
        self._server_sock = None
        self._config = config
        self._buff_size = config.get_default_buff_size()
        self._writer = open(config.get_output_path(), config.get_output_mode())

    @staticmethod
    def _create_forward_socket(host, port):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((host, port))
        return sock

    def _mediate(self, client_sock, forward_sock, http_data_container, is_https):
        # send data already received
        if http_data_container.request_data and not is_https:
            forward_sock.sendall(http_data_container.request_data)

        # mediate communication for given sockets
        socks = [client_sock, forward_sock]
        close = False
        while True:
            r_socks, w_socks, x_socks = select.select(socks,
                                                      [],
                                                      socks,
                                                      self._config.get_default_timeout())
            try:
                if not r_socks:
                    break
                if x_socks:
                    break

                for r_sock in r_socks:
                    to_sock = client_sock
                    recv_from_client = False
                    if r_sock is client_sock:
                        to_sock = forward_sock
                        recv_from_client = True

                    data = r_sock.recv(self._buff_size)

                    if recv_from_client:
                        http_data_container.append_request_data(data)
                    else:
                        http_data_container.append_response_data(data)

                    if not data:
                        close = True
                        break
                    to_sock.sendall(data)
                if close:
                    break
            except Exception:
                logging.error(traceback.format_exc())

    def _handle_request(self, client_sock, client_address):
        forward_socket = None
        try:
            container = HttpDataContainer()

            # receive request data until the end of header
            req_data = client_sock.recv(self._buff_size)
            while '\r\n\r\n' not in req_data:
                req_data += client_sock.recv(self._buff_size)
            container.append_request_data(req_data)

            header_helper = HttpHeaderHelper(container.get_request_header())

            logging.info("{0} from {1}".format(header_helper.get_first_row(),
                                               client_address[0]))

            # get forward host info
            forward_host, forward_port = header_helper.get_forward_to()

            forward_socket = ProxyServer._create_forward_socket(forward_host, forward_port)

            # http or https ?
            is_https = header_helper.is_https()
            if is_https:
                # send  200 status code to web browser
                established = 'HTTP/1.0 200 Connection established\r\n\r\n'
                client_sock.sendall(established)
                container.append_response_data(established)
                self._puts_result(container)

            self._mediate(client_sock, forward_socket, container, is_https)

            if not is_https:
                self._puts_result(container)

        finally:
            # close sockets
            if forward_socket:
                try:
                    forward_socket.close()
                except IOError:
                    pass
            if client_sock:
                try:
                    client_sock.close()
                except IOError:
                    pass

    def _init_server_sock(self):
        self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._server_sock.bind((self._host, self._port))
        self._server_sock.listen(256)
        logging.info('Proxy server started(port=%d).', self._port)

    def _puts_result(self, http_data_container):
        # puts http request, response to the file
        if self._config.get_output_enable():
            writer = self._writer
            writer.write(http_data_container.date_time.strftime("%Y/%m/%d %H:%M:%S"))
            writer.write('\n')
            writer.write(http_data_container.request_data)
            writer.write(http_data_container.response_data)
            writer.flush()

    def start(self):
        # Start proxy server
        self._init_server_sock()

        while True:
            # wait request form web browser
            client_sock, client_address = self._server_sock.accept()

            # handle request
            self._handle_request(client_sock, client_address)

    def stop(self):
        # stop proxy server

        # close socket
        if self._server_sock:
            try:
                self._server_sock.close()
            except IOError:
                pass

        # close writer
        if self._writer:
            try:
                self._writer.close()
            except IOError:
                pass
        logging.info('Proxy server stopped.')


class ProxyServerConfig:
    # Class to manage Proxy Server Config

    _CONFIG_PATH = './config.ini'

    def __init__(self):
        # constructor
        # load config file
        self._ini_file = ConfigParser.SafeConfigParser()
        self._ini_file.read(ProxyServerConfig._CONFIG_PATH)

    def get_default_port(self):
        return self._ini_file.getint('default', 'port')

    def get_default_buff_size(self):
        return self._ini_file.getint('default', 'buff.size')

    def get_default_timeout(self):
        return self._ini_file.getint('default', 'timeout')

    def get_output_mode(self):
        return self._ini_file.get('output', 'mode')

    def get_output_path(self):
        return self._ini_file.get('output', 'path')

    def get_output_enable(self):
        return self._ini_file.getboolean('output', 'enable')


if __name__ == "__main__":
    server = None
    try:
        # init logger
        logging.basicConfig(format='%(asctime)s    %(process)d    %(thread)x    %(levelname)s    %(message)s')
        logging.getLogger().setLevel(logging.INFO)

        # init config
        config = ProxyServerConfig()

        # Start proxy server
        server = ProxyServer(config)
        server.start()
    except KeyboardInterrupt as e:
        logging.info('Ctrl-c was pressed.')
    except Exception:
        logging.error(traceback.format_exc())
    finally:
        if server:
            server.stop()

config.ini(設定ファイル)

[default]
port=8080
timeout=30
buff.size=4096

[output]
enable=True
mode=w
path=./output.log

参考にしたサイト

https://github.com/inaz2/proxy2 http://memo.saitodev.com/home/python_network_programing/

github

githubに上げた。

https://github.com/magayengineer/PyProxy

修正歴

  • 2016/11/28 コードを微修正
  • 2016/11/29 コードを微修正
  • 2016/11/29 コードを修正。設定ファイル読み込み、結果出力実装。
  • 2016/12/2 githubへのリンクを追加

requestsモジュールでREST APIを叩く(私的メモ)

ごめんなさい。完全に私的なメモです。 会社で扱ってる製品でREST APIをサポートするものが増えてきたので 簡単にREST APIを叩けるようにpythonの関数を準備した。 REST APIなのでクライアント/サーバ間でやりとりするデータはjson形式前提。 jupyter等で対話式に呼び出して使うとおそらくいい感じの使用間になるはず。

#!/usr/bin/python
# -*- coding: utf-8 -*-

import json
import requests
import traceback
import logging

'''
メモ

import Rest

# setting
Rest.HOST = "your host"
Rest.PORT = "your port number"

r = Rest.post("/hoge/piyo", {"piyo":"hoge"})
r = Rest.get("/hoge/piyo", {"piyo":"hoge"})
r = Rest.delete("/hoge/piyo", {"piyo":"hoge"})
r = Rest.put("/hoge/piyo", {"piyo":"hoge"})

# Return object(requests.Response) attributes
r.status_code
r.headers
r.json
r.text
r.content
r.url

http://docs.python-requests.org/en/master/api/#requests.Response
'''

HOST = "localhost"
PORT = "9999"
DEFAULT_HEADERS = {'Content-Type': 'application/json'}


def get(path, headers=DEFAULT_HEADERS, params=None):
    try:
        return requests.get("http://{0}:{1}{2}".format(HOST, PORT, path),
                         params=params,
                         headers=headers)

    except Exception as e:
        handle_exception(e)


def post(path, body, headers=DEFAULT_HEADERS):
    return common_send_data(requests.post, path, body, headers)


def put(path, body, headers=DEFAULT_HEADERS):
    return common_send_data(requests.put, path, body, headers)


def delete(path, body=None, headers=DEFAULT_HEADERS):
    return common_send_data(requests.delete, path, body, headers)


def common_send_data(http_func, path, body, headers):
    try:
        return http_func("http://{0}:{1}{2}".format(HOST, PORT, path),
                         data=json.dumps(body),
                         headers=headers)
    except Exception as e:
        handle_exception(e)


def handle_exception(e):
    logging.error(e)
    logging.error(traceback.format_exc())


def print_dic(dic):
    for k, v in dic.iteritems():
        print k + ": " + v

PS3のコントローラのイベントをHTTPでRaspberry Piに送信

サマリー

過去記事(例えばこれ)でラジコンをPS3のコントローラでリモート操作する際に、 ノートPCとRaspberry Pi間の通信はTCP/IPをゴリ押しで使っていてスマートなやり方とは言えなかった。

そこで、今回はノートPCとRaspberry Pi間の通信をHTTP化し、整理してみた。 今後のラジコンづくりで利用予定。

クライアント側

PS3のコントローラを扱うためのライブラリとしてpygameを使用。 HTTP通信にはrequestsを使用。 発生したPS3のコントローラのイベントはJSON形式でPOST。

Ps3ControllerClient.py

#!/usr/bin/python
# -*- coding: utf-8 -*-

import pygame
from pygame.locals import *
import time
import json
import requests
from requests.exceptions import ConnectionError
import traceback
import logging


class Ps3Controller:
    """
    PS3のコントローラクラス
    """
    # pygameのイベント種別の定数とボタンイベント種別のマップ
    EVENT_TYPE_MAP = {JOYBUTTONDOWN: "DOWN",
                      JOYBUTTONUP: "UP"}

    # pygameのボタン種別の定数とわかりやすくするためにこちらで命名したボタン種別のマップ
    BUTTON_ID_MAP = {0: "SELECT",
                     1: "ANALOG_LEFT",
                     2: "ANALOG_RIGHT",
                     3: "START",
                     4: "UP",
                     5: "RIGHT",
                     6: "DOWN",
                     7: "LEFT",
                     8: "L2",
                     9: "R2",
                     10: "L1",
                     11: "R1",
                     12: "TRIANGLE",
                     13: "CIRCLE",
                     14: "CROSS",
                     15: "SQUARE"}

    # 扱うイベントのフィルタ(Event種別✕ButtonID) 傾き等のイベントは扱わない
    EVENT_FILTER = []
    for type_key in EVENT_TYPE_MAP.keys():
        for button_key in BUTTON_ID_MAP.keys():
            EVENT_FILTER.append((type_key, button_key))

    def __init__(self, handler):
        """
        コンストラクタ
        :param handler:PS3のコントローライベント発生時に実行するcallback関数
        :return:Ps3Controllerオブジェクト
        """
        pygame.joystick.init()
        joys = pygame.joystick.Joystick(0)
        joys.init()
        self._handler = handler

    def start(self):
        """
        PS3のコントローラからの入力を受付開始
        :return:void
        """
        pygame.init()

        while True:
            # イベントをハンドル
            self.handle_ps3_controller_event(pygame.event.get())
            time.sleep(0.1)

    def handle_ps3_controller_event(self, all_events):
        """
        :param all_events:PS3のコントローラで発生した全イベント
        :return:void
        """

        # 不要なイベントをフィルタ(傾き等のイベントは無視する)
        filtered_event = filter(lambda e:e.dict.has_key("button") and (e.type, e.dict["button"]) in Ps3Controller.EVENT_FILTER, all_events)

        if len(filtered_event) > 0:
            event_list = []
            for e in filtered_event:
                event = {'button_id': Ps3Controller.BUTTON_ID_MAP.get(e.dict["button"]),
                         'event_type': Ps3Controller.EVENT_TYPE_MAP.get(e.type)}
                event_list.append(event)
            # callback
            self._handler(event_list)

# Raspberry PiのIPアドレスまたはホスト名
RASPBERRY_HOST = "IP or Host Name"

# Raspberry側の待ち受けポート
RASPBERRY_PORT = "9999"

def send_ps3_controller_events(event_list):
    """
    PS3のコントローラのイベントをHTTPでRaspberry Piに送信する
    :param event_list:発生したPS3のコントロオーライベント一覧
    :return:void
    """
    try:
        # 以下のようなJSONをHTTPで送信する
        # {'events':[{'button_id': 'UP', 'event_type': 'DOWN'}, {'button_id': 'R2', 'event_type': 'UP'}]}
        requests.post("http://{0}:{1}/ps3ctrlevents".format(RASPBERRY_HOST, RASPBERRY_PORT),
                      data=json.dumps({"events": json.dumps(event_list)}),
                      headers={'Content-Type': 'application/json'})
    except ConnectionError as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())

if __name__ == '__main__':
    try:
        # PS3コントローラ初期化
        c = Ps3Controller(send_ps3_controller_events)
        c.start()
    except KeyboardInterrupt:
        logging.info("terminated.")

サーバ側

サーバ側は、Pythonの軽量WebフレームワークであるFlaskを利用してさくっと実装。

RaspberryPiServer.py

#!/usr/bin/python
# -*- coding: utf-8 -*-

import traceback
import logging
import json
from flask import Flask, request, make_response

# Web Application
app = Flask(__name__)


@app.route('/ps3ctrlevents', methods=['POST'])
def ps3ctrlevent():
    """
    PS3のコントローラのイベント受付API
    :return:HTTP Response
    """
    if request.method == 'POST':
        try:
            ps3ctrlevents_handler(json.loads(request.data))
            response = make_response()
            response.headers["Content-Type"] = "application/json"
            response.data = json.dumps({"result": "accept"})
            return response
        except Exception as e:
            logging.error(e)
            logging.error(traceback.format_exc())


def ps3ctrlevents_handler(data):
    """
    PS3のコントローラのイベントを処理する
    :param data:PS3のコントローラのイベント情報(json)
    :return:void
    """
    events = data['events']

    # ここにラズパイにやらせたい処理を記述する################
    for event in json.loads(events):
        print event['button_id'], event['event_type']
    ##################################################

if __name__ == "__main__":
    try:
        app.run(host='0.0.0.0', port=9999, debug=True)
    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())

以上です。

Flaskとwhooshで簡単全文検索Webアプリケーション その2

サマリ

前回(Flaskとwhooshで簡単全文検索Webアプリケーション)は、Flaskとwhooshで全文検索Webアプリケーションを作成した。 ただし、前回作成したものは実運用するにはまだまだ改善すべき点があった。
(特に前回その辺には触れなかったが...)
今回は前回作ったものを、もう少し実運用に耐えられるようにいくらか改善みた。
(エラー処理は相変わらず適当ではあるが...)

改善すべき点/前回の問題点

その1

全文検索用のインデックスに登録するテキストデータを無駄にディスクに保存していた。 何を言っているかというとwhooshのインデックスに登録したいファイルを、元のディレクトリから全部コピーしていたため、ディスクの容量を2倍食う状態であった。 MSオフィスやPDFファイルはインデックス登録のために、一度テキスト変換する必要はあるが、 インデックス登録時にだけあればよいのでディスク上に保持しておく必要はない。

その2

全文検索の対象のファイルが更新されるケースについて対応してなかった。 そのため、前回までの状態だとファイルが更新された場合、一度インデックスを全て削除して作り直しが必要であった。 また、インデックスの更新を定期的に自動実行する作りこみをしていなかったため、 インデックスを更新したい場合、手動でコマンド叩いたり更新作業をする必要があった。

改善内容

ほとんど、上で書いた通りだが、

  • インデックス登録用のファイルを2重で持たないようにした。具体的には、インデックス登録用の中間ファイル(テキストファイル)は作成せずに、pythonプログラムの中からインデックス登録の際にオリジナルのファイルを読み込むようにした。MSオフィスのファイルとPDFのファイルは、テキストを抽出するjavaのプログラムをpythonからキックし、結果を標準出力で受け取るようにした。

  • インデックスの自動更新対応 1時間間隔でインデックスを自動更新できるようにした。ファイルのタイムスタンプを見て前回インデックス登録時からタイムスタンプが変更されていたら更新するようにした。 なお、ファイルが消されるケースは業務では少なさそうなため、まだ非対応。

コード

前回から差分があるのは一部でjavaコードと、インデックスPythonコード(create_index.py)だけである。 Webアプリケーション部分やディレクトリ構成等は変わらないため、前回(Flaskとwhooshで簡単全文検索Webアプリケーション)、を参照のこと。

以下は改良後のjavaコード。MSオフィスとPDFファイルからテキストを抽出し、標準出力に出力する。jarファイル化してPythonコードから呼び出す。

Extractor.java

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.POITextExtractor;
import org.apache.poi.extractor.ExtractorFactory;

/**
 * 全文検索用にファイルをテキストファイルに変換するクラス。
 * PDFとMSオフィスのファイルからテキストを抽出し、標準出力に出力する
 */
public class Extractor {

    /**
    * MSオフィスの拡張子
    */
    private static final List<String> MS_OFFICE_SUFFIX_LIST = new ArrayList<String>();
    static {
        MS_OFFICE_SUFFIX_LIST.add("xls");
        MS_OFFICE_SUFFIX_LIST.add("doc");
        MS_OFFICE_SUFFIX_LIST.add("xlsx");
        MS_OFFICE_SUFFIX_LIST.add("docx");
        MS_OFFICE_SUFFIX_LIST.add("ppt");
        MS_OFFICE_SUFFIX_LIST.add("pptx");
        MS_OFFICE_SUFFIX_LIST.add("msg");
    }

    public static void main(String[] args) {
        String filePath = args[0];

        // テキスト抽出
        Extractor.extractText(new File(filePath));
    }

    /*
    * ファイルからテキストファイルを抽出して標準出力に出力する
    * @param file テキスト抽出元ファイル
    */
    private static void extractText(File file) {
        // 拡張子取得
        String suffix = Extractor.getSuffix(file.getName());
        if (suffix != null) {
            if (MS_OFFICE_SUFFIX_LIST.contains(suffix)) {
                // MSオフィスの場合
                Extractor.getOfficeText(file);
            } else if("pdf".equals(suffix)) {
                // PDFの場合
                Extractor.getPdfText(file);
            }
        }
    }

    /**
    * MSオフィスのファイルからテキストを抽出し、標準出力に出力する
    * @param file MSオフィスのファイル
    */
    private static void getOfficeText(File file){
        POITextExtractor textExtractor = null;
        try {
            textExtractor = ExtractorFactory.createExtractor(file);
            System.out.println(textExtractor.getText());
        } catch (Exception e) {
            System.err.println("failed to extract from " + file.getAbsolutePath());
        } finally {
            if (textExtractor != null) {
                try {
                    textExtractor.close();
                } catch (IOException e) {
                }
            }
        }
    }

    /**
    * PDFからテキストを抽出し、標準出力に出力する
    * @param file PDFファイル
    */
    private static void getPdfText(File file) {
        PDDocument document = null;
        try {
            document = PDDocument.load(file);
            System.out.println(new PDFTextStripper().getText(document));
        } catch (IOException e) {
            System.err.println("failed to extract from " + file.getAbsolutePath());
        } finally {
            if (document != null) {
                try {
                    document.close();
                } catch (IOException e) {
                }
            }
        }
    }

    /**
    * ファイルの拡張子を取得する
    * @param fileName ファイル名(拡張子込み)
    * @return 拡張子
    */
    private static String getSuffix(String fileName) {
        int point = fileName.lastIndexOf(".");
        if (point != -1) {
            return fileName.substring(point + 1);
        }
        return null;
    }
}

以下が改良したインデックス登録pythonコード。1時間おきにインデックスの更新をするようになった。また、javaのコマンドを叩いてMSオフィスとPDFファイルからテキストを抽出するようになった。

create_index.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging
import datetime
import time
from subprocess import Popen, PIPE

# インデックス保存先ディレクトリ
INDEX_DIR = "index_dir"

# 検索対象のファイル配置先ディレクトリ
TARGET_DIR = "static"

# インデックスオブジェクト
INDEX = None

# インデックスの更新間隔
UPDATE_INTERVAL = 3600


def exec_cmd(cmd):
    """
    外部コマンド実行を実行して結果を標準出力で得る
    :param cmd:コマンド
    :return: コマンドの実行結果
    """
    p = Popen(cmd, stdout=PIPE, stderr=PIPE)
    out, err = p.communicate()
    if err and err is not "":
        logging.warn(err)
    return [s for s in out.split('\n') if s]


def get_txt(file_path):
    """
    ファイルを読み込んでテキストに変換して返す
    :param file_path: ファイルパス
    :return: テキストファイル(リスト形式で一行を一要素にして返す)
    """

    # 拡張子取得
    file_name = file_path
    if "/" in file_path:
        file_name = file_path[file_path.rindex("/")+1:]

    suffix = ""
    if "." in file_name:
        suffix = file_name[file_name.rindex("."):]

    # 対象ファイル種別でなかった場合は処理しない
    if suffix not in [".txt",
                      ".html", ".xml", ".mxml", ".htm",
                      ".sh", ".bat", ".ps1",
                      ".py", ".rb", ".c", ".as", ".js", ".java", ".h", ".cpp",
                      ".xls", ".xlsx", ".doc", ".docx", ".ppt", ".pptx",
                      ".pdf"]:
        return None

    # MSオフィス/PDFの場合はテキストを抽出
    if suffix in [".xls", ".xlsx", ".doc", ".docx", ".ppt", ".pptx", ".pdf"]:
        return exec_cmd(["java", "-jar", "Extractor.jar", file_path])

    # 普通のテキストファイル等
    with open(file_path) as f:
        return f.readlines()


def get_last_modified(file_path):
    """
    ファイルの最終更新時刻を返す
    :param file_path: ファイルパス    :return: ファイルの最終更新時刻を示す文字列
    :return: ファイルの最終更新時刻
    """
    last_modified = os.stat(file_path).st_mtime
    return datetime.datetime.fromtimestamp(last_modified)


def create_index(index_dir):
    """
    インデックスを作成する
    :param index_dir インデックス保存先ディレクトリ
    """

    # インデックスがまだ作成されていない場合のみインデックスを作成
    if len(os.listdir(index_dir)) == 0:

        # スキーマ定義
        schema = whoosh.fields.Schema(
                                      # ファイルパスを格納するフィールド
                                      path=whoosh.fields.ID(stored=True),
                                      # 最終更新時刻
                                      last_modified=whoosh.fields.DATETIME(stored=True),
                                      # テキストファイルの行を格納するフィールド
                                      content=whoosh.fields.NGRAM(stored=True))

        whoosh.index.create_in(INDEX_DIR, schema)


def get_registered_last_modified(file_path, index):
    """
    インデックスに登録されているファイルの最終更新時刻を取得
    :param file_path: ファイルパス
    :param index: インデックスオブジェクト
    :return:インデックスに登録されているファイルの最終更新時刻
    """
    # クエリ作成
    parser = whoosh.qparser.QueryParser('path', index.schema)
    query = parser.parse(unicode(file_path, 'utf-8'))
    searcher = None
    try:
        # 検索実行
        searcher = index.searcher()
        results = searcher.search(query, limit=1)
        for result in results:
            return result['last_modified']
        return None
    finally:
        if searcher is not None:
            searcher.close()


def delete_document(file_path, index):
    """
    インデックスからfile_pathに指定したドキュメントを削除する
    :param file_path: ファイルパス
    :param index: インデックスオブジェクト
    :return:void
    """

    # 前回インデックス作成時から更新されている場合は、一度インデックスから削除する
    writer = None
    count = 0
    try:
        writer = index.writer()
        writer.delete_by_term('path', file_path)
        count += 1
    finally:
        if writer is not None and count > 0:
            writer.commit(optimize=False)


def add_document(file_path, last_modified, index):
    """
    インデックス登録
    :param file_path:ファイルパス
    :param last_modified:ファイル更新時刻
    :param index:インデックスオブジェクト
    """

    # ファイルの中身をリード
    contents = get_txt(file_path)

    writer = None
    count = 0
    try:
        writer = index.writer()
        if contents:
            for content in contents:
                # 一行ずつ解析してインデックスに登録する
                writer.add_document(path=unicode(file_path, 'utf-8'),
                                    last_modified=last_modified,
                                    content=unicode(content, 'utf-8'))
                count += 1
        if count == 0:
            logging.warn("No data was registered. (path=%s)", file_path)
    except UnicodeDecodeError:
        # インデックス登録失敗。バイナリ等が混ざっている。
        logging.warn("Decode error. Creating Index failed. (path=%s)", file_path)
    finally:
        if writer is not None and count > 0:
            writer.commit(optimize=False)


def update_index(index_dir, target_dir):
    """
    テキストファイルを解析し、インデックスに各テキストファイルの情報を反映する
    :param index_dir インデックス保存先ディレクトリ
    :param target_dir 検索対象ファイル保存先ディレクトリ
    :return:void
    """
    logging.info("Updating index start.")
    index = None
    try:
        # インデックスオープン
        index = whoosh.index.open_dir(index_dir)

        # 再帰的にディレクトリを探索し、テキストファイルを解析する
        for root, dirs, files in os.walk(target_dir):
            for file_name in files:
                # テキストファイルパス
                file_path = os.path.join(root, file_name)

                # シンボリックリンクの場合飛ばす
                if os.path.islink(file_path):
                    continue

                logging.info("Creating index of %s", file_path)

                # ファイルの最終更新時刻を取得
                last_modified = get_last_modified(file_path)

                # インデックスに登録されているファイルの最終更新時刻を取得
                registered_last_modified = get_registered_last_modified(file_path, index)

                # 最終更新時刻が更新されていた場合インデックスから削除
                if registered_last_modified is not None and last_modified > registered_last_modified:
                    delete_document(file_path,  index)

                # インデックスに登録
                if registered_last_modified is None or last_modified > registered_last_modified:
                    add_document(file_path, last_modified, index)
    finally:
        if index is not None:
            index.close()
        logging.info("Updating index end.")


if __name__ == "__main__":
    try:
        logging.getLogger().setLevel(logging.INFO)
        logging.info("start")

        if not os.path.exists(INDEX_DIR):
            logging.error("Directory [%s] is not found", INDEX_DIR)
            exit()
        if not os.path.exists(TARGET_DIR):
            logging.error("Directory [%s] is not found", TARGET_DIR)
            exit()

        # index作成
        create_index(INDEX_DIR)

        while True:
            # index更新
            update_index(INDEX_DIR, TARGET_DIR)

            # スリープ
            time.sleep(UPDATE_INTERVAL)

    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    finally:
        logging.info("end")

追記(2016/10/10)

  • PDFからのテキスト抽出はpdftotextコマンド使ったほうが楽。
  • ファイルパスに日本語が含まれていたりする場合で、get_registered_last_modified関数でパス名でうまく検索できないことがある。whooshのスキーマにパスをハッシュ化したものを追加登録することで対応可能。

Flaskとwhooshで簡単全文検索Webアプリケーション

サマリ

Pythonの軽量WebフレームワークであるFlask全文検索ライブラリであるwhooshを使用して全文検索Webアプリケーションを作ってみた。

f:id:magayengineer:20160920000022p:plain

ただ単にテキストファイルを全文検索しただけでは実用性に欠ける。 そのため、MSオフィスのファイルとPDFファイルをApache POI, Apache PDFBoxでテキストファイルに変換し、全文検索で検索できるようにした。

全体構成

まず、全文検索の対象にしたいファイルをApache POIApache PDFBoxなどを利用し、テキストファイルに変換する。変換の必要のないファイルはそのままコピーすればよい。 次にwhooshでテキストファイルを解析し、検索用のインデックスファイルを作る。そしてFlaskを使って作成したWebアプリからインデックスを使用して全文検索を行い、検索結果と検索にヒットしたファイルのURLを返すという作りになっている。

f:id:magayengineer:20160920000039p:plain

MSオフィスファイルからのテキスト抽出

Apache POIから必要なライブラリ(jarファイル)を入手すれば終わったようなもの。

以下のJavaコードで簡単にテキストファイルが抽出できる。

/**
 * MSオフィスのファイルからテキストを抽出する
 * @param file MSオフィスのファイル
 * @return MSオフィスのファイルのテキスト
 */
private static String getOfficeText(File file){
  POITextExtractor textExtractor = null;
  try {
    textExtractor = ExtractorFactory.createExtractor(file);
    return textExtractor.getText();
  } catch (Exception e) {
    e.printStackTrace();
  } finally {
    if (textExtractor != null) {
      try {
        textExtractor.close();
      } catch (IOException e) {
        e.printStackTrace();
      };
    }
  }
  return null;
}

PDFファイルからのテキストを抽出

こちらも同じくApache PDFBoxから必要なライブラリ(jarファイル)を入手すれば終わったようなもの。

以下のJavaコードで簡単にテキストファイルが抽出できる。

/**
 * PDFからテキストを抽出して返す
 * @param file PDFファイル
 * @return PDFから抽出したテキスト
 */
private static String getPdfText(File file) {
  PDDocument document = null;
  try {
    document = PDDocument.load(file);
    return new PDFTextStripper().getText(document);
  } catch (IOException e) {
    e.printStackTrace();
  } finally {
    if (document != null) {
      try {
        document.close();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
  return null;
}

最終的にできたテキスト変換Javaコード

最終的にできたコードがこれ。 MSオフィスやPDFファイルはテキストファイルに変換しコピーし、その他のファイルはそのままコピーする。指定したディレクトリに含まれる全てのファイルに対して実行する。 これを実行するだけで、全文検索に用いるテキストファイルが用意できる。

CopyAndConvert.java

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.POITextExtractor;
import org.apache.poi.extractor.ExtractorFactory;

/**
 * 全文検索用にファイルをテキストファイルに変換するクラス
 * ディレクトリ構造のコピー、ファイルのコピーを行う。
 * MSオフィスのファイルはテキストを抽出しにファイル作成する。
 */
public class CopyAndConvert {
    public static void main(String[] args) {
        // コピー元ディレクトリのパス
        String fromDirPath = args[0];
        // コピー先ディレクトリのパス
        String toDirPath = args[1];
        // ファイルコピー
        CopyAndConvert.copyAndConvert(new File(fromDirPath), new File(toDirPath));
    }

    /**
    * MSオフィスの拡張子
    */
    private static final List<String> MS_OFFICE_SUFFIX_LIST = new ArrayList<String>();
    static {
        MS_OFFICE_SUFFIX_LIST.add("xls");
        MS_OFFICE_SUFFIX_LIST.add("doc");
        MS_OFFICE_SUFFIX_LIST.add("xlsx");
        MS_OFFICE_SUFFIX_LIST.add("docx");
        MS_OFFICE_SUFFIX_LIST.add("ppt");
        MS_OFFICE_SUFFIX_LIST.add("pptx");
        MS_OFFICE_SUFFIX_LIST.add("msg");
    }

    /**
    * 無視する拡張子
    */
    private static final List<String> IGNORE_SUFFIX_LIST = new ArrayList<String>();
    static {
        IGNORE_SUFFIX_LIST.add("JPG");
        IGNORE_SUFFIX_LIST.add("jpg");
        IGNORE_SUFFIX_LIST.add("iso");
        IGNORE_SUFFIX_LIST.add("png");
        IGNORE_SUFFIX_LIST.add("exe");
    }

    /**
    * ファイルをテキストファイルに変換して配置する
    * @param fromDir 変換元ディレクトリ
    * @param toDir コピー&変換先ディレクトリ
    */
    private static void copyAndConvert(File fromDir, File toDir) {
        for (File fromFile:fromDir.listFiles()) {
            if (fromFile.isDirectory()) {
                //ディレクトリのとき
                File newDir = new File(toDir, fromFile.getName());
                newDir.mkdir();
                CopyAndConvert.copyAndConvert(fromFile, newDir);
            } else {
                // 拡張子取得
                String suffix = CopyAndConvert.getSuffix(fromFile.getName());
                if (suffix == null) {
                    CopyAndConvert.copyFile(toDir, fromFile);
                } else {。
                    if (IGNORE_SUFFIX_LIST.contains(suffix)){
                        // 無視する拡張子の場合
                        continue;
                    }
                    if (MS_OFFICE_SUFFIX_LIST.contains(suffix)) {
                        // MSオフィスの場合
                        String msText = CopyAndConvert.getOfficeText(fromFile);
                        if (msText != null) {
                            File newFile = new File(toDir, fromFile.getName());
                            try {
                                CopyAndConvert.saveTextFile(newFile, msText);
                                System.out.println(newFile.getAbsolutePath() +"," + fromFile.getAbsolutePath());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    } else if("pdf".equals(suffix)) {
                        // PDFの場合
                        String pdfText = CopyAndConvert.getPdfText(fromFile);
                        if (pdfText != null) {
                            File newFile = new File(toDir, fromFile.getName());
                            try {
                                CopyAndConvert.saveTextFile(newFile, pdfText);
                                System.out.println(newFile.getAbsolutePath() +"," + fromFile.getAbsolutePath());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    } else {
                        CopyAndConvert.copyFile(toDir, fromFile);
                    }
                }
            }
        }
    }

    /**
    * MSオフィスのファイルからテキストを抽出する
    * @param file MSオフィスのファイル
    * @return MSオフィスのファイルのテキスト
    */
    private static String getOfficeText(File file){
        POITextExtractor textExtractor = null;
        try {
            textExtractor = ExtractorFactory.createExtractor(file);
            return textExtractor.getText();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {。。
            if (textExtractor != null) {
                try {
                    textExtractor.close();
                } catch (IOException e) {
                    e.printStackTrace();
                };
            }
        }
        return null;
    }


    /**
    * PDFからテキストを抽出して返す
    * @param file PDFファイル
    * @return PDFから抽出したテキスト
    */
    private static String getPdfText(File file) {
        PDDocument document = null;
        try {
            document = PDDocument.load(file);
            return new PDFTextStripper().getText(document);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (document != null) {
                try {
                    document.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }


    /**
    * テキストをファイルに保存する
    * @param file ファイル
    * @param text 保存するテキスト
    * @throws IOException IOエラーが発生した場合
    */
    private static void saveTextFile(File file, String text) throws IOException {
        PrintWriter pw = null;
        try {
            pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
            pw.println(text);
        } finally {
            if (pw != null) {
                pw.close();
            }
        }
    }

    /**
    * ファイルの拡張子を取得する
    * @param fileName ファイル名(拡張子込み)
    * @return 拡張子
    */
    private static String getSuffix(String fileName) {
        int point = fileName.lastIndexOf(".");
        if (point != -1) {
            return fileName.substring(point + 1);
        }
        return null;
    }

    /**
    * ファイルをコピーする
    * @param toDir コピー先ディレクトリ
    * @param fromFile コピー対象ファイル
    */
    private static void copyFile(File toDir, File fromFile) {
        try {
            File newFile = new File(toDir, fromFile.getName());
            Files.copy(fromFile.toPath(), newFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING);
            System.out.println(newFile.getAbsolutePath() +" -> " + fromFile.getAbsolutePath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

インデックスファイルの作成

whooshのインストール

全文検索にはpython全文検索ライブラリであるwhooshを使用する。まずはpipでインストールする。

$ pip install whoosh

スキーマ作成

whooshを利用するためには、スキーマ定義が必要。 スキーマは、検索に使う情報や検索後に合わせて利用しておきたい情報をぶっこんでおけばいいものという理解。 なので以下の2つのフィールドを入れられるように定義する。

各フィールドをスキーマに定義する際にはフィールド種別っぽいものが必要らしい。 ファイル名と、ファイルパスは https://whoosh.readthedocs.io/en/latest/api/fields.html を読むと、whoosh.fields.IDがよいと読めた。

class whoosh.fields.ID(stored=False, unique=False, field_boost=1.0, sortable=False, analyzer=None) Configured field type that indexes the entire value of the field as one token. This is useful for data you don’t want to tokenize, such as the path of a file. Parameters: stored – Whether the value of this field is stored with the document.

全文検索したいテキストを格納するフィールドはwhoosh.fields.NGRAMに入れるよよいようだ。

以下のような感じでスキーマ定義ができる。

# スキーマ定義
schema = whoosh.fields.Schema(
                              # ファイルパスを格納するフィールド
                              path=whoosh.fields.ID(stored=True),
                              # テキストファイルの行を格納するフィールド
                              content=whoosh.fields.NGRAM(stored=True))

# インデックス作成
whoosh.index.create_in(index_dir_path, schema)

インデックスへのファイルの登録

スキーマ定義が終わったら全文検索したいテキストファイルをインデックスに登録する。

# インデックスオープン
ix = whoosh.index.open_dir(index_dir_path)

# ファイル登録
writer = ix.writer()
writer.add_document(
                    # ファイルパス
                    path=unicode(original_file_path, 'utf-8'),
                    # テキストファイルの中の一行
                    content=unicode(content, 'utf-8'))
# コミット
writer.commit(optimize=True)
# indexクローズ
ix.close()

最終的に出来上がったインデックス作成Pythonコード

最終的に出来上がったインデックス作成用Pythonコードは以下の通り。 指定ディレクトリ配下を再帰的に探索し、テキストファイルを一行ずつインデックスに登録している。

create_index.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging


def read_txt_file(file_path):
    """
    テキストファイルを読み込んで返す
    :param file_path: テキストファイルのパス
    :return: テキストファイル(リスト形式で一行を一要素にして返す)
    """
    with open(file_path) as f:
        return f.readlines()


def create_index(index_dir_path):
    """
    インデックスを作成する
    :param index_dir_path:インデックス保存先ディレクトリ
    :return:インデックスオブジェクト
    """

    # スキーマ定義
    schema = whoosh.fields.Schema(# ファイルパスを格納するフィールド
                                  path=whoosh.fields.ID(stored=True),
                                  # テキストファイルの行を格納するフィールド
                                  content=whoosh.fields.NGRAM(stored=True))

    return whoosh.index.create_in(index_dir_path, schema)


def update_index(index_dir_path, target_dir_path):
    """
    テキストファイルを解析し、インデックスに各テキストファイルの情報を反映する
    :param index_dir_path:インデックス保存先ディレクトリ
    :param target_dir_path:全文検索対象のテキストファイル保存先ディレクトリ
    :return:void
    """
    try:
        # インデックスオープン
        ix = whoosh.index.open_dir(index_dir_path)

        # 再帰的にディレクトリを探索し、テキストファイルを解析する
        for root, dirs, files in os.walk(target_dir_path):
            for file_name in files:
                # テキストファイルパス
                text_file_path = os.path.join(root, file_name)

                logging.info("creating index of %s", text_file_path)

                # テキストファイルをリード
                contents = read_txt_file(text_file_path)
                # オリジナルのファイルのパス
                original_file_path = text_file_path.replace(target_dir_path, "/static")

                writer = None
                try:
                    writer = ix.writer()
                    if contents:
                        for content in contents:
                            # 一行ずつ解析してインデックスに登録する
                            writer.add_document(path=unicode(original_file_path, 'utf-8'),
                                                content=unicode(content, 'utf-8'))
                except UnicodeDecodeError:
                    # バイナリファイル等は無視
                    logging.warn("decode error [%s]", text_file_path)
                finally:
                    if writer is not None:
                        writer.commit(optimize=True)
    finally:
        if ix is not None:
            ix.close()


if __name__ == "__main__":
    try:
        logging.getLogger().setLevel(logging.INFO)
        logging.info("start")
        # インデックス保存先ディレクトリ
        index_dir_path = "piyo"

        # 全文検索対象のテキストファイル保存先ディレクトリ
        target_dir_path = "hoge"

        if not os.path.exists(index_dir_path):
            logging.error("index_dir [%s] is not found", index_dir_path)
            print "index_dir is not found."
            exit()
        if not os.path.exists(target_dir_path):
            logging.error("target_dir [%s] is not found", target_dir_path)
            exit()

        # index作成
        create_index(index_dir_path)

        # index更新
        update_index(index_dir_path, target_dir_path)

    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    finally:
        logging.info("end")

検索プログラム & Webアプリケーションの作成

検索プログラムの作成

検索もwhooshのAPIに沿って作成すれば簡単。 今回は"content"というフィールドにテキストファイルを格納しているため、 テキストファイルに対して全文検索したければ、QueryParserの第一引数に"content"を指定してやればいい。

# インデックスオープン
ix = whoosh.index.open_dir(index_dir_path)

# クエリ作成
parser = whoosh.qparser.QueryParser('content', ix.schema)
query = parser.parse(keywords)

# 検索実行(10000件上限)
searcher = ix.searcher()
results = searcher.search(query, limit=10000)

searcher.close()

検索結果は、ファイルのパスと、検索に引っかかった行の組で返ってくるため、 以降で扱いやすくするため、以下のようなコードでファイルパスごとにまとめておく。

"""
ここからこんなマップを作る
key                     ,value
検索に引っかかったファイルパス,[該当行1, 該当行2,...]
"""
result_map = {}

# 検索結果を一つずつ処理する
for result in results:
    # 1ファイルに関して検索に引っかかった行を全て取得
    rows = result_map.get(result['path'])

    if rows is None:
        rows = []
        result_map[result['path']] = rows

    # 検索に引っかかった行をリストに追加
    rows.append(result['content'])

Webアプリケーション

Pythonの軽量WebフレームワークであるFlaskって検索Webアプリケーションを作る。

flaskのインストール

flaskはお決まりのpipでインストールする。

pip install flask

検索受付URL作成

"@app.route"を関数の前につけるだけで簡単に作成可能。 また特定のHTTPメソッドだけ受け付けるようにする設定も簡単にできる。

@app.route('/')
def index():
    """
    トップページを返す
    :return: トップページ
    """
    return render_template('index.html')

@app.route('/search', methods=['POST'])
def post_search():
  """
  全文検索リスエストを処理して結果を返す
  :return: 検索結果
  """
# 中略
# 検索を実行し、検索結果をテンプレートに反映して返す
return render_template('index.html',
                       keywords=keywords,
                       contents=result_html)

# Appスタート
app.debug = True # デバッグモード有効化
app.run(host='0.0.0.0', port=9999)

上記のコード中で使用している"render_template"関数では、テンプレートとして用意しておいたHTMLファイルに動的に値を埋め込むことができる関数である。今回は、以下の検索用のフォームを持たせたHTMLに"keywords"と"contents"という変数を持たせ、そこを動的に書き換えている。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Search System</title>
  </head>
  <body>
{% block content %}
<div class="form">
  <div class="container">
    <div class="row">
      <div class="col-md-12">
        <p class="lead">
          <h2>
          {% if keywords %}
            Search results of {{ keywords }}
          {% endif %}
          </h2>
        </p>
        <form action="/search" method="post" class="form-inline">
          <label for="keywords">keywords</label>
          <input type="text" class="form-control" placeholder="keywords"
                 id="keywords"
                 name="keywords"
                 value={% if keywords %}{{ keywords }}{% endif %}>
          <button type="submit" class="btn btn-default">search</button>
        </form>
      </div>
    </div>
  </div>
</div>
{% endblock %}
{% if contents %}
    {% autoescape false %}
        {{ contents }}
    {% endautoescape %}
{% endif %}
  </body>
</html>

ディレクトリ構成

今回、私が作成したプログラム郡のディレクトリ構成は以下のようにした。 flaskではstaticディレクトリ配下にリソースファイルを格納すると、そのファイルを公開できる。 今回は検索にヒットしたファイルをWebブラウザ経由でダウンロードできるようにしたいのでテキストファイルへ変換前のwordやpdfファイルを格納している。

./search/
├── create_index.py   <- インデックス作成プログラム
├── index_dir         <- インデックス保存先ディレクトリ
├── search_server.py  <- 検索 & Webアプリ
├── static            <- 全文検索対象のファイル格納ディレクトリ(テキストファイルへ変換前のwordやexcel, pdfファイル)
├── target_dir     <- 全文検索対象のテキストファイル格納ディレクトリ
└── templates
    └── index.html    <- HTMLのテンプレート

検索プログラム & Webアプリケーション全体

今回作成した検索プログラム & Webアプリのコード全体は以下の通り。

search_server.py

#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
import whoosh.fields
import whoosh.index
import whoosh.qparser
import traceback
import logging
from flask import Flask, render_template, request
import sys

# Web Application
app = Flask(__name__)

# 全文検索用のインデックスオブジェクト(Python-Whoosh)
ix = None


@app.route('/')
def index():
    """
    トップページを返す
    :return: トップページ
    """
    return render_template('index.html')


@app.route('/search', methods=['POST'])
def post_search():
    """
    全文検索リスエストを処理して結果を返す
    :return: 検索結果
    """

    if request.method == 'POST':
        # リクエストパラメタ取得
        keywords = request.form['keywords']

        # 全文検索実行
        result_map = exec_search(keywords)

        # 返却するHTML
        result_html = ""
        for path, rows in result_map.items():
            # 検索に引っかかったファイルの名前、パスを出力
            path_str = path.encode('utf-8')

            file_name = path_str
            if "/" in path_str:
                file_name = path_str[path_str.rindex("/")+1:]


            result_html += "<h2><a href=\"{0}\">{1}({2})</a></h2><br>".format(path_str,
                                                                              file_name,
                                                                              path_str)
            result_html += "<ol>"
            for row in rows:
                # 検索に引っかかった行を出力
                result_html += "<li>{0}</li><br>".format(row.encode('utf-8'))
            result_html += "</ol>"

        # 検索を実行し、検索結果をテンプレートに反映して返す
        return render_template('index.html',
                               keywords=keywords,
                               contents=result_html)

def exec_search(keywords):
    """
    全文検索実行
    :param keywords:検索キーワード
    :return 検索結果
    """

    # クエリ作成
    parser = whoosh.qparser.QueryParser('content', ix.schema)
    query = parser.parse(keywords)

    searcher = None
    try:
        # 検索実行(10000件上限)
        searcher = ix.searcher()
        results = searcher.search(query, limit=10000)

        """
        ここからこんなマップを作る
        key                     ,value
        検索に引っかかったファイルパス,[該当行1, 該当行2,...]
        """
        result_map = {}

        # 検索結果を一つずつ処理する
        for result in results:
            # 1ファイルに関して検索に引っかかった行を全て取得
            rows = result_map.get(result['path'])

            if rows is None:
                rows = []
                result_map[result['path']] = rows

            # 検索に引っかかった行をリストに追加
            rows.append(result['content'])

    finally:
        if searcher is not None:
            searcher.close()
    return result_map


if __name__ == "__main__":
    try:
        # UTF-8をデフォルトのエンコーディングに設定
        # これをやらないとHTMLをレンダリングした際にコケる
        reload(sys)
        sys.setdefaultencoding("utf-8")

        # 検索用インデックス読み込み
        index_dir_path = "hoge"
        if not os.path.exists(index_dir_path):
            logging.error("index_dir [%s] is not found", index_dir_path)
            print "index_dir is not found."
            exit()
        ix = whoosh.index.open_dir(index_dir_path)

        # Appスタート
        app.debug = True # デバッグモード有効化
        app.run(host='0.0.0.0', port=9999)

    except Exception as e:
        logging.error(e)
        logging.error(traceback.format_exc())
    finally:
        if ix is not None:
            ix.close()

まとめ

今回はPythonの軽量WebフレームワークであるFlask全文検索ライブラリであるwhooshを使用して全文検索Webアプリケーションを作った。

また、実用性をあげるために、ただ単にテキストファイルを検索するだけではなくMSオフィスのファイルとPDFファイルも全文検索できるようにした。

作成にかかったトータル時間は1日程度のはず。 個人でもさくっと全文検索Webアプリケーションが書ける時代がきましたね。 OSSってすごい。

参考にしたサイト

http://a2c.bitbucket.org/flask/ http://qiita.com/ynakayama/items/2cc0b1d3cf1a2da612e4 http://d.hatena.ne.jp/rudi/20110420/1303307332 https://pypi.python.org/pypi/Whoosh/