【Java】JNIの利用方法
本稿はJavaでJNIを実行する方法を紹介します。JNIよりも簡単に使用できるJNAというライブラリもあるので、よっぽどのことがないかぎり、JNAを利用するのが良いでしょう。
今回は勉強の一環としてJNIをやってみたいと思います。
Java側はコマンドライン経由でコンパイルや実行などを行うことを前提に説明します。
C側の実装はVisualStudioを利用することを前提とします。言語はC++を使用していることを前提とします。
動作環境はWindowsの64bitを前提とします。
1.JNIとは
JNIはJavaからC/C++で作成されたネイティブ実装を呼び出すことができるインターフェースです。
メリットやデメリットは下記のようなものがありそうです。
使用するメリット
- 処理速度が必要な場合に使用できる
- Javaで実現不可能な処理を実行できる
使用するデメリット
- JNI呼び出しのオーバーヘッドがあるので、むやみに使用すると遅くなる
- C言語の知識のみならず、JNI関連の機能を利用する知識なども必要
- ネイティブ実装は環境依存しやすい
とりあえず私が思ったことは、とにかく面倒くさいということです。Javaで完結できるならJavaで完結したほうが良いです。
2.nativeメソッドの定義
Java側でnativeメソッドを定義してみましょう。メソッドにはnativeのキーワードを使って、宣言します。
下記はサンプルです。
public class Test { public static void main(String[] args) { print(); } //nativeメソッド public static native void print(); }
正直Java側でやることはほとんどありませんね。
3.javacコマンドでC言語ヘッダの作成
2で作成したコードをhオプションを付けてコンパイルしてみましょう。(古いJavaの場合はjavahコマンドを使用します。)
javac Test.java -h .
コンパイルするとTest.hというC/C++用のヘッダファイルが作成されると思います。
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class Test */ #ifndef _Included_Test #define _Included_Test #ifdef __cplusplus extern "C" { #endif /* * Class: Test * Method: print * Signature: ()V */ JNIEXPORT void JNICALL Java_Test_print (JNIEnv *, jclass); #ifdef __cplusplus } #endif #endif
4.VisualStudioの設定
私の環境にはC/C++の開発環境はVisualStudioしかないので、VisualStudioでの操作で説明します。バージョンは2013 expressと、ちょっと古めです。
では早速プロジェクトを作成しましょう。
プロジェクトを作成したら、空のソースファイルを作成してみましょう。
3で作成したヘッダファイルTest.hをTest.cppファイルが存在するフォルダにコピーします。
コピーしたファイルをVisualStudioに関連付けます。Test.hをヘッダーファイルにドラッグアンドドロップすればOK
続いて構成マネージャーの設定を行います。
アクティブソリューションはReleaseにします。私の環境は64bit環境なのでプラットフォームはx64を選択します。
もし、x64がない場合はプルダウンから新規作成を選択し、作成しましょう。
jniを利用するためにはjniの基本ヘッダを使用できるように設定しなければなりません。
ヘッダを参照させる設定は下記のようにプロジェクトのプロパティを開きます。
左側のリストからC/C++を選択し、追加のインクルードディレクトリを設定します。jniの基本ヘッダはJavaのインストールフォルダに作成されていますのでそのパスを参照させてください。
私の環境はeclipseを入れているので、そこに同梱されているJavaのインクルードフォルダを参照させています。includeフォルダとwin32フォルダの二つのパスをセミコロン区切りで設定しましょう。下記は設定例です。
C:\Users\ユーザー\Desktop\eclipse\pleiades\java\9\include;C:\Users\ユーザー\Desktop\eclipse\pleiades\java\9\include\win32
これで一旦準備OK
ここまでの設定が本当に面倒であります。
次はC言語、もしくはC++でソースを作成してみましょう。
5.C/C++でnativeメソッドの実装
Test.cppの中身を下記のように変更します。
#include "Test.h" #include <stdio.h> //Java側で定義したprintメソッドの処理を作成します。 JNIEXPORT void JNICALL Java_Test_print (JNIEnv *, jclass) { printf("Hello JNI World!"); }
シンプルにコンソール出力だけを実装。
6.動的ライブラリを作成する
左側から構成プロパティ→全般を選択し、ダイナミックライブラリを選択。OK
最後にビルドしてみましょう。
ビルド成功したらプロジェクトのReleaseフォルダにTest.dllが作成されているのでこいつをjava側のソースフォルダにコピーもしくは移動させます。
7.ライブラリをJava側で読み込んで実行
Java側で作成したdllを読み込むにはSystem.loadLibraryメソッドを呼び出します。
Javaプログラムを下記のように書き換えてコンパイルします。
public class Test { public static void main(String[] args) { System.loadLibrary("Test"); print(); } public static native void print(); }
Hello JNI World!
ちなみにフルパスで指定できる、loadメソッドも存在します。
最初のソース作成の時にloadLibraryを入れてしまっても良かったかもしれませんね。
以上でJNIの動作確認ができたかと思います。
JNIの機能をいろいろ使ってみる
JNIを利用しているとJava側の型を引数に渡したり、ネイティブからJavaのクラスのメソッドを呼び出したりしたくなるかもしれません。
ここでは、そのような連携用に用意された、機能をサンプルコードとともに紹介します。
基本的な型とサンプル
JNIにはjava側の型と対応するCの型が存在します。
下記はjavaとC側で対応する型表です。
Javaの型
|
Cの型
|
---|---|
boolean
|
jboolean
|
byte
|
jbyte
|
char
|
jchar
|
short
|
jshort
|
int
|
jint
|
long
|
jlong
|
float
|
jfloat
|
double
|
jdouble
|
全ての参照型
|
jobject
|
String
|
jstring
|
Class
|
jclass
|
配列型のベース
|
jarray
|
参照型配列
|
jobjectArray
|
boolean[]
|
jbooleanArray
|
byte[]
|
jbyteArray
|
char[]
|
jcharArray
|
short[]
|
jshortArray
|
int[]
|
jintArray
|
long[]
|
jlongArray
|
float[]
|
jfloatArray
|
double[]
|
jdoubleArray
|
jobjectはすべてのJavaオブジェクトとして扱いますので、例えば自作クラスのCharaクラスであってもjobjectですし、HashMapでもjobjectです。
あと、連携用の型として特殊なJNIEnvがあります。この型はJavaとの連携を行うための関数を呼び出す際に使用できます。
さて、それではいくつかの基本型を引数にとるサンプルを作成し、結果を返す処理を実装してみましょう。
※以降はヘッダやdllの作成工程は省略します。
public class Test { public static void main(String[] args) { System.loadLibrary("Test"); //ネイティブメソッド呼び出し double result = tashizan(10, 4.5f); //結果を表示 System.out.println(result); } public static native double tashizan(int a, float b); }
#include "Test.h" JNIEXPORT jdouble JNICALL Java_Test_tashizan (JNIEnv *, jclass, jint a, jfloat b) { //Java側から送られてきたintとfloatを足し算して結果をdoubleで返す。 jdouble result = a + b; return result; }
14.5
配列の取り扱いとサンプル
配列を取り扱うための基本操作をいくつか紹介します。JNIEnvオブジェクトを利用して特定の関数を呼び出すことが基本です。
New(基本型)Array関数
配列を作成します。
jintArray arr = env->NewIntArray(要素数);
GetArrayLength関数
配列の要素数を取得します。
jsize len = env->GetArrayLength(Java配列。例えばjintArrayを指定できる);
※jsize型はサイズを表す数値型です。
Get(基本型)ArrayElements関数
配列へアクセスするための関数です。これで取得したバッファはRelease関数を利用して解放しなければなりません。
jint* nums_ptr = env->GetIntArrayElements(参照したいjintArray型, コピーが作成されたかのフラグが設定されるポインタ);
コピーが作成されたかを知る必要は、ほとんどないので、nullを指定しておくといいです。取得したい場合はjbooleanのポインタを渡しましょう。
Release(基本型)ArrayElements関数
配列へアクセスするためのバッファを開放します。Get(基本型)ArrayElements関数で取得したら、この関数で解放してください。
env->ReleaseIntArrayElements(元のjintArray配列型, Get時に取得したポインタ, 解放処理モード);
解放処理モードの引数は下記です。
0・・・元配列に結果を反映後に解放
JNI_COMMIT・・・元配列に結果を反映する
JNI_ABORT・・・解放のみ
通常は0のみ使用で問題ありません。
それでは、これらの関数を使用して、ネイティブメソッドを実装したサンプルをご覧ください。
public class Test { public static void main(String[] args) { System.loadLibrary("Test"); //ネイティブメソッドで配列を作成 int[] nums = newIntArray(4); //ネイティブメソッドで全要素100で初期化 fill(nums, 100); //数値を代入 nums[1] = 2; nums[2] = 5; nums[3] = 6; //ネイティブメソッドで足し算 String result = tashizan(nums); //結果を表示 System.out.println(result); } //配列の中身を足し算して結果を文字列で返す public static native String tashizan(int[] nums); //配列の中身を引数nで全初期化する public static native void fill(int[] nums, int n); //要素数lengthで新しい配列を作成する public static native int[] newIntArray(int length); }
#include "Test.h" #include <string> using namespace std; //配列の中身を足し算して計算式と結果を文字列で返す JNIEXPORT jstring JNICALL Java_Test_tashizan (JNIEnv *env, jclass, jintArray nums) { //配列要素数の取得 jsize len = env->GetArrayLength(nums); //配列への参照を取得(使用後は解放しなければならない) jint* nums_ptr = env->GetIntArrayElements(nums, nullptr); //足し算 int resultNum = 0; string result = ""; for (int i = 0; i < len; i++) { resultNum += nums_ptr[i]; if (i != 0) result += " + "; result += to_string(nums_ptr[i]); } result += " = " + to_string(resultNum); //解放処理 env->ReleaseIntArrayElements(nums, nums_ptr, 0); //結果の文字列をJavaのStringへ変換 jstring javaResult = env->NewStringUTF(result.c_str()); return javaResult; } //Javaから送られてきた配列を引数nで初期化する関数 JNIEXPORT void JNICALL Java_Test_fill (JNIEnv *env, jclass, jintArray nums, jint n) { //配列要素数の取得 jsize len = env->GetArrayLength(nums); //配列への参照を取得(使用後は解放しなければならない) jint* nums_ptr = env->GetIntArrayElements(nums, nullptr); //値を代入 for (int i = 0;i < len;i++) { nums_ptr[i] = n; } //解放処理 env->ReleaseIntArrayElements(nums, nums_ptr, 0); } //新しいint配列を作成してJavaへ返す JNIEXPORT jintArray JNICALL Java_Test_newIntArray (JNIEnv *env, jclass, jint len) { jintArray res = env->NewIntArray(len); return res; }
100 + 2 + 5 + 6 = 113
オブジェクトの取り扱いとサンプル
最後にJavaのオブジェクトをC側から扱うものを見ていきましょう。
Javaクラスのインスタンス化
Javaクラスのインスタンス化にはFindClass関数とGetMethodID関数、NewObject関数を使用します。
FindClass関数はjclassオブジェクトを取得します。
FindClass(型のシグネチャ) //型シグネチャ=[L]+[Javaのパッケージをスラッシュ区切りで結合]+クラス名+[;]
例えばTestクラスをJava実行階層に配置した場合は[LTest;]という文字列を、JavaのHashMapなら[Ljava/util/HashMap;]という感じです。
※ただしString型だけは専用関数が用意されているのでこちらを使用したほうが簡単です。NewString関数でjchar*を引数にとるjstringを作成できます。NewStringUTFを利用するとchar*を引数にとるjstringを作成できます。
NewStringはC側で数値型として扱われている(環境によって変わる可能性あり)2バイトの型になるため、取り扱いが若干面倒になります。
NewStringUTFはC側でもchar型で扱えるためプログラムが少し簡単で、使いやすくなります。ただし、1バイトであるため、全角の取り扱いに注意が必要です。うまいことしないと文字化けする可能性があります。
まあ状況によって使い分けてねってことで。
紹介はしませんが、他にもString関連の関数は用意されています。
GetMethodID関数は実行したいコンストラクタを特定します。
GetMethodID(取得したいメソッドが存在するjclassオブジェクト, "<init>", メソッドシグネチャ);
メソッドシグネチャはJavapコマンドを使用し、-sオプションを付けることで取得できます。例えば実行階層にあるCharaクラスのシグネチャを確認したい場合は下記のコマンドを実行すると良いでしょう。
javap -s Chara
Compiled from “Test.java”
class Chara {
java.lang.String name;
descriptor: Ljava/lang/String;
int hp;
descriptor: I
int power;
descriptor: I
Chara();
descriptor: ()V
}
この中でコンストラクタはChara()と描かれた部分になりますので、その下のdescriptor:の右側を引数に指定します。この場合は[()V]となります。
NewObject関数はFindClass関数で取得したオブジェクトと、MethodIDを引き渡します。
例えば自作のCharaクラスをインスタンス化したい場合はこんな感じのコードになるでしょう。
jclass cls = env->FindClass("LChara;");//使用するクラスを取得 jmethodID constructor = env->GetMethodID(cls, "<init>", "()V");//コンストラクタ取得 jobject ch = env->NewObject(cls, constructor);//インスタンス化
Javaクラスのフィールドへアクセス
フィールドにアクセスするにはFindClass関数とGetFieldID関数、Set(型名)Field関数、Get(型名)Field関数を使用します。
FindClass関数はインスタンス化の時と同じ要領です。
GetFieldIDは操作したいフィールドを特定します。
GetFieldID(取得したいメソッドが存在するjclassオブジェクト, フィールド名, 型のシグネチャ);
型のシグネチャはフィールドの型を表すシグネチャです。こちらもjavap -sコマンドで確認できます。
Set(型名)FieldやGet(型名)Fieldで取得や設定が可能です。
例えばCharaクラスにString name;のフィールドがあった場合、取得と設定は下記のようになります。
取得
jclass cls = env->FindClass("LChara;"); jfieldID nameId = env->GetFieldID(cls, "name", "Ljava/lang/String;"); jobject str = env->GetObjectField(操作対象のCharaオブジェクト, nameId);
設定
jclass cls = env->FindClass("LChara;"); jfieldID nameId = env->GetFieldID(cls, "name", "Ljava/lang/String;"); jstring name = env->NewStringUTF("string!!"); env->SetObjectField(操作対象のCharaオブジェクト, nameId, name);
もちろんstaticフィールドにアクセスできる関数も存在します。
Javaクラスのメソッドへアクセス
メソッドへアクセスするにはFindClass関数、GetMathodID関数、Call(戻り値の型名)Method関数を使用します。
FindClass、GetMathodID関数はインスタンス化の時と同じ要領で取得できます。
たとえば、Charaクラスに戻り値のないexecuteメソッドが存在し、それを呼び出す場合は下記のようなコードになると思います。
jclass cls = env->FindClass("LChara;"); jmethodID calbackId = env->GetMethodID(cls, "execute", "()V"); env->CallVoidMethod(操作対象のCharaオブジェクト, calbackId);
もちろんstaticメソッドにアクセスできる関数も存在します。
例外処理
例外処理はThrow関数や、ThrowNew関数を使用します。
ThrowNew(env->FindClass("Ljava/lang/RuntimeException;"), "Exception!!");
この場合はRuntimeExceptionをスローしたときと同じような扱いとなります。Throw関数のほうはjobjectを引数にとります。すでにインスタンス化していた場合はこちらを使いましょう。
それでは、これらの関数を使用して、ネイティブメソッドを実装したサンプル作ってみましょう。
Charaオブジェクトを作成するネイティブメソッドと、2つのCharaを引数にしてバトルをネイティブメソッドで行い、結果をJava側のBattleResultメソッドへコールするという内容です。Java側ではネイティブメソッドから渡されてきた、ArrayListの中身を表示するだけの処理を行っています。
また、C側のwchar_tが2バイトである前提の処理を行いますので、2バイトでない場合は例外を発生させることにしました。
ソースは若干複雑になってしまいましたが、インスタンス化、フィールドアクセス、メソッドアクセス、例外など紹介した機能を網羅したサンプルとなっています。
import java.util.ArrayList; public class Test { public static void main(String[] args) { System.loadLibrary("Test"); Chara ch1 = newChara("赤龍帝", 300, 100); Chara ch2 = newChara("白龍皇", 600, 50); Chara ch3 = newChara("獅子王", 500, 90); battle(ch1, ch3); battle(ch3, ch2); battle(ch1, ch2); } //新しいキャラを作成するネイティブメソッド public static native Chara newChara(String name, int hp, int power); //引数のキャラがバトルを行うネイティブメソッド public static native void battle(Chara ch1, Chara ch2); } class Chara{ String name; int hp; int power; } class BattleResult{ //バトルの結果を表示するメソッド public void result(ArrayListresultArr){ resultArr.stream().forEach(System.out::println); } }
#include "Test.h" #include <string> using namespace std; //新しいキャラを作成するメソッド JNIEXPORT jobject JNICALL Java_Test_newChara (JNIEnv *env, jclass, jstring name, jint hp, jint power) { //Charaクラスを検索 jclass cls = env->FindClass("LChara;"); //CharaクラスのコンストラクタIDを取得 jmethodID constructor = env->GetMethodID(cls, "<init>", "()V"); //Charaオブジェクトの作成 jobject ch = env->NewObject(cls, constructor); //名前の設定 jfieldID nameId = env->GetFieldID(cls, "name", "Ljava/lang/String;"); env->SetObjectField(ch, nameId, name); //hpの設定 jfieldID hpId = env->GetFieldID(cls, "hp", "I"); env->SetIntField(ch, hpId, hp); //powerの設定 jfieldID powerId = env->GetFieldID(cls, "power", "I"); env->SetIntField(ch, powerId, power); return ch; } //バトルメソッド(※wchar_tが2バイトであることが前提の処理が実装されています) JNIEXPORT void JNICALL Java_Test_battle (JNIEnv *env, jclass, jobject ch1, jobject ch2) { if ( sizeof(wchar_t) != 2 ) { //このプログラムは動作不能であるので例外処理を行う env->ThrowNew(env->FindClass("Ljava/lang/RuntimeException;"), "Battle Stop!! Illegal Environment Exception. wchar_t size is Must be 2 bytes."); return; } //Charaクラスを検索 jclass cls = env->FindClass("LChara;"); //各種フィールドIDを取得 jfieldID nameId = env->GetFieldID(cls, "name", "Ljava/lang/String;"); jfieldID hpId = env->GetFieldID(cls, "hp", "I"); jfieldID powerId = env->GetFieldID(cls, "power", "I"); //ch1各種のフィールドの取得 jstring name1 = static_cast<jstring>(env->GetObjectField(ch1, nameId)); jsize name1_len = env->GetStringLength(name1); const char* name1_ptr = env->GetStringUTFChars(name1, nullptr);//1バイト文字で取り出し const jchar* jname1_ptr = env->GetStringChars(name1, nullptr);//2バイト文字で取り出し jint hp1 = env->GetIntField(ch1, hpId); jint power1 = env->GetIntField(ch1, powerId); //ch2各種のフィールドの取得 jstring name2 = static_cast<jstring>(env->GetObjectField(ch2, nameId)); jsize name2_len = env->GetStringLength(name2); const char* name2_ptr = env->GetStringUTFChars(name2, nullptr); const jchar* jname2_ptr = env->GetStringChars(name2, nullptr); jint hp2 = env->GetIntField(ch2, hpId); jint power2 = env->GetIntField(ch2, powerId); //結果格納用のArrayListクラスをインスタンス化 jclass acls = env->FindClass("Ljava/util/ArrayList;"); jmethodID aconstructor = env->GetMethodID(acls, "<init>", "()V"); jobject arraylist = env->NewObject(acls, aconstructor); jmethodID addId = env->GetMethodID(acls, "add", "(Ljava/lang/Object;)Z"); //バトル結果の文字列をArrayListのaddメソッドで追加していく(1要素1行) env->CallBooleanMethod(arraylist, addId, env->NewStringUTF("-------------------------")); //ヘッダ string str = ""; str = string(name1_ptr) + " VS " + name2_ptr; env->CallBooleanMethod(arraylist, addId, env->NewStringUTF(str.c_str())); env->CallBooleanMethod(arraylist, addId, env->NewStringUTF(""));//改行用 for (int i = 1;hp1 > 0 && hp2 > 0 && i < 100;i++ ) { hp1 -= power2; hp2 -= power1; if (hp1 < 0) hp1 = 0; if (hp2 < 0) hp2 = 0; //各種ステータスの文字列をaddメソッドで追加していく wstring turn = to_wstring(i) + L"ターン目:"; env->CallBooleanMethod(arraylist, addId, env->NewString(reinterpret_cast<const jchar*>(turn.c_str()), turn.length())); string s = ""; s += "[" + string(name1_ptr) + ", HP:" + to_string(hp1) + "]"; env->CallBooleanMethod(arraylist, addId, env->NewStringUTF(s.c_str())); s = ""; s += "[" + string(name2_ptr) + ", HP:" + to_string(hp2) + "]"; env->CallBooleanMethod(arraylist, addId, env->NewStringUTF(s.c_str())); env->CallBooleanMethod(arraylist, addId, env->NewStringUTF(""));//改行用 } if (hp1 > 0 && hp2 > 0 || hp1 <= 0 && hp2 <= 0) { wstring draw = L"結果:ドロー"; env->CallBooleanMethod(arraylist, addId, env->NewString(reinterpret_cast<const jchar*>(draw.c_str()), draw.length())); } else if (hp1 > 0) { wstring r = wstring(L"結果:[") + reinterpret_cast<const wchar_t*>(jname1_ptr) + L"]の勝利!!"; env->CallBooleanMethod(arraylist, addId, env->NewString(reinterpret_cast<const jchar*>(r.c_str()), r.length())); } else { wstring r = wstring(L"結果:[") + reinterpret_cast<const wchar_t*>(jname2_ptr)+L"]の勝利!!"; env->CallBooleanMethod(arraylist, addId, env->NewString(reinterpret_cast<const jchar*>(r.c_str()), r.length())); } env->CallBooleanMethod(arraylist, addId, env->NewStringUTF("-------------------------")); //解放 env->ReleaseStringChars(name1, jname1_ptr); env->ReleaseStringChars(name2, jname2_ptr); env->ReleaseStringUTFChars(name1, name1_ptr); env->ReleaseStringUTFChars(name2, name2_ptr); //結果のコールバッククラスをインスタンス化 jclass bcls = env->FindClass("LBattleResult;"); jmethodID bconstructor = env->GetMethodID(bcls, "<init>", "()V"); jobject bresult = env->NewObject(bcls, bconstructor); //結果を通知 jmethodID calbackId = env->GetMethodID(bcls, "result", "(Ljava/util/ArrayList;)V"); env->CallVoidMethod(bresult, calbackId, arraylist); }
————————-
赤龍帝 VS 獅子王
1ターン目:
[赤龍帝, HP:210]
[獅子王, HP:400]
2ターン目:
[赤龍帝, HP:120]
[獅子王, HP:300]
3ターン目:
[赤龍帝, HP:30]
[獅子王, HP:200]
4ターン目:
[赤龍帝, HP:0]
[獅子王, HP:100]
結果:[獅子王]の勝利!!
————————-
————————-
獅子王 VS 白龍皇
1ターン目:
[獅子王, HP:450]
[白龍皇, HP:510]
2ターン目:
[獅子王, HP:400]
[白龍皇, HP:420]
3ターン目:
[獅子王, HP:350]
[白龍皇, HP:330]
4ターン目:
[獅子王, HP:300]
[白龍皇, HP:240]
5ターン目:
[獅子王, HP:250]
[白龍皇, HP:150]
6ターン目:
[獅子王, HP:200]
[白龍皇, HP:60]
7ターン目:
[獅子王, HP:150]
[白龍皇, HP:0]
結果:[獅子王]の勝利!!
————————-
————————-
赤龍帝 VS 白龍皇
1ターン目:
[赤龍帝, HP:250]
[白龍皇, HP:500]
2ターン目:
[赤龍帝, HP:200]
[白龍皇, HP:400]
3ターン目:
[赤龍帝, HP:150]
[白龍皇, HP:300]
4ターン目:
[赤龍帝, HP:100]
[白龍皇, HP:200]
5ターン目:
[赤龍帝, HP:50]
[白龍皇, HP:100]
6ターン目:
[赤龍帝, HP:0]
[白龍皇, HP:0]
結果:ドロー
————————-
やっぱC/C++のプログラムはいろいろと面倒なことが多いです・・・そこにJNIの仕様が入ってるからさらに面倒になる・・・
参考サイト
https://docs.oracle.com/javase/jp/8/docs/technotes/guides/jni/spec/types.html
https://docs.oracle.com/javase/jp/1.4/guide/jni/spec/functions.doc.html
http://www.ne.jp/asahi/hishidama/home/tech/java/jni_code.html
ディスカッション
コメント一覧
まだ、コメントがありません