.NETプログラミング研究 第65号 †
.NET質問箱 †
「.NET質問箱」では、「どぼん!のプログラミング掲示板」に書き込まれた.NETプログラミングに関する投稿を基に、さらに考察を加え、Q&A形式にまとめて紹介します。
CSV形式のファイルをDataTableや配列等として取得する †
【質問】
CSV(Comma Separated Value)形式のファイルをDataTableや配列として読み込むことはできませんか?
【回答】
これには幾つかの方法が考えられます。ここでは3つの方法を紹介しますが、その前にCSVとはなにかについて確認しておきます。
CSV形式について、絶対的な決まりは存在していないようです。ただし、一般的なアプリケーションで使われている決まりについては、次のサイトで説明されています。
要約しますと、次のようになります。
1.レコードは、LFまたはCRLFで区切られる。
2.フィールドは、カンマ(,)で区切られる。
3.区切りのカンマの前後のスペース(タブを含む)は無視される。
4.フィールドにカンマが含まれる場合、フィールドをダブルクォートで囲まなければならない。
5.フィールドにダブルクォート(")が含まれる場合、フィールドをダブルクォートで囲み、フィールド内のダブルクォートを2つの連続するダブルクォート(つまり、「""」)に置き換えなければならない。
6.フィールドが改行文字を含む場合、フィールドをダブルクォートで囲まなければならない。
7.フィールドの前後にスペースがある場合、フィールドをダブルクォートで囲まなければならない。
8.すべてのフィールドがダブルクォートで囲まれているかもしれない。
9.はじめのレコードは、ヘッダかもしれない。
ここでは、ここのような決まりに準じて、話を進めることにします。
CSV形式のファイルを配列として取得する方法としてよく使われるのが、CSVファイルから一行ずつ読み出し、その一行をString.Splitメソッドを使って「,」文字で分割するという方法です。しかしこの方法は、上記の規則の4,6を見れば、正しくないことが分かります。つまり、フィールドにカンマや改行文字が含まれている可能性があるのです。よってこのような方法は、フィールドにカンマや改行文字が含まれていないことが保障されたCSVにしか使用できません。そうでないCSVを扱うためには、より複雑な処理が必要になります。
さて、ここから本題に入ります。まずは、Jet ProviderやODBC Providerを使う方法を紹介します。これらを使って、CSVファイルを解析することができます。
以下にJet Providerを使った例を紹介します。ここでは解析されたCSVファイルの内容をDataTableに格納しています。CSVファイルの文字コードは、Shift JISである必要があります。なお、接続文字列の「HDR=No」を「HDR=Yes」とすることにより、一行目をヘッダとすることができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| | Dim csvDir As String = "C:\"
Dim csvFileName As String = "test.csv"
Dim conString As String = _
"Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" _
+ csvDir + ";Extended Properties=""text;HDR=No;FMT=Delimited"""
Dim con As New System.Data.OleDb.OleDbConnection(conString)
Dim commText As String = "SELECT * FROM [" + csvFileName + "]"
Dim da As New System.Data.OleDb.OleDbDataAdapter(commText, con)
Dim dt As New DataTable
da.Fill(dt)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| | string csvDir = @"C:\";
//CSVファイルの名前
string csvFileName = "test.csv";
//接続文字列
string conString = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source="
+ csvDir + ";Extended Properties=\"text;HDR=No;FMT=Delimited\"";
System.Data.OleDb.OleDbConnection con =
new System.Data.OleDb.OleDbConnection(conString);
string commText = "SELECT * FROM [" + csvFileName + "]";
System.Data.OleDb.OleDbDataAdapter da =
new System.Data.OleDb.OleDbDataAdapter(commText, con);
//DataTableに格納する
DataTable dt = new DataTable();
da.Fill(dt);
|
次はODBC Provider(Microsoft Text Driver)を使った例です。.NET Framework 1.1以降で使用できます。なおこの場合は、一行目がヘッダとして処理されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| | Dim csvDir As String = "C:\"
Dim csvFileName As String = "test.csv"
Dim conString As String = _
"Driver={Microsoft Text Driver (*.txt; *.csv)};Dbq=" _
+ csvDir + ";Extensions=asc,csv,tab,txt;"
Dim con As New System.Data.Odbc.OdbcConnection(conString)
Dim commText As String = "SELECT * FROM [" + csvFileName + "]"
Dim da As New System.Data.Odbc.OdbcDataAdapter(commText, con)
Dim dt As New DataTable
da.Fill(dt)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| | string csvDir = @"C:\";
//CSVファイルの名前
string csvFileName = "test.csv";
//接続文字列
string conString = "Driver={Microsoft Text Driver (*.txt; *.csv)};Dbq="
+ csvDir + ";Extensions=asc,csv,tab,txt;";
System.Data.Odbc.OdbcConnection con =
new System.Data.Odbc.OdbcConnection(conString);
string commText = "SELECT * FROM [" + csvFileName + "]";
System.Data.Odbc.OdbcDataAdapter da =
new System.Data.Odbc.OdbcDataAdapter(commText, con);
//DataTableに格納する
DataTable dt = new DataTable();
da.Fill(dt);
|
このような方法では、正しくCSVファイルが解析されるようにするには、Schema.iniを用意しておく必要があります。そうしないと、正しく解釈されない可能性が十分にあります。
Schema.iniファイルの作成法については、次のサイトが参考になります。
参考:
次に正規表現を使った方法を紹介します。「Perlメモ」の「CSV形式の行から値のリストを取り出す」や「値に改行コードを含む CSV形式を扱う」が参考になります。
これによると、CSVの一つの行(一つのレコード)からフィールドを取り出すには、レコードの最後にカンマをつけてから、
("(?:[^"]|"")*"|[^,]*),
というパターンを使用します。
また、フィールドに改行コードを含む場合にCSV形式の文字列から一つのレコードを取り出すには、まず一行を取り出してから、その文字列内の「"」の数を数え、奇数であれば次の一行を追加します。これを「"」の数が偶数になるまで繰り返し、偶数になったところで、一つのレコードが取り出せたものとします。
フィールドを取り出すためのパターンは、他にもいろいろ考えられます。例えば、一つのレコードからフィールドを取り出すためのパターンとしては、
,(?=([^\"]*"[^"]*")*(?![^"]*"))
や
(?:^|,)(\"(?:[^\"]+|\"\")*\"|[^,]*)
などが以下のサイトで紹介されています。
「Perlメモ」による方法を参考にしたサンプルを以下に紹介します。ここでは、先のCSV規則の3が適切に処理されるように、「Perlメモ」のパターンに手を加えています。また、一行取り出すためにも、正規表現を使っています。取り出したフィールドは、レコードごとにArrayListに格納し、これらをさらにArrayListに格納しています(実用的ではありませんが、あくまでCSVの解析のサンプルということで、ご理解ください)。CSVにはヘッダが無く、すべてのフィールドを文字列として取得します。CSV形式のファイルを解析する場合は、その内容をString型に読み込んでからメソッドを呼び出してください。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
| | Public Shared Function CsvToArrayList1(ByVal csvText As String) _
As System.Collections.ArrayList
Dim csvRecords As New System.Collections.ArrayList
csvText = csvText.Trim( _
New Char() {ControlChars.Cr, ControlChars.Lf})
Dim regLine As New System.Text.RegularExpressions.Regex( _
"^.*(?:\n|$)", _
System.Text.RegularExpressions.RegexOptions.Multiline)
Dim regCsv As New System.Text.RegularExpressions.Regex( _
"\s*(""(?:[^""]|"""")*""|[^,]*)\s*,", _
System.Text.RegularExpressions.RegexOptions.None)
Dim mLine As System.Text.RegularExpressions.Match = _
regLine.Match(csvText)
While mLine.Success
Dim line As String = mLine.Value
While CountString(line, """") Mod 2 = 1
mLine = mLine.NextMatch()
If Not mLine.Success Then
Throw New ApplicationException("不正なCSV")
End If
line += mLine.Value
End While
line = line.TrimEnd( _
New Char() {ControlChars.Cr, ControlChars.Lf})
line += ","
Dim csvFields As New System.Collections.ArrayList
Dim m As System.Text.RegularExpressions.Match = _
regCsv.Match(line)
While m.Success
Dim field As String = m.Groups(1).Value
field = field.Trim()
If field.StartsWith("""") And field.EndsWith("""") Then
field = field.Substring(1, field.Length - 2)
field = field.Replace("""""", """")
End If
csvFields.Add(field)
m = m.NextMatch()
End While
csvFields.TrimToSize()
csvRecords.Add(csvFields)
mLine = mLine.NextMatch()
End While
csvRecords.TrimToSize()
Return csvRecords
End Function
Public Shared Function CountString( _
ByVal strInput As String, _
ByVal strFind As String) As Integer
Dim foundCount As Integer = 0
Dim sPos As Integer = strInput.IndexOf(strFind)
While sPos > -1
foundCount += 1
sPos = strInput.IndexOf(strFind, sPos + 1)
End While
Return foundCount
End Function
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
| | public static System.Collections.ArrayList CsvToArrayList1(string csvText)
{
System.Collections.ArrayList csvRecords =
new System.Collections.ArrayList();
csvText = csvText.Trim(new char[] {'\r', '\n'});
System.Text.RegularExpressions.Regex regLine =
new System.Text.RegularExpressions.Regex(
"^.*(?:\\n|$)",
System.Text.RegularExpressions.RegexOptions.Multiline);
System.Text.RegularExpressions.Regex regCsv =
new System.Text.RegularExpressions.Regex(
"\\s*(\"(?:[^\"]|\"\")*\"|[^,]*)\\s*,",
System.Text.RegularExpressions.RegexOptions.None);
System.Text.RegularExpressions.Match mLine = regLine.Match(csvText);
while (mLine.Success)
{
string line = mLine.Value;
while ((CountString(line, "\"") % 2) == 1)
{
mLine = mLine.NextMatch();
if (!mLine.Success)
{
throw new ApplicationException("不正なCSV");
}
line += mLine.Value;
}
line = line.TrimEnd(new char[] {'\r', '\n'});
line += ",";
System.Collections.ArrayList csvFields =
new System.Collections.ArrayList();
System.Text.RegularExpressions.Match m = regCsv.Match(line);
while (m.Success)
{
string field = m.Groups[1].Value;
field = field.Trim();
if (field.StartsWith("\"") && field.EndsWith("\""))
{
field = field.Substring(1, field.Length - 2);
field = field.Replace("\"\"", "\"");
}
csvFields.Add(field);
m = m.NextMatch();
}
csvFields.TrimToSize();
csvRecords.Add(csvFields);
mLine = mLine.NextMatch();
}
csvRecords.TrimToSize();
return csvRecords;
}
public static int CountString(string strInput, string strFind)
{
int foundCount = 0;
int sPos = strInput.IndexOf(strFind);
while (sPos > -1)
{
foundCount++;
sPos = strInput.IndexOf(strFind, sPos + 1);
}
return foundCount;
}
|
最後に紹介するのは、文字列を独自に解析する方法です。面倒ですが、これが一番速いかもしれません。
以下にその例を示します。使い方は先のCsvToArrayList1メソッドと同じです。
(テストが十分でないため、間違いがあるかもしれません。不具合を発見された方は、ぜひご報告ください。)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
| | Public Shared Function CsvToArrayList2(ByVal csvText As String) _
As System.Collections.ArrayList
csvText = csvText.Trim( _
New Char() {ControlChars.Cr, ControlChars.Lf})
Dim csvRecords As New System.Collections.ArrayList
Dim csvFields As New System.Collections.ArrayList
Dim csvTextLength As Integer = csvText.Length
Dim startPos As Integer = 0
Dim endPos As Integer = 0
Dim field As String = ""
While True
While startPos < csvTextLength _
AndAlso (csvText.Chars(startPos) = " "c _
OrElse csvText.Chars(startPos) = ControlChars.Tab)
startPos += 1
End While
If startPos < csvTextLength _
AndAlso csvText.Chars(startPos) = ControlChars.Quote Then
endPos = startPos
While True
endPos = csvText.IndexOf(ControlChars.Quote, endPos + 1)
If endPos < 0 Then
Throw New ApplicationException("""が不正")
End If
If endPos + 1 = csvTextLength OrElse _
csvText.Chars((endPos + 1)) <> ControlChars.Quote Then
Exit While
End If
endPos += 1
End While
field = csvText.Substring(startPos, endPos - startPos + 1)
field = field.Substring(1, field.Length - 2). _
Replace("""""", """")
endPos += 1
While endPos < csvTextLength AndAlso _
csvText.Chars(endPos) <> ","c AndAlso _
csvText.Chars(endPos) <> ControlChars.Lf
endPos += 1
End While
Else
endPos = startPos
While endPos < csvTextLength AndAlso _
csvText.Chars(endPos) <> ","c AndAlso _
csvText.Chars(endPos) <> ControlChars.Lf
endPos += 1
End While
field = csvText.Substring(startPos, endPos - startPos)
field = field.TrimEnd()
End If
csvFields.Add(field)
If endPos >= csvTextLength OrElse _
csvText.Chars(endPos) = ControlChars.Lf Then
csvFields.TrimToSize()
csvRecords.Add(csvFields)
csvFields = New System.Collections.ArrayList( _
csvFields.Count)
If endPos >= csvTextLength Then
Exit While
End If
End If
startPos = endPos + 1
End While
csvRecords.TrimToSize()
Return csvRecords
End Function
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
| | public static System.Collections.ArrayList CsvToArrayList2(string csvText)
{
csvText = csvText.Trim(new char[] {'\r', '\n'});
System.Collections.ArrayList csvRecords =
new System.Collections.ArrayList();
System.Collections.ArrayList csvFields =
new System.Collections.ArrayList();
int csvTextLength = csvText.Length;
int startPos = 0, endPos = 0;
string field = "";
while (true)
{
while (startPos < csvTextLength &&
(csvText[startPos] == ' ' || csvText[startPos] == '\t'))
{
startPos++;
}
if (startPos < csvTextLength && csvText[startPos] == '"')
{
endPos = startPos;
while (true)
{
endPos = csvText.IndexOf('"', endPos + 1);
if (endPos < 0)
{
throw new ApplicationException("\"が不正");
}
if (endPos + 1 == csvTextLength || csvText[endPos + 1] != '"')
{
break;
}
endPos++;
}
field = csvText.Substring(startPos, endPos - startPos + 1);
field = field.Substring(1, field.Length - 2).Replace("\"\"", "\"");
endPos++;
while (endPos < csvTextLength &&
csvText[endPos] != ',' && csvText[endPos] != '\n')
{
endPos++;
}
}
else
{
endPos = startPos;
while (endPos < csvTextLength &&
csvText[endPos] != ',' && csvText[endPos] != '\n')
{
endPos++;
}
field = csvText.Substring(startPos, endPos - startPos);
field = field.TrimEnd();
}
csvFields.Add(field);
if (endPos >= csvTextLength || csvText[endPos] == '\n')
{
csvFields.TrimToSize();
csvRecords.Add(csvFields);
csvFields = new System.Collections.ArrayList(
csvFields.Count);
if (endPos >= csvTextLength)
{
break;
}
}
startPos = endPos + 1;
}
csvRecords.TrimToSize();
return csvRecords;
}
|
このようなコードを自分で書かなくても、すでに優秀なクラスが多く存在します。最後に、その内幾つかを以下に紹介しておきます。(上の2つが代表的なものです。)
○この記事の基になった掲示板のスレッド
- CSVファイルの総行数を取得したいです。 | 投稿者(敬称略) こつ, 小野@どっとねっとふぁん, こど。, 壱丸3, じゃんぬねっと
- CSV箕荷 | 投稿者(敬称略) kenta, 岡田 之仁, sas
- CSVファイルをデータグリットで表示 | 投稿者(敬称略) A, nepia
- CSVファイルについて | 投稿者(敬称略) Boo, 岡田 之仁
- CSVデータをODBCで削除するには? | 投稿者(敬称略) おすぎ, 深山
- テキストファイルを実行ファイルに取り込んで扱う方法 | 投稿者(敬称略) だまさい, Mike
- ArrayListの使い方 | 投稿者(敬称略) kyoro, Blue, 中博俊
- 多次元配列(二次元配列)の初期化について | 投稿者(敬称略) HOGE, java.lang.Nullpo
- Split | 投稿者(敬称略) SOMY, まどか, なおこ(・∀・)
DataTableや配列等をCSV形式のファイルとして保存する †
【質問】
DataTableや配列をCSV形式のファイルとして保存するにはどのようにすればよいでしょうか?
【解答】
ここでもCSV形式の規則は、先ほど紹介したものと同じとして、話を進めます。つまり、4,6の規則に従い、フィールドにカンマ、改行文字が含まれる場合は、ダブルクォートで囲みます。また5の規則に従い、フィールドにダブルクォートが含まれる場合は、これを2つのダブルクォートに置換して、ダブルクォートで囲みます。さらに規則7に従い、フィールドの前後にスペースがある場合も、ダブルクォートで囲みます。
このような方針により、DataTableをCSV形式のファイルに保存する例を以下に示します。フィールドの型を考慮せず、単純にToStringメソッドで文字列にして保存しています。ヘッダも書き込んでいますが、ヘッダの書き込みと、レコードの書き込みが全く同じコードになっており、コードとしてはあまり良くありません。あくまでサンプルということで、ご了承ください。
またこの例では、いちいち一つ一つのフィールドを調べてダブルクォートで囲むか調べていますが、これが面倒であれば、すべてのフィールドをダブルクォートで囲むようにしても結構です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| | Dim dt As DataTable = CType(DataGrid1.DataSource, DataTable)
Dim csvPath As String = "C:\test1.csv"
Dim enc As System.Text.Encoding = _
System.Text.Encoding.GetEncoding("Shift_JIS")
Dim sr As New System.IO.StreamWriter(csvPath, False, enc)
Dim colCount As Integer = dt.Columns.Count
Dim lastColIndex As Integer = colCount - 1
Dim i As Integer
For i = 0 To colCount - 1
Dim field As String = dt.Columns(i).Caption
If field.IndexOf(ControlChars.Quote) > -1 OrElse _
field.IndexOf(","c) > -1 OrElse _
field.IndexOf(ControlChars.Cr) > -1 OrElse _
field.IndexOf(ControlChars.Lf) > -1 OrElse _
field.StartsWith(" ") OrElse _
field.StartsWith(ControlChars.Tab) OrElse _
field.EndsWith(" ") OrElse _
field.EndsWith(ControlChars.Tab) Then
If field.IndexOf(ControlChars.Quote) > -1 Then
field = field.Replace("""", """""")
End If
field = """" + field + """"
End If
sr.Write(field)
If lastColIndex > i Then
sr.Write(","c)
End If
Next i
sr.Write(ControlChars.Cr + ControlChars.Lf)
Dim row As DataRow
For Each row In dt.Rows
For i = 0 To colCount - 1
Dim field As String = row(i).ToString()
If field.IndexOf(ControlChars.Quote) > -1 OrElse _
field.IndexOf(","c) > -1 OrElse _
field.IndexOf(ControlChars.Cr) > -1 OrElse _
field.IndexOf(ControlChars.Lf) > -1 OrElse _
field.StartsWith(" ") OrElse _
field.StartsWith(ControlChars.Tab) OrElse _
field.EndsWith(" ") OrElse _
field.EndsWith(ControlChars.Tab) Then
If field.IndexOf(ControlChars.Quote) > -1 Then
field = field.Replace("""", """""")
End If
field = """" + field + """"
End If
sr.Write(field)
If lastColIndex > i Then
sr.Write(","c)
End If
Next i
sr.Write(ControlChars.Cr + ControlChars.Lf)
Next row
sr.Close()
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
| | DataTable dt = (DataTable) dataGrid1.DataSource;
string csvPath = "C:\\test1.csv";
System.Text.Encoding enc =
System.Text.Encoding.GetEncoding("Shift_JIS");
System.IO.StreamWriter sr =
new System.IO.StreamWriter(csvPath, false, enc);
int colCount = dt.Columns.Count;
int lastColIndex = colCount - 1;
for (int i = 0; i < colCount; i++)
{
string field = dt.Columns[i].Caption;
if (field.IndexOf('"') > -1 ||
field.IndexOf(',') > -1 ||
field.IndexOf('\r') > -1 ||
field.IndexOf('\n') > -1 ||
field.StartsWith(" ") || field.StartsWith("\t") ||
field.EndsWith(" ") || field.EndsWith("\t"))
{
if (field.IndexOf('"') > -1)
{
field = field.Replace("\"", "\"\"");
}
field = "\"" + field + "\"";
}
sr.Write(field);
if (lastColIndex > i)
{
sr.Write(',');
}
}
sr.Write("\r\n");
foreach (DataRow row in dt.Rows)
{
for (int i = 0; i < colCount; i++)
{
string field = row[i].ToString();
if (field.IndexOf('"') > -1 ||
field.IndexOf(',') > -1 ||
field.IndexOf('\r') > -1 ||
field.IndexOf('\n') > -1 ||
field.StartsWith(" ") || field.StartsWith("\t") ||
field.EndsWith(" ") || field.EndsWith("\t"))
{
if (field.IndexOf('"') > -1)
{
field = field.Replace("\"", "\"\"");
}
field = "\"" + field + "\"";
}
sr.Write(field);
if (lastColIndex > i)
{
sr.Write(',');
}
}
sr.Write("\r\n");
}
sr.Close();
|
このような方法以外に、掲示板では、XSLTを使ってCSVに変換する方法が紹介されています。この方法については、記事の最後にある掲示板の過去ログへのリンク(「DatasetのデータをCSV保存する」)や、次のリンク先をご覧ください。
私個人の意見としましては、このような方法では上記に示したCSVの規則に従うファイルを出力するのが難しく、柔軟性にも乏しいため、お勧めはできません。
○この記事の基になった掲示板のスレッド
コメント †