yotiky Tech Blog

とあるエンジニアの備忘録

C# - ZIP を操作する

目次

検証環境

  • .NET Core 3.1
  • LINQ Pad 6

ZIP を操作する

.NET Core で ZIP を操作するには、ZipFileZipArchiveZipArchiveEntry などを使う。

ZipFile はパスを必要としディスクへの読み書きに使う。

ZipArchive は初期化に Stream を必要とするため、メモリ上で操作するのに向いている。

ZipArchiveEntry は ZIP に含まれるファイルもしくは空のフォルダの情報を表す。

以降のサンプルコードでは次の変数を使用する。 また実行および出力結果は LINQPad で行っている。

// 圧縮する対象フォルダ
var targetDir = @"C:\ZipTest\ParentDir";
// 圧縮先
var zipPath = @"C:\ZipTest\test.zip";
// 展開先
var extractPath = @"C:\ZipTest\test";

targetDir 配下の構成は以下の通りになっている。

f:id:yotiky:20210309044430p:plain

圧縮する

ZipFile.CreateFromDirectory(targetDir, zipPath);

引数でエンコードを指定しない場合、 フォルダ名およびファイル名が ASCII 文字のみで構成されているとシステムのデフォルトコードページが使用される。 ASCII 文字以外を含む場合は、UTF-8 が使用される。

docs.microsoft.com

展開する

ZipFile.ExtractToDirectory(zipPath, extractPath);

引数でエンコードを指定した場合も指定しない場合も、ファイルヘッダーにフラグが設定されている場合、UTF-8 を使用してデコードされる。

ファイルヘッダーにフラグが設定されていない場合は、引数でエンコードを指定すると指定したエンコードでデコードされる。エンコードを指定しないとシステムのデフォルトコードページが使用される。

docs.microsoft.com

一般的に Windows では Shift-JISLinuxMac OS などでは UTF-8 が使用されるため、外部のツールなどで圧縮を行った場合は注意が必要である。

ヘッダーを直接読めれば UTF-8 かそれ以外かの判定ができる?かも?)

ZIP 内のファイルを列挙する

using (var archive = ZipFile.OpenRead(zipPath))
{
    foreach (var entry in archive.Entries)
    {
        entry.FullName.Dump();
    }
}

出力結果は次の通り。

f:id:yotiky:20210309044826p:plain

空のフォルダはフォルダ単体で ZipArchive.Entries プロパティに含まれるが、ファイルを配下に持つフォルダはフォルダ単体では含まれない。フォルダは末尾に / が付く。

一部だけ展開する

using (var archive = ZipFile.OpenRead(zipPath))
{
    var fileEntry = archive.GetEntry("SubDir/SubDirMemo1.txt");
    fileEntry.ExtractToFile(Path.Combine(extractPath, "SubDirMemo1_copy.txt"), true);

    var dirEntry = archive.GetEntry("EmptyDir/");
    dirEntry.ExtractToFile(Path.Combine(extractPath, "EmptyDir_copy"), true);
}

ファイルを持つフォルダはエントリーの取得ができないため、フォルダを指定して展開することはできない。また、空のフォルダのエントリーは取得できてもファイルではないので展開できない。空のファイルとして書き出される。

f:id:yotiky:20210309072204p:plain

ファイルを削除する

using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Update))
{
    var fileEntry = archive.GetEntry("SubDir/SubDirMemo2.txt");
    fileEntry.Delete();
}

f:id:yotiky:20210309072556p:plain f:id:yotiky:20210309072452p:plain

ファイルを追加する

using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Update))
{
    archive.CreateEntryFromFile(Path.Combine(targetDir, "Memo1.txt"), "AddedDir/Memo1_copy.txt");
}

f:id:yotiky:20210309072556p:plain f:id:yotiky:20210309072813p:plain

展開せずにテキストファイルの内容を取得する

using (var archive = ZipFile.OpenRead(zipPath))
{
    var fileEntry = archive.GetEntry("Memo1.txt");
    using (var reader = new StreamReader(fileEntry.Open()))
    {
        reader.ReadToEnd().Dump();
    }
}

サンプルコード全文

// 圧縮する対象フォルダ
var targetDir = @"C:\ZipTest\ParentDir";
// 圧縮先
var zipPath = @"C:\ZipTest\test.zip";
// 展開先
var extractPath = @"C:\ZipTest\test";

if (File.Exists(zipPath))
    File.Delete(zipPath);
if (Directory.Exists(extractPath))
    Directory.Delete(extractPath, true);

// 圧縮
ZipFile.CreateFromDirectory(targetDir, zipPath);
// 展開
ZipFile.ExtractToDirectory(zipPath, extractPath);

// ファイルの列挙
using (var archive = ZipFile.OpenRead(zipPath))
{
    foreach (var entry in archive.Entries)
    {
        // 空のフォルダは含まれるが、ファイルを持つフォルダはフォルダ単体としては含まれない
        entry.FullName.Dump();
    }
}

// 一部だけ展開する
using (var archive = ZipFile.OpenRead(zipPath))
{
    var fileEntry = archive.GetEntry("SubDir/SubDirMemo1.txt");
    fileEntry.ExtractToFile(Path.Combine(extractPath, "SubDirMemo1_copy.txt"), true);
    // 空のフォルダは取得できてもファイルではないので展開できない(空のファイルとして書き出される)
    var dirEntry = archive.GetEntry("EmptyDir/");
    dirEntry.ExtractToFile(Path.Combine(extractPath, "EmptyDir_copy"), true);
}

using (var archive = ZipFile.OpenRead(zipPath))
{
    archive.Entries.Select(x => x.FullName).Dump("元のZIP");
}
    
// ファイルを削除する
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Update))
{
    var fileEntry = archive.GetEntry("SubDir/SubDirMemo2.txt");
    //fileEntry.Delete();

    archive.Entries.Select(x => x.FullName).Dump("削除後のZIP");
}

// ファイルを追加する
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Update))
{
    archive.CreateEntryFromFile(Path.Combine(targetDir, "Memo1.txt"), "AddedDir/Memo1_copy.txt");
    archive.Entries.Select(x => x.FullName).Dump("追加後のZIP");
}

// 展開せずにテキストファイルの内容を取得する
using (var archive = ZipFile.OpenRead(zipPath))
{
    var fileEntry = archive.GetEntry("Memo1.txt");
    using (var reader = new StreamReader(fileEntry.Open()))
    {
        reader.ReadToEnd().Dump();
    }
}

ZipArchive

Form に ZIP ファイルを添付して送られてきた場合や、Azure Blob Storage から ZIP ファイルをメモリにダウンロードした場合など、ZIP ファイルのデータを Stream として取得できる場合は ZipAcrhive を直接使う。

using (var stream = form.Files[0].OpenReadStream())
using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, false, Encoding.GetEncoding("Shift_JIS")))
{
}
using (var stream = new MemoryStream())
{
    await blobClient.DownloadToAsync(stream);
    using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, true, Encoding.GetEncoding("Shift_JIS")))
    {
    }
}

第3引数の leaveOpen は、ZipArchiveDispose した後も Stream を開いたままにするかどうかのフラグ。

参考

.NET Core で Shift-JIS エンコードを扱う

目次

検証環境

  • .NET Core 3.1
  • System.Text.Encoding.CodePages 5.0.0

実装

NuGet で System.Text.Encoding.CodePages をインストールする。

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var enc = Encoding.GetEncoding("Shift_JIS");

補足

Encoding.Default

Encoding.Default でシステムのデフォルトエンコーディングを取得できるが、.NET Framework .NET Core 以降では値が変わる。

.NET Framework では Shift_JIS であったが、 .NET Core では UTF-8 となる。

docs.microsoft.com

UTF-8

Encoding.UTF8 で取得する Encoding は BOM 付きとなる。

new UTF8Encoding() で引数 encoderShouldEmitUTF8Identifierfalse を設定した場合は BOM なしとなる。デフォルトが false のため、引数なしで初期化しても同様である。

Encoding.Default で取得する UTF-8Encoding は BOM なしである。

StreamWriter でそれぞれのエンコードを指定して書き出した時のバイト列は次の通り。 「EF-BB-BF-」がBOMの部分。

f:id:yotiky:20210309043457p:plain

Encoding.Default
E3-83-91-E3-83-B3-E3-81-AE-E8-80-B3

Encoding.UTF8
EF-BB-BF-E3-83-91-E3-83-B3-E3-81-AE-E8-80-B3-0D-0A

UTF8Encoding(encoderShouldEmitUTF8Identifier:false)
E3-83-91-E3-83-B3-E3-81-AE-E8-80-B3-0D-0A

