ASP.NET Core 1.0 / 1.1で SHIFT_JIS / UTF-8 BOM付きの CSV file を提供する

SoR (System of Record) なシステムを開発していると、CSV ファイルのダウンロード機能を実装する機会があるでしょう。今回は、最新の ASP.NET Core 環境下で SoR なシステムを開発することになった方が、やっぱり CSV なんすかね、そうですよね、CSV 難しいんですよ、という話になった際に、スムースにことを済ませるための要点を、ASP.NET Core を実際に使いながら洗い出してみたいと思います。

本記事は、 http://qiita.com/advent-calendar/2016/asp-net の4日目です。

納期に追われながらググってえいやえやとやってると、出来た!ってあたりで客先の Excel に取り込んでハタと気が付くわけです。UTF-8 がデフォ的な世界で出力した CSV file なので、勢いで取り込んだ結果、切ない感じになります。

切ない感じの例。CSV ファイルダブルクリックで Excel で直接開くの図。

これですね。

原因は明確。出力されたファイルが UTF-8 BOM無し の CSV ファイルなので、日本語環境の Excel さんは文字コード判定を間違ってしまい文字化けします。

ふむ。

あるべき論で言えば、 Database 側をはじめ全ての処理系において格納されている文字がどのような内容で入っているのか、そして過去のデータも含めコード体系として何を選択した上で、業務処理系全体としてどう管理しているのかを考えるべきではあります。ですが、ぶっちゃけ、そんなことを言ってられるシステムばかりではありません。我々は「知らない先人の歴史」という分厚い壁を乗り越える覚悟も、時として必要です。迷わず、先へ先へ進みましょう。歩みを止めてもペイするような単価の仕事かは、常に考えるべきです。

ざっくりですが、日本語環境下の業務の歴史で言えば、Windows登場以降のシステムであれば SHIFT_JIS で動いてる事が大半でしょう(EBCDICとか言わないの)。今回は、SHIFT_JIS でのCSV提供をゴールとし、ついでに UTF-8 BOM 付きも視野に、 途中失敗しながら CSV file のダウンロード機能を勢いで作ります。勢い大事です。

環境は以下の通り。環境の作り方は省略します。ダウンロードはこのへんから。

  • Visual Studio 2015 Update 3
  • .NET Core 1.0.1 / .NET Core 1.1 Runtime
  • DotNetCore.1.0.1-VS2015Tools.Preview2.0.3.exe
  • WebAPIContrib.Core at github

今回は .csproj な Visual Studio 2017 RC は使いません。いくつかの検証プロジェクトで既存の project.json なそれを Migrate しようと試みて早々に心が折れました。一旦忘れましょう。

また、CSV 化する際のライブラリでシンプルで改造しやすいヤツとして WebAPIContrib.Core を使います。これ、たまたま急ぎで選んでたら出てきてくれたので使ってるだけです。別にコードが公開されていればなんでもいいです。

https://github.com/JoshClose/CsvHelper というのもあって List<T> を捌くようなコードを書いてあげないといけないですが、いろいろと楽そうです。 CsvHelper 使う場合でも、やる事は同じです。エンコーディング追加して、TextWriter で SHIFT_JIS で書けばいいはず。 add 12/4/2016

ASP.NET Core で CSV ダウンロード

まずプロジェクト作りましょう。えいやです。

go! go! go!
no changes.
いつもの画面ですね。

で、いきなりなんですが、WebAPIContrib.Core を NuGet で入れずに、 GitHub リポジトリからファイルをもってきて、ソリューションに追加します。改造する必要があるので、しょうがないですね。別にプロジェクト参照してもらってもかまいませんが、今回は追加しちゃいます。

具体的には、以下のプロジェクトの .cs ファイルを拾ってきて配置します。
 https://github.com/WebApiContrib/WebAPIContrib.Core/tree/master/src/WebApiContrib.Core.Formatter.Csv

  • CsvFormatterMvcBuilderExtensions.cs
  • CsvFormatterOptions.cs
  • CsvInputFormatter.cs (これ upload 時の処理用なので本来は不要)
  • CsvOutputFormatter.cs

こんなノリです。

雑ですが気にしない。

これを呼び出すためのコード修正ですが、 GitHub のリポジトリに手順があるのですが古いです。動かすための最小限でいきます。

Statup.cs に以下を追加します。

services.AddMvc()
.AddCsvSerializerFormatters(new CsvFormatterOptions
{
UseSingleLineHeaderInCsv = true,
CsvDelimiter = ",",
});

UseSingleLineHeaderInCsv = true で、あとあとわかりますが変換元のクラスのメンバー名をヘッダーにしてくれるようです。しれっと設定。デリミタはやっぱり , ですよね。既定値は ; になってます。

HomeController.cs に手をいれます。これまた雑ですが /Home/GetCsv というリンクをクリックしたらダウンロードするような感じでいきましょう。

[Produces("text/csv")]
public async Task<IActionResult> GetCsv()
{
// Produces でなく以下でもいい
// Response.ContentType = "text/csv";
Response.Headers.Add(
new KeyValuePair<string, StringValues>(
"Content-Disposition",
new StringValues(@"attachment; filename=""data_"
+ RuntimeInformation.OSDescription
+ @"_.csv""")));
return Ok(GetData());
}
private static IEnumerable<CsvLayout> GetData()
{
return new List<CsvLayout>
{
new CsvLayout
{
RowId = 0,
日本語列 = "日本語、あいうabcシーエスブイ 。",
date_time_0 = DateTime.MinValue,
date_time_1 =
DateTime.MinValue.ToString("yyyy/MM/dd HH:mm:ss"),
},
new CsvLayout
{
RowId = 1,
日本語列 = "さろげ𩸽𩸽𩹉さろげ",
date_time_0 = DateTime.MaxValue,
date_time_1 =
DateTime.MaxValue.ToString("yyyy/MM/dd HH:mm:ss"),
}
};
}
public class CsvLayout
{
public int RowId { get; set; }
public string 日本語列 { get; set; }
public DateTime date_time_0 { get; set; }
public string date_time_1 { get; set; }
}

class CsvLayout は、別に何でもかまいません。1階層の平べったいものであれば動くと思います。メンバー名が CSV のヘッダーになりますので、試しに日本語をぶち込んでみます。int と string と DateTime でどんなアウトになるか、ざっと見てみましょ。

ついでに、ダウンロードする際のファイル名に OS の詳細をぶち込んでみましょう。変な文字入らないといいなぁ。。

実務として動かすのであれば、 IEnumerable<T> を返す先での先で DB からデータを取得する感じにはずで、そのあたり意識して aiwat 使ってもないのに async Task<IActionResult> とか書いてますがそれはそれで。Content-Disposition とか見ると、昔の IE の苦渋を思い出しますが気にしない。稼働単価を考えるんだ!

で次。View 直しましょう。

不要な About.cshml 他は消してる。

ざっくりこんなノリで十分です。

@{
ViewData["Title"] = "Home Page";
}
<div class="row" style="margin-top: 16px;">
<div class="col-md-12">
<a asp-controller="Home" asp-action="GetCsv">GetCsv</a>
</div>
</div>

レッツデバッグ。なんとなくデバッグログの見やすさで最近 IIS Express を使わないのですが、そのわりに IIS とかその系はホント偉いよなぁ、と、いつも感心します。関係ないですが。

ブラウザが起動するので、表示されたらリンクをクリック!

click! click!
コンピュータ名が NSX ですが NSX 欲しいのです。以前からの大ファンです。

検証してる端末環境が Windows 10 の IP Build 14971 なので、そんなノリのファイル名になってます。保存して開きます。

何も考えずにダブルクリックしましょう。

そういうノリが、利用者の気持ちになったテストとして大切です。間違ってもエディタで開くなんぞ最初からやってはいけません。初心忘れずべからず。

うん。知ってた。

そこに全角がいたのね。

こんなんです。UTF-8 BOM 無し の威力。で、ここから直します。えいえいおー!

まず BOM 付きにトライ。処理系が全て UTF-8 ならこれで。(寿司の話とかは当方スコープ外です)

CsvOutputFormatter.cs に手を入れます。

// var streamWriter = new StreamWriter(response.Body); // ここを以下に
var streamWriter = new StreamWriter(
response.Body, new UTF8Encoding(true));

BOM付きを指定してあげるだけです。簡単。デバッグ実行して、ファイルを開いてみましょ。

UTF-8 BOM 付き

でけた。よかった。西暦1年のデータは、Excel が日付変換出来ないからですかね、標準セルとして認識されていて左寄せ、一方で 西暦9999年はユーザ定義で yyyy/m/d h:mm になってるのも、毎度な感じですがまぁそんなところです。

次に SHIFT_JIS です。

.NET Core で shift_jis と言えば、” System.Text.Encoding.CodePages” だそうです。NuGet で追加してあげてください。project.json に以下追記でもいいです。

"System.Text.Encoding.CodePages": "4.3.0"

で、どこでもいいのですが、1回だけ呼ばれそうなところに以下を追記します。

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

例えば Startip.cs の以下あたりとか。

Program.cs でもいいんですが、どこに何書いたら忘れますよね。こういうの。困る。

また CsvOutputFormatter.cs に手を入れます。

// var streamWriter = new StreamWriter(response.Body); // ここを以下に
var streamWriter = new StreamWriter(
response.Body,
Encoding.GetEncoding("shift_jis",
new EncoderReplacementFallback("□"),
DecoderFallback.ReplacementFallback));

SHIFT_JIS にエンコード出来ない文字の場合に、何に置き換えるかを指定することができます。既定値だと ?? な感じになるのですが、今回は豆腐らしくしてみます。レッツ・デバッグ!

SHIFT_JIS です。

いい感じに化けてますが、確かに SHIFT_JIS な CSV になりました。業務処理系、DB側が SHIFT_JIS ならこれでいきたいところ。

このまま勢いで使ってしまいたいところですが、残念がらの CSV です。改行コード入ってる場合とか、 ," をどうするか考えないといけません。

Excel っぽくぽく、それらしくするなら、

  • 文字列なら無条件に " " で囲む
  • """ でエスケープる
  • 文字列内の改行コードは LF にする

あたりだと思うのですが、WebApiContrib.Core.Formatter.Csv の実装は以下で、

  • 文字列評価した場合に , があるケースでのみ " で囲む
  • \r と \n は 半角スペース" "に置き換える

ちょっとだけ調整してみて、どこまで逃げられるかを確認しておきます。

またまた CsvOutputFormatter.cs に手を入れます。

var vals = obj.GetType().GetProperties().Select(
pi => new {
Value = pi.GetValue(obj, null),
Type = pi.PropertyType // ここ追加して
}
);

メンバーの型を拾って、その下にある判定を調整。雑ですが。

//Check if the value contans a comma and place it in quotes if so
//if (val.Type == typeof(string) || _val.Contains(","))
// val.Type == typeof(string) を判定に追加
if (val.Type == typeof(string) || _val.Contains(","))
_val = string.Concat("\"", _val, "\"");

string の場合は無条件に、または、値に , があったら囲います。

レッツ・デバッグ!

string の値が囲まれました。この方法で、型を見て一括で振る舞いを変えられるので、 decimal に全て 1234.0000 と小数点が入るところを、おりゃーと小数点カットとかできます。うん微妙ですが。この方法で、 " をえいやとエスケープする事も可能でしょう。私は、データに入る値の範囲を見て、CsvLayout 用の型に入れ直すときに自前でエスケープしましたけど。

.NET Core 1.1 化

続きのネタの前振りもあって無理矢理 1.1 化します。ここの手順通りで、簡単です。まず global.json を調整します。これですね。

"version": "1.0.0-preview2-003131"

を、

"version": "1.0.0-preview2-1-003177"

にします。で、 project.json を開いて、netcoreapp1.0 を以下のように 1.1 に、

"frameworks": {
"netcoreapp1.1": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},

Microsoft.NETCore.App1.0.11.1.0 にします。

"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.1.0",
"type": "platform"
},

変更を保存するとパッケージの復元が走りますので、その後に、NuGet でがっつりアップデートしちゃいましょう。

ここから

BundlerMinifier.Core だけ残しますが、以前これをアップデートしたら、どうでもいいところでエラーが出て大変に時間をつぶしたので、今回は忘れずに「スルー」します。知らんですもう。

global.json を見れば、まるっとアップデートされてます。良かったヨカッタ。

今回の開発環境は、 .NET Core 1.0 と 1.1 の Runtime が入ってますので、レッツ・デバッグ!で、特に困る事なく走ります。しれっと走ってしまうと実感が沸きませんが、動いてるプロセスを見る限り、呼び出してるライブラリが 1.1.0 とかになってるので大丈夫なんでしょう。

今回のコードはこちらに。 https://github.com/arichika/AspNetCoreDeCsv

整理としては、

  • CSV が出てきたら身構えろ
  • MSBuild 化したら使えない手順は多いね
  • 文字コード変えるの簡単だけどDBのデータ見ないとね

あたりでせうか。

温故知新大好きな @arichika がお送りしました。

続きものです。次回は・・・

このプロジェクトを使って、Azure App Service の Web App (Windows) と App Service on Linux の Web Apps for Linux で Web App on Linux の双方で動かしてみて、業務系でやりがちな「あるある」を試してみたいと思います。

App Service on Linux だけど、 Docker コマンドとか使わないから安心して!