java – こみなのメモ帳 / 趣味と実益のネタ帳 Tue, 05 Aug 2025 14:40:20 +0000 ja hourly 1 https://wordpress.org/?v=6.1.1 H2DBのLinked Tablesについて調べてみた /archives/1670/ /archives/1670/#respond Tue, 05 Aug 2025 14:40:20 +0000 https://www.komina.info/?p=1670 H2DBにはLinked Tablesという機能があります。外部テーブル(ほかのDB)へのテーブルリンクを作成して、あたかも H2DB 内に存在するテーブルかのように扱う機能です。

JDBCドライバで接続できるDBであれば、以下のようなコマンドで利用可能です。

CREATE LINKED TABLE LINK('org.h2.Driver', 'jdbc:h2:./test2', 'sa', 'sa', 'TEST');

社内に MySQL PostgreSQL などいろんなDBサーバが乱立しているようなときに使ったら便利そう、ということで調べてみることにしました。

環境構築

docker-composeを使ってお試し環境を作ってみました。h2db + mysql + postgresql な環境です。githubに一式を公開してあります。

https://github.com/komina77/h2db

git clone https://github.com/komina77/h2db.git
cd LinkedTables
docker-compose up -d

サンプルデータは MySQL の公式サイトにあるサンプルデータworld)を利用させてもらうことにします。これを mysqlpostgresql の両方の初期データとしてインストールすることにします。(postgresql へはそのままの文法では食わせることはできなかったので少し書き換えています)

city, country, countryLanguage の3つのテーブルから構成されるデータベースになります。

MySQLを参照してみる

実はH2DBのサーバに内蔵されているWeb管理画面はH2DB以外のDBにも接続できますので、これを利用してみたいと思います。

ホストOS上のブラウザで、http://localhost:8082/ を開き、保存済設定の中から「Generic MySQL」を選びます。ドライバクラスなどのデフォルト設定が表示されるので、JDBC URLやユーザ名、パスワード(mysql/mysql)を入力します。接続テストが通れば成功です。

PostgreSQLを参照してみる

同様に PostgreSQL のデータベースも参照することができます。

オートコンプリート機能も有効で、ちょっとしたSQLならすぐに試してみることができます。PostgreSQLの大文字小文字混在のカラムはダブルコーテーションで囲わないといけないので、逆に不便でした。

Linked Tables を定義してみる

mysqlpostgresql の準備ができたところで H2DB に接続して Linked Tables を作成してみたいと思います。

まずは mysqlworldデータベースの中の countryテーブルを my_country という名前で参照できるようにしてみます。

CREATE LINKED TABLE IF NOT EXISTS MY_COUNTRY(
'com.mysql.jdbc.Driver', 'jdbc:mysql://mysql-1:3306/world', 'mysql', 'mysql', 'country'
);

これだけで MY_COUNTRY というテーブルをローカルにあるテーブルのように参照できるようになります。

同様に、PG_CITYPG_COUNTRYLANGUAGE についてもテーブルを定義します。

CREATE LINKED TABLE IF NOT EXISTS PG_CITY(
'org.postgresql.Driver', 'jdbc:postgresql://postgres-1/world', 'postgres', 'postgres', 'city'
);

CREATE LINKED TABLE IF NOT EXISTS PG_COUNTRYLAUNGUAGE(
'org.postgresql.Driver', 'jdbc:postgresql://postgres-1/world', 'postgres', 'postgres', 'countrylanguage'
);

どんなSQLが発行されているか(MySQL

まずは条件なしで検索。

SELECT * FROM MY_COUNTRY 
SELECT * FROM country T

次は簡単な条件を付けてみます。

SELECT * FROM MY_COUNTRY where CODE ='JPN'
SELECT * FROM country T 
WHERE CODE>='JPN' AND CODE<='JPN'

単純な文字列の等号による一致条件のつもりだったのですが不等号の範囲検索に変換されてしまいました。なにか意図があるのでしょうがとりあえずヨシとします。

どんなSQLが発行されているか(PostgreSQL

SELECT * FROM PG_CITY 
SELECT * FROM public.city T
SELECT * FROM PG_CITY 
WHERE ID =10
SELECT * FROM public.city T 
WHERE ID>=$1 AND ID<=$2
ERROR:  column "id" does not exist at character 35

おっと。エラーが出てしまいました。どうやらDDLの段階でカラム名をダブルコーテーションで囲って大文字小文字を厳密に定義していたことが原因と思われる。。

DDLを修正してもう一度やり直し。PG_CITYPG_COUNTRYLANGUAGE についてもテーブル定義をやり直します。

SELECT * FROM PG_CITY 
WHERE ID =10
SELECT * FROM public.city T 
WHERE ID>=$1 AND ID<=$2
DETAIL:  parameters: $1 = '10', $2 = '10'

無事にクエリを発行することができました。

結合したらどうなるか(異DB同士

テーブル一つに対するクエリであれば、ほぼ等価の条件式が渡されるようなので、リンクされた側のDBで適切な実行計画が適用されることになりそうです。

では、2つのDBにまたがる結合をしたらどうなるのか。やってみたいと思います。

SELECT T1.NAME, T2.LANGUAGE  FROM MY_COUNTRY  T1
INNER JOIN PG_COUNTRYLAUNGUAGE  T2
ON T2.COUNTRYCODE = T1.CODE
AND  T2.ISOFFICIAL ='T'
WHERE T1.REGION  = 'North America'
;

北アメリカの国における公用語は?、という感じの意味合いになります。まず MY_COUNTRY を北アメリカで絞り込んだのち、国コードで PG_COUNTRYLANGUAGE から公用語を得る、という結合になることを想定してみました。

MySQL

SELECT * FROM country T WHERE CODE>='ABW' AND CODE<='ABW'
SELECT * FROM country T WHERE CODE>='AFG' AND CODE<='AFG'
SELECT * FROM country T WHERE CODE>='AFG' AND CODE<='AFG'
SELECT * FROM country T WHERE CODE>='AIA' AND CODE<='AIA'
SELECT * FROM country T WHERE CODE>='ALB' AND CODE<='ALB'
SELECT * FROM country T WHERE CODE>='AND' AND CODE<='AND'
SELECT * FROM country T WHERE CODE>='ANT' AND CODE<='ANT'
SELECT * FROM country T WHERE CODE>='ANT' AND CODE<='ANT'
SELECT * FROM country T WHERE CODE>='ARE' AND CODE<='ARE'
SELECT * FROM country T WHERE CODE>='ARG' AND CODE<='ARG'
SELECT * FROM country T WHERE CODE>='ARM' AND CODE<='ARM'
:
:

PostgreSQL

SELECT * FROM public.countrylanguage T WHERE ISOFFICIAL>=$1 AND ISOFFICIAL<=$2
-- parameters: $1 = 'T', $2 = 'T'

どうやら想定と逆に、PG_COUNTRYLANGUAGE から公用語の一覧を取得したのち、対応する国の一覧を取得。クエリには北アメリカでの絞り込みは入っていないので H2DB側で一番最後に行われたようです。

実行計画を見てみる

事後になりますが H2DBでの実行計画を見てみます。

EXPLAIN
SELECT T1.NAME, T2.LANGUAGE  FROM MY_COUNTRY  T1
INNER JOIN PG_COUNTRYLAUNGUAGE  T2
ON T2.COUNTRYCODE = T1.CODE
AND  T2.ISOFFICIAL ='T'
WHERE T1.REGION  = 'North America'
;
SELECT
    "T1"."NAME",
    "T2"."LANGUAGE"
FROM "PUBLIC"."PG_COUNTRYLAUNGUAGE" "T2"
    /* PUBLIC."": ISOFFICIAL = 'T' */
    /* WHERE T2.ISOFFICIAL = 'T'
    */
INNER JOIN "PUBLIC"."MY_COUNTRY" "T1"
    /* PUBLIC."": CODE = T2.COUNTRYCODE */
    ON 1=1
WHERE ("T1"."REGION" = 'North America')
    AND (("T2"."ISOFFICIAL" = 'T')
    AND ("T2"."COUNTRYCODE" = "T1"."CODE"))

FROM句で指定していたテーブルが MY_COUNTRY から PG_COUNTRYLANGUAGE に変わっていますね。H2DB では結合順序指定するヒント句はサポートされていません。性能を高めるには希望する結合順になるまで SQLをこねくり回すしかなさそうです。

結合したらどうなるか(同DB同士

同じDBにあるテーブル同士の結合も試してみます。

EXPLAIN
SELECT T1.COUNTRYCODE ,T1.DISTRICT , T1.NAME , T2.LANGUAGE
FROM PG_CITY T1
INNER JOIN PG_COUNTRYLAUNGUAGE T2
ON T2.COUNTRYCODE =T1.COUNTRYCODE 
WHERE T1.POPULATION >7000000
AND T2.ISOFFICIAL ='T'
;
SELECT
    "T1"."COUNTRYCODE",
    "T1"."DISTRICT",
    "T1"."NAME",
    "T2"."LANGUAGE"
FROM "PUBLIC"."PG_CITY" "T1"
    /* PUBLIC."": POPULATION > 7000000 */
    /* WHERE T1.POPULATION > 7000000
    */
INNER JOIN "PUBLIC"."PG_COUNTRYLAUNGUAGE" "T2"
    /* PUBLIC."": COUNTRYCODE = T1.COUNTRYCODE */
    ON 1=1
WHERE ("T2"."COUNTRYCODE" = "T1"."COUNTRYCODE")
    AND (("T1"."POPULATION" > 7000000)
    AND ("T2"."ISOFFICIAL" = 'T'))

人口が700万人超の都市のある国の公用語の一覧を得るクエリです。PostgreSQL へは以下のクエリが送信されました。

SELECT * FROM public.city T WHERE POPULATION>=$1
-- parameters: $1 = '7000000'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'BRA', $2 = 'BRA'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'GBR', $2 = 'GBR'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'IDN', $2 = 'IDN'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'IND', $2 = 'IND'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'IND', $2 = 'IND'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'JPN', $2 = 'JPN'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'CHN', $2 = 'CHN'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'CHN', $2 = 'CHN'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'KOR', $2 = 'KOR'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'MEX', $2 = 'MEX'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'PAK', $2 = 'PAK'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'TUR', $2 = 'TUR'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'RUS', $2 = 'RUS'
SELECT * FROM public.countrylanguage T WHERE COUNTRYCODE>=$1 AND COUNTRYCODE<=$2
-- parameters: $1 = 'USA', $2 = 'USA'

700万人超の都市の一覧を取得したのち、国コードを使って1国ずつ PG_COUNTRYLANGUAGE を取得していますね。ISOFFICIAL については記載がないので H2DB側で絞り込みを行っているのでしょう。

同じDBにあるテーブルであっても、結合をそのDBに任せず H2DB内で行っていることが分かりました。

まとめ

  • H2DB には他のDBのテーブルをH2DB内のテーブルのように見せかける機能がある。複数のシステムのDBを透過的に扱うのに便利。
  • 外部DBのテーブル定義に依存する。カラム名に大文字小文字混在を許すようなテーブルを参照するときは失敗することがあるので、事前に調査してビューなどを介すなど工夫が必要。
  • テーブル1つに対するクエリであればテーブルを持つDB上で抽出が行われるので、性能的には問題なさそう。
  • リンクテーブル同士での結合はサポートされている。しかし、H2DBの判断で実行計画が決まってしまうので性能が出ないことがある。
  • 結合するリンクテーブルが同じDBのテーブルであっても、結合がリンク先のDBに任されない。H2DBの判断によって行われる。

異なるシステムで使われているDBのテーブルを一時的に参照するような用途では便利かもしれない。しかし、複雑なクエリで問い合わせるときは性能を出すために苦労することになりそうだ。

]]>
/archives/1670/feed/ 0
java6のzip問題 /archives/1234/ /archives/1234/#respond Thu, 05 Dec 2024 16:04:31 +0000 https://www.komina.info/?p=1234 私、仕事でいまだにjava6が生きている環境を扱っております。維持だけでなく時折補助ツールを作ったりもします。

言うてもjavaなので新しい書式を使ったり新しい機能を使おうと思わない限りは問題はほとんど起きないものです。そんな中で ZipOutputStreamで意外な原因で以下の例外が発生することを発見しました。

せっかくなのでメモしておきます。

そもそも ZipOutputStreamFileOutputStreamなどのストリームに接続してZip圧縮したバイナリを出力するものです。ZipOutputStreamオブジェクトに対して putNextEntry(ZipEntry e) メソッドでファイルやディレクトリを追加していく感じで使います。で、最後にcloseすると無事Zipアーカイブが完成するというわけです。

意外な原因とは

どこら辺が問題かというと、ZipOutputStreamオブジェクトを生成しておきながら一件も putNextEntryしないで closeすると例外が発生するという。。。

つまり中身が空っぽのZipファイルを作成できないということなのです。

えー、そんなことが想定されていなかったの?という感じでした。さすがにjava7からは修正されていますので、私のようなjava6縛りプレイを強いられている方以外は新しいjavaを使うことで回避できます。。

解決策

ではどうするか。まず、圧縮対象が見つかったタイミングで ZipOutputStreamオブジェクトを生成するようなラップクラスを作ります。(ZipOutputStreamクラス自体は継承できないので新たに包含したクラスを作成するイメージです。デザインパターンでいうところのBridgeパターンでしょうか、知らんけど)

ここでは ZipCreaterJava6 というクラスを作成しました。こんな感じです。

public class ZipCreaterJava6 {
    FileOutputStream os = null;
    ZipOutputStream zos = null;
    
    // 空ZIPバイナリ
    int[] emptyzip = { 'P', 'K', 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
    
    public ZipCreaterJava6(File zipFile) throws Exception {
        this.os = new FileOutputStream(zipFile);
    }
    
    public void putNextEntry(ZipEntry e) throws IOException {
        getZos().putNextEntry(e);
    }
    
    public void write(byte[] b, int off, int len) throws IOException {
        getZos().write(b, off, len);
    }

    void close() throws IOException {
        if (this.zos == null) {
            // Create an empty ZIP file.
            for (int e : emptyzip) {
                os.write(e);
            }
        } else {
            zos.close();
        }
        if (os != null) os.close();
    }
    
    ZipOutputStream getZos() {
        if (zos == null) zos = new ZipOutputStream(os);
        return zos;
    }
}

putNextEntry メソッド、writeメソッド、 closeメソッド を実装しますと、ZipOutputStreamっぽく使えます。 putNextEntryメソッド、writeメソッド内では getZos()経由することで初回呼び出しを検出してそのタイミングでZipOutputStreamオブジェクト生成しています。
closeメソッド内ではZipOutputStreamオブジェクトが生成されていれば close処理する。生成されてなかったら空っぽのZIPを表すバイナリ列を返すようにします。

使用サンプルは以下のようになります。

public class Main {

	public static void main(String[] args) throws Exception {
		File dir = args.length > 0 ? new File(args[0]) : null;
		if (dir != null && dir.exists() && dir.isDirectory()) {
            ZipCreaterJava6 zos = new ZipCreaterJava6(new File("archive.zip"));
            //ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("archive.zip"));
		    
    		for (File item : dir.listFiles()) {
    		    FileInputStream fis = new FileInputStream(item);
    		    ZipEntry entry = new ZipEntry(item.getName());
    		    zos.putNextEntry(entry);
    		    
    		    byte[] buffer = new byte[4096];
    		    for (int n; (n=fis.read(buffer)) != -1; ) {
                    zos.write(buffer, 0, n);
                }
    		    fis.close();
    		}
    		zos.close();
		} else {
		    System.out.println("第一引数にソースとなるディレクトリを指定してください.");
		}
	}
}

最後に

どうしてもjava6でも動くようにしたい、という奇特なケースでない限りは不要なコードはありますが、実際私のような人間も2024年の時点で存在しているわけでして。あちこちから集めた情報をもとに解決した内容をここに記しておきました。

例外処理とかは適当です。引用するときはちゃんと書きましょう。以上。

]]>
/archives/1234/feed/ 0
javamailを使ってYahoo!メール(IMAP)でフォルダ移動する /archives/1032/ /archives/1032/#respond Wed, 27 Jul 2022 10:12:46 +0000 https://www.komina.info/?p=1032 「楽天ブログに自動で記事を投稿したい」というタイトルから脱線してきたのでそのまんまの件名に変えました。

前回はIMAPプロトコルで狙ったメールの受信を行うプログラムを書きました。

メールを解析した後は不要なのでメーラを使って手作業で消していたのですが、毎回はさすがに面倒なので解析の流れでプログラムで削除まで行うことにしました。

IMAPプロトコルではフォルダ間のメッセージ移動が可能なので、受信箱に届いたメッセージをゴミ箱へ移動させることにします。

String host = 'imap.mail.yahoo.co.jp'
String user = '******'
String pass = '******'
String SSL_FACTORY = "javax.net.ssl.SSLSocketFactory"

Properties properties = System.properties
properties.setProperty('mail.imap.socketFactory.class', SSL_FACTORY)
properties.setProperty('mail.imap.ssl.trust', '*')
properties.setProperty('mail.debug', 'true')

Session session = Session.getInstance(properties)
URLName urln = new URLName('imap', host, 993, null, user, pass)
Store store = session.getStore(urln)
store.connect()
IMAPFolder inbox = store.getFolder('Inbox')
inbox.open(Folder.READ_WRITE)

SearchTerm[] stlst = [
        new SubjectTerm('日の日記'),
        new ReceivedDateTerm(ComparisonTerm.EQ, new Date()),
        new FromStringTerm('no-reply@plaza.rakuten.co.jp')
    ]
SearchTerm st = new AndTerm(stlst)

List<Message> target = []
inbox.search(st).each { Message msg->
    println("Subject  : ${msg.getSubject()}")
    println(" From    : ${msg.getFrom()}")
    println(" ReplyTo : ${msg.getReplyTo()}")
    target.add(msg)
}

if (!target.isEmpty()) {
    Message[] targetArray = target.toArray(new Message[0])
    inbox.moveMessages(targetArray, store.getFolder('Trash'))
}

inbox.close()
store.close()

ポイントは store.getFolder('Inbox')com.sun.mail.imap.IMAPFolderで受けているところです。
メッセージの移動はIMAP固有の機能なのでjavax.mail.FolderにはmoveMessagesメソッドが定義されていません。

問題発生

これで課題解決かと思ったのですが実際に実行してみると例外が発生します。うーん?デバッグ出力を有効にしてみるとMOVEコマンドでエラーが発生していました。

A6 MOVE 15,33 Trash
A6 NO [CANNOT] MOVE It's not possible to perform specified operation

プログラムの書き方の問題ではなくIMAPサーバ側でコマンドを拒否しているようです。メッセージの移動を一切禁止するとかあるのだろうか。

ちなみに上記の A6 というのはコマンドとその応答を紐づけるものです。並列実行を考慮した実装となっているそうです。

原因の調査

さてプロトコル系でうまくいかないときは正常に動いているものを参考にするのが定石です。Thunderbirdという無料メーラーはログを出力する機能があるのでそれを利用することにしました。

環境変数 NSPR_LOG_MODULES にプロトコル種別とログレベルを設定(IMAP:3 など)、NSPR_LOG_FILE に保存先ファイルパスを設定した上でThunderbirdを起動します。(※ログは起動のたびにクリアされます)

Thunderbirdの画面からメッセージの移動を行ったところ以下のコマンドが発行されていました。(抜粋)

65 uid move 394141 "Trash"
* OK [COPYUID 4 394141 359316]
* 2 EXPUNGE
65 OK UID MOVE completed

単なる MOVEコマンドではなく、UID MOVEコマンドを使用していました。

MOVEコマンドで指定しているのはメッセージ番号、これはメールボックス内の連番でセッション内で有効な番号。一方、UID MOVEコマンドで指定しているのはUIDというものでメールボックス内で一意で永続的であることが保証された番号。

どうやらYahoo!メールのIMAPではメッセージ番号を使った移動はサポートしていないということらしい。

解決方法

さてメッセージを移動させる方法は分かりました。しかし UID MOVEコマンドをjavamail-1.6.2がサポートしているのかというとどうも期待できなさそうです。moveuidとかcopyuidとか思わせぶりなメソッドは存在するのですが実際に発行しているコマンドは MOVEコマンドでした。

そうなると自前で発行するしかありません。
javamailには自前のプロトコル処理に差し替える機能があるようですが(プロパティmail. + プロトコル名 +.classなど)、正直そこまでやるほどの労力を使いたくありません。

そこでimap関連の機能を利用しつつ UID MOVE コマンド発行することにしました。

SearchTerm[] stlst = [
        new SubjectTerm('日の日記'),
        new ReceivedDateTerm(ComparisonTerm.EQ, new Date()),
        new FromStringTerm('no-reply@plaza.rakuten.co.jp')
    ]
SearchTerm st = new AndTerm(stlst)
IMAPMessage[] msgs = inbox.search(st)

FetchProfile fp = new FetchProfile()
fp.add(FetchProfile.Item.ENVELOPE)  
fp.add(UIDFolder.FetchProfileItem.UID)
inbox.fetch(msgs, fp)

msgs.each { IMAPMessage msg->
    println("Subject  : ${msg.getSubject()}")
    println(" From    : ${msg.getFrom()}")
    println(" ReplyTo : ${msg.getReplyTo()}")
    println(" UID     : ${msg.getUID()}")
}
if (msgs.length > 0) {
    inbox.getProtocol().simpleCommand('UID MOVE '
        + UIDSet.toString(Utility.toUIDSet(msgs)) 
        + ' "Trash"', null)
}

UID MOVE コマンドを発行するにあたりUIDが必要となるので、まずはfecthメソッドにてUIDを取得します。そしてそのあとにsimpleCommandメソッドを用いてコマンドを発行しています。

UIDSet.toString(Utility.toUIDSet(msgs))は幾つかのUIDをまとめてくれる便利メソッドです。(123,124,125といった連番であれば123:125のようにまとめてくれる)

Yahoo!メールではゴミ箱は使用容量のカウント外となるのでひとまず移動だけでオッケーですね。

おまけ

IMAPでメッセージ削除は論理削除→物理削除の順で行います。
論理削除はメッセージに対してDeletedフラグを立てる形で行われるのですが、こちらも単なるSTOREコマンドではエラーになり、UID STOREコマンドを用いる必要がありました。

if (msgs.length > 0) {
    inbox.getProtocol().simpleCommand('UID STORE '
        + UIDSet.toString(Utility.toUIDSet(msgs))
        + ' +FLAGS '
        + inbox.getProtocol().createFlagList(new Flags(Flags.Flag.DELETED))
        , null)
}
inbox.expunge()

+FLAGSでフラグON、-FLAGSでフラグOFFを意味します。UID STOREコマンドで削除フラグを立てて、expungeメソッドで物理削除しています。

関連記事

https://www.komina.info/archives/887

参考資料

]]>
/archives/1032/feed/ 0