docs.microsoft.com

参考

Azure Functions で アップロードした ZIP ファイルの中身を列挙する

目次

検証環境

  • Azure Functions v3

実装

using System.IO.Compression;
foreach (var file in form.Files)
{
    using (var stream = file.OpenReadStream())
    using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, true))
    {
        foreach (var entry in zip.Entries)
        {
            log.LogInformation(entry.FullName);
        }
    }
}

実行結果は以下の通り。

[2021-03-08T11:50:37.540Z] ParentDir/Memo1.txt
[2021-03-08T11:50:37.542Z] ParentDir/SubDir/SubDirMemo1.txt
[2021-03-08T11:50:37.542Z] ParentDir/SubDir/SubDirMemo2.txt

エンコード

zip ファイル内に日本語のフォルダまたはファイルが含まれる場合は注意が必要。 一般的に Windows 環境で圧縮した場合は Shift-JIS が使用され、Unix 系や Mac などで圧縮した場合は UTF-8 が使用される。

.NET Core で、Shift-JIS を使用する場合、NuGetで System.Text.Encoding.CodePages をインストールする必要がある。

また、Azure Functions の SDK では、Function Runtime が持っているアセンブリを除外するため、そのままだと例外が発生して動かない。 Azure Functions で上記パッケージを使う場合は csproj に以下の設定を追加する。

<PropertyGroup>
    <_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
</PropertyGroup>

ZipArchive のコンストラクタでエンコードを指定する。

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

using (var stream = file.OpenReadStream())
using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, true, Encoding.GetEncoding("Shift_JIS")))
{
}

yotiky.hatenablog.com

参考

Azure Functions INDEX

Azure Functions 備忘録

目次

AuthorizationLevel

説明
Anonymous API キーは必要ありません。
User *EasyAuth で使われる予定らしいが未だサポートされていないらしい
Function 関数固有の API キーが必要です。 何も指定されなかった場合は、これが既定値になります。
System
Admin マスター キーが必要です。

リクエストのサイズ制限

HTTP 要求の長さは 100 MB (104,857,600 バイト) に、URL の長さは 4 KB (4,096 バイト) バイトに制限されています。 これらの制限は、ランタイムの Web.config ファイルの httpRuntime 要素で指定されています。

Windows ローカルで実行した場合は 30MB で制限されてしまう不具合があるとかないとか。

Azure Functions で 複数ファイルをアップロードする

目次

検証環境

サーバーサイド

クライアントからは、multipart/form-data 形式で POST してもらう想定。

[FunctionName("Upload")]
public async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
    ILogger log)
{
    log.LogInformation("C# HTTP trigger function processed a request.");

    // UserAgent を取得するキーは "User-Agent"
    log.LogInformation(req.Headers["User-Agent"][0]);

    var form = await req.ReadFormAsync();

    foreach (var file in form.Files)
    {
        log.LogInformation(file.FileName);
    }
    foreach (var kvp in form)
    {
        log.LogInformation($"{kvp.Key}({kvp.Value.Count}): {kvp.Value}");
    }

    return new OkObjectResult("OK");
}

クライアントサイド

webkitdirectory 属性

webkitdirectory 属性を使用すると、ファイルを選択する代わりにフォルダを選択できるようになる。送信されたファイルのファイル名は相対パス付きのため、ディレクトリのツリー構造の情報も取得できるようになる。

developer.mozilla.org

<form action="http://localhost:7071/api/Upload", method="POST", enctype="multipart/form-data">
    BaseDir: <input type="text" name=projectId/><br/>
    Folder: <input type="file" name="files" webkitdirectory multiple/><br/>
    <input type="submit" value="Submit"/>
    <input type="reset" value="Reset"/>
</form>

f:id:yotiky:20210227015224p:plain

サーバーサイドの実行結果。
ファイル名が選択したフォルダを基点とした相対パスになっている。

[2021-02-26T17:46:19.298Z] C# HTTP trigger function processed a request.
[2021-02-26T17:46:19.320Z] Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
[2021-02-26T17:46:19.322Z] wallpapers/glanpalace1.jpg
[2021-02-26T17:46:19.322Z] wallpapers/photo1/aurora2.jpg
[2021-02-26T17:46:19.323Z] wallpapers/photo1/icelandlake2540.jpg
[2021-02-26T17:46:19.323Z] basedir/(1): wallpapers
[2021-02-26T17:46:19.325Z] Executed 'Upload' (Succeeded, Id=6a51b942-e294-401c-b344-b3395e53f9a5, Duration=140ms)

f:id:yotiky:20210227024639p:plain

Postman

Postman では form-data のファイル選択でフォルダを選択することができない。そのため複数ファイルを選択する場合ツリー構造の情報は除外される。そもそもひとつの Key-Value には同じ階層のファイルしか含めることができない。階層が違うファイルは別のプロパティとして設定する必要がある。 特にツリー構造の情報が必要ない場合はファイルだけを、ツリー構造の情報が必要な場合は別途その情報を設定して送信する。

f:id:yotiky:20210227012709p:plain

typeTextFile が選べる。File は1つでも複数でも選択できるようになっている。 File の Key-Value は複数定義すると1つの配列にまとめられて送信されるため、階層が違うファイルを選択する場合はそれぞれの階層に応じた Key-Value を増やせば一括で送信できる。

f:id:yotiky:20210227012748p:plain

サーバーサイドの実行結果。

[2021-02-26T17:45:20.643Z] C# HTTP trigger function processed a request.
[2021-02-26T17:45:20.676Z] PostmanRuntime/7.26.5
[2021-02-26T17:45:20.676Z] glanpalace1.jpg
[2021-02-26T17:45:20.677Z] aurora2.jpg
[2021-02-26T17:45:20.677Z] icelandlake2540.jpg
[2021-02-26T17:45:20.678Z] basedir(1): wallpapers
[2021-02-26T17:45:20.679Z] filePaths(3): glanpalace1.jpg,photo1/aurora2.jpg,photo1/icelandlake2540.jpg
[2021-02-26T17:45:20.689Z] Executed 'Upload' (Succeeded, Id=5172d86a-1074-40bb-a3d7-f4c793d35157, Duration=265ms)

f:id:yotiky:20210227024533p:plain

Azure Functions で Swagger UI

NSwag は NSwag.SwaggerGeneration.AzureFunctionsV2 が更新されておらず、V3ではエラーが出て動かなかったため、Swashbuckle を使用する。

目次

検証環境

  • Azure Functions v3
  • AzureExtensions.Swashbuckle v3.2.2

古いライブラリに注意

  • AzureFunctions.Extensions.Swashbuckle は更新されておらずエラーが出て動かない

github.com

実装

NuGet でライブラリをインストールする。

Statup クラスを追加する。

using AzureFunctions.Extensions.Swashbuckle;
...

[assembly: FunctionsStartup(typeof(Startup))]

namespace FunctionApp1
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.AddSwashBuckle(Assembly.GetExecutingAssembly());
        }
    }
}

Open API と Swagger UI の Functions を追加する。

public static class SwaggerFunctions
{
    [SwaggerIgnore]
    [FunctionName("Swagger")]
    public static Task<HttpResponseMessage> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "Swagger/json")] HttpRequestMessage req,
        [SwashBuckleClient] ISwashBuckleClient swashBuckleClient)
    {
        return Task.FromResult(swashBuckleClient.CreateSwaggerDocumentResponse(req));
    }

    [SwaggerIgnore]
    [FunctionName("SwaggerUi")]
    public static Task<HttpResponseMessage> Run2(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "Swagger/ui")] HttpRequestMessage req,
        [SwashBuckleClient] ISwashBuckleClient swashBuckleClient)
    {
        return Task.FromResult(swashBuckleClient.CreateSwaggerUIResponse(req, "swagger/json"));
    }
}

対象の Functions に属性をつける。

public class Function1
{
    [QueryStringParameter("name", "User Name", DataType = typeof(string), Required = false)]
    [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
    [ProducesResponseType((int)HttpStatusCode.InternalServerError, Type = typeof(Error))]
    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        string responseMessage = string.IsNullOrEmpty(name)
            ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
            : $"Hello, {name}. This HTTP triggered function executed successfully.";

        return new OkObjectResult(responseMessage);
    }
}
public class Error
{
    public string Title { get; set; }

    public string Description { get; set; }
}

http://localhost:7071/api/Swagger/ui で Swagger UI にアクセス。

f:id:yotiky:20210224092449p:plain

参考

ASP.NET Core の場合に参考になりそうな記事。