WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

JavaScript の async / await

by WebSurfer 25. November 2022 18:50

JavaScript の async / await の使い方について思い違いをしていました。また同じミスを犯さないように、どういうことだったかを以下に書いておきます。

例として、GET 要求を受けると 2 秒後に以下の JSON 文字列を返す Web API から fetch を使って応答を取得するサンプルを書きました。

{"id":5,"name":"スライムマン"}

JavaScript のコードは以下のようにし、ボタンクリックなどのイベントで asyncTest を実行します。

const fetchHero5 = async () => {
    const response = await fetch("/api/values/5")
    const data = await response.json();
    console.log(data.name);
};

const writeLog = () => {
    console.log("writeLog の実行");
}

// 同期的に順番に実行される
function asyncTest()
{
    fetchHero5();
    writeLog();
}

fetchHero5 の中で await で待機しているので、asyncTest の中の fetchHero5 の呼び出しでまず Web API からの応答の "スライムマン" がコンソールに書き込まれ、その後 writeLog が呼び出されて "writeLog の実行" が書き込まれると思っていたのですが、そうではなかったです。

結果は以下の通りで、思っていたのと逆の順序でした。

コンソール書き込み

asyncTest は同期関数でその中の fetchHero5() と writeLog() は同期的に実行されます。つまり、同期的に順番にまず fetchHero5 が呼ばれ、fetchHero5 の完了後 writeLog が呼ばれるという動きになります。

ここで、fetchHero5 は async が付与された非同期関数であるということが、自分が思っていた結果と違ったことになった原因のようです。

現代の JavaScript チュートリアル の記事 Async/await に "async は関数が promise を返すことを保証し、非 promise をその中にラップします" という説明があります。

ということは、fetchHero5 が呼ばれると即 Promise が返され、その時点で fetchHero5 は完了とみなされて、次の writeLog が呼ばれてコンソールに "writeLog の実行" と書き込んだということのようです。

その後、Web API からの応答の処理は fetchHero5 から返された Promise が行うようです。Promise オブジェクトが fetch で Web API を呼んで 2 秒後に応答が返ってきてから "スライムマン" と書き込んだということだと思います。

fetchHero5() で "スライムマン" と書き込んだ後で、writeLog() で "writeLog の実行" と書き込む(上の画像の順序を反対にする)には、asyncTest に async を付与してを非同期関数にし、その中の fetchHero5 に await を付与してそこで待機するようにします。

すなわち以下のようなコードにします。

async function asyncTest()
{
    await fetchHero5();
    writeLog();
}

結果は以下の通りとなります。

コンソール書き込み

上のコード例で、一番下位の関数 fetch, json は Promise を返します。その上位の関数 fetchHero5 も async が付与されているので Promise を返します。そういう形で Promise が下位から上位に伝搬して行くので、上のコード例でのように最上位の関数 asyncTest を非同期にして、そこで await を使って fetchHero5 の完了を待機すると期待したようになるということのようです。

.NET アプリの Microsoft のドキュメント「非同期プログラミングのベスト プラクティス」には "上から下に (または下から上に) 非同期コードが他の非同期コードを呼び出す、または呼び出される場合に最もうまく機能する" ということがに書かれています。

それは JavaScript でも同じことなのかもしれません。

Tags: , , ,

JavaScript

JavaScript の非同期関数

by WebSurfer 23. November 2022 12:12

JavaScript の習得が遅々として進まない自分には、昔は無かった class とか => とか、さらには非同期関数とかが出てきて、Q&A サイトなどでの話題についていけない状況にあります。(汗)

というわけで、JavaScript の非同期についてちょっとだけ勉強してみました。以下に勉強したことを忘れないように書いておきます。

まず、非同期関数ですが、MDN のドキュメント「非同期関数」の説明が自分的には分かりやすいと思いました。

そのドキュメントによると "非同期関数は async キーワードで宣言され、その中で await キーワードを使うことができます" ということで、ドキュメントのサンプルコード(下に転載します)の asyncCall を非同期関数と呼ぶようです。

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  const result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: "resolved"
}

asyncCall();

非同期関数の利点は、"async および await キーワードを使用することで、プロミスベースの非同期の動作を、プロミスチェーンを明示的に構成する必要なく、よりすっきりとした方法で書くことができます" ということだそうです。

ということで、上で言う「プロミスベースの非同期の動作」すなわちコードに出てくる Promise, resolve が JavaScript における非同期関数の核心のようです。

まず、Promise というのは何かですが、自分的に分かりやすかったのは「現代の JavaScript チュートリアル」の Promise と「JavaScript Primer」の [ES2015] Promise という記事でした。(MDN のドキュメント「プロミスの使用」も読みましたがほとんど分かりませんでした)

記事に書いてあったことを要約して以下に書きます。(解釈が間違っているところもあるかも)

Promise は非同期処理の最終的な完了もしくは失敗を表すオブジェクトで、基本的に以下のように作成します。

let promise = new Promise((resolve, reject) => {
  // 処理
});

上の Promise コンストラクタの引数に渡している関数 (resolve, reject) => { // 処理 } は executor と呼ばれる関数で、resolve と reject という 2 つの引数を取ります。

resolve と reject は JavaScript に定義済みのコールバックで、以下のように executor の結果に応じていずれかを呼び出す必要があります。

  • resolve(value) – 正常に終了した場合。Promise の result プロパティを value に設定。
  • reject(error) – エラーが発生した場合。Promise の result プロパティを error に設定。

具体例は上のコードの resolveAfter2Seconds 関数を見てください。Promise オブジェクトを生成してそれを戻り値として返すようにしています。

asyncCall 関数が resolveAfter2Seconds 関数を呼ぶと、resolveAfter2Seconds 関数は Promise オブジェクトを生成して即座に戻り値として返します。

Promise コンストラクタの引数の executor 関数は new Promise の時点で自動的かつ即座に実行されます。

executor 関数内の setTimeout により 2 秒後に resolve('resolved') が呼ばれて、Promise オブジェクトの result プロパ��ィは 'resolved' という文字列に設定されます。

asyncCall 関数内で resolveAfter2Seconds 関数に await が付与されているところに注目してください。await よって、resolveAfter2Seconds が戻り値として返した Promise が resolve されるまで次の行の console.log(result); には進まず待機します。

さらに await によって Promise オブジェクトの result プロパティのオブジェクトが取り出されて渡されます。上のコード例では resolve('resolved') としているので 'resolved' という文字列が渡されます。

そのあたりは C# の非同期処理で使う Task<T>, async, await と同様なようです。

実際に使ってみると理解が深まると思って、先の記事「canvas の画像をアップロード (その 2)」の JavaScript のコードに非同期関数を適用してみました。そのコードを載せておきます。

input type="file" でファイルの選択が完了すると発生する change イベントのリスナを非同期関数式にして、その中で Promise を使った関数 CreateDataUrl と CreateImage を await を付与して呼び出すようにしています。詳しくはコード内のコメントを見てください。

@{
    ViewData["Title"] = "Canvas2";
}

<h1>Canvas2</h1>

<input type="file" id="file1" />
<input id="button1" type="button" value="Upload" style="display:none;" />
<br />
<div id="msg"></div>
<canvas id="mycanvas"></canvas>
<div id="result"></div>

@section Scripts {
    <script type="text/javascript">
        //<![CDATA[

        const maxFileSize = 500000;
        const allowedContentType = "image/jpeg";

        const maxWidth = 500;
        const maxHeight = 500;

        let inputFile, uploadButton, msgDiv, myCanvas, resultDiv;

        // input type="file" から取得できる File オブジェクトを引数
        // に渡す。FileReader オブジェクトを生成して readAsDataURL 
        // メソッドで Data url を生成し、resolve コールバックの引数
        // に設定する 
        const CreateDataUrl = file => {
            return new Promise(resolve => {
                const reader = new FileReader();
                reader.addEventListener('loadend', 
                                e => resolve(e.target.result));
                reader.readAsDataURL(file);
            });
        };

        // Data url を引数に渡す。Image オブジェクトを生成しその 
        // src に Data url を代入。ロード完了後の Image オブジェ
        // クトを resolve コールバックの引数に設定する
        const CreateImage = dataUrl => {
            return new Promise(resolve => {
                const img = new Image();
                img.addEventListener('load', 
                                     e => resolve(e.target));
                img.src = dataUrl;
            });
        };

        // DOMContentLoaded イベントで初期設定を行う
        window.addEventListener('DOMContentLoaded', () => {

            inputFile = document.getElementById("file1");
            uploadButton = document.getElementById("button1");
            msgDiv = document.getElementById("msg");
            myCanvas = document.getElementById("mycanvas");
            resultDiv = document.getElementById("result");

            if (window.File && window.FileReader && window.FileList) {

                uploadButton.addEventListener('click', uploadImage);

                // リスナを非同期関数式にして、リスナの中で Promise 
                // を使った関数 CreateDataUrl と CreateImage を 
                // await を付与して呼び出す。
                inputFile.addEventListener('change', async () => {
                    resultDiv.innerText = "";

                    if (ClientValidate(inputFile) == false) {
                        uploadButton.style.display = "none";
                        myCanvas.style.display = "none";
                        return;
                    }

                    //  選択された画像の Data url を取得
                    let dataUrl = 
                        await CreateDataUrl(inputFile.files[0]);

                    // 画像をロードした Image オブジェクトを取得
                    let img = await CreateImage(dataUrl);

                    // canvas に画像を描画
                    DrawImageOnCanvas(img);
                });
            }
            else {
                inputFile.style.display = "none";
                myCanvas.style.display = "none";
                msgDiv.innerText =
                    "File API がサポートされていません。";
            }
        });

        // 選択されたファイルの検証のためのヘルパ関数
        function ClientValidate(fileUpload) {
            msgDiv.innerText = "";

            if (fileUpload.files[0] == null) {
                msgDiv.innerText = "ファイルが未選択です。";
                return false;
            }

            if (fileUpload.files[0].type != allowedContentType) {
                msgDiv.innerText = "選択されたファイルのタイプが " +
                    allowedContentType + " ではありません。";
                return false;
            }

            if (fileUpload.files[0].size > maxFileSize) {
                msgDiv.innerText = "ファイルのサイズが " +
                    maxFileSize + " バイトを超えています。";
                return false;
            }

            return true;
        }

        // 画像が読み込み済みの Image オブジェクトを引数に受け取る。
        // その画像を指定されたサイズに縮小して canvas に描画
        function DrawImageOnCanvas(image) {
            // オリジナル画像のサイズ
            const w = image.width;
            const h = image.height;

            let targetW, targetH;
            const context = myCanvas.getContext('2d');

            if (w <= maxWidth && h <= maxHeight) {
                // w, h ともに制限 maxWidth, maxHeight 以内 ⇒
                // そのままのサイズで canvas に描画
                myCanvas.setAttribute('width', w);
                myCanvas.setAttribute('height', h);
                context.drawImage(image, 0, 0);
            }
            else if (w < h) {
                // w, h どちらかが制限オーバーで h の方が大きい ⇒
                // 高さを maxHeight に縮小
                targetH = maxHeight;
                // 幅は高さの縮小比率で縮小
                targetW = Math.floor(w * targetH / h);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }
            else {
                // w, h どちらかが制限オーバーで w の方が大きい ⇒
                // 幅を maxWidth に縮小
                targetW = maxWidth;
                // 高さは幅の縮小比率で縮小
                targetH = Math.floor(h * targetW / w);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }

            uploadButton.style.display = "inline-block";
            myCanvas.style.display = "block";
        }

        // canvas の画像データを DataURL 形式で取得し、JSON 文
        // 字列を組み立てて fetch API でサーバーに送信する
        async function uploadImage() {
            const context = myCanvas.getContext('2d');
            const dataUrl = context.canvas.toDataURL("image/jpeg");
            const params = {
                method: "POST",
                body: '{"imgBase64":"' + dataUrl + '"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch("/api/Canvas", params);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        }

        //]]>
    </script>
}

非同期関数がある程度理解できれば、先の記事「canvas の画像をアップロード (その 2)」のコードに比べてかなり読みやすくなったような気はします。

Tags: , , , , ,

JavaScript

canvas の画像をアップロード (その 2)

by WebSurfer 20. November 2022 11:46

先の記事「canvas の画像をアップロード」の機能を .NET 6.0 の ASP.NET Core MVC アプリに実装しました。以下に備忘録として書いておきます。

canvas の画像をアップロード

基本的には先の記事と同じことを行っており、クライアントの PC にある画像を HTML5 の File API を利用して取得し、それを指定のサイズに縮小して HTML5 の canvas に描画し、canvas に描画された画像をサーバーにアップロードして保存するというものです。

先の記事との違いは、(1) アプリが先の記事では ASP.NET Web Forms であったものがこの記事では ASP.NET Core MVC であること、(2) jQuery は使わないようにしたこと(なので、jQuery ajax に代えて fetch API を使ってます)、(3) アップロードされてきたファイルを受けるのを ASP.NET Core Web API のコントローラーとしたことです。

概略の動きは以下の通りです。詳しくは下のサンプルコードとそれに書いたコメントを参照ください。

  1. クライアントによる画像ファイルの選択は HTML の <input type="file" ... /> を利用する。
  2. <input type="file" ... /> で画像ファイルが選択されたタイミングで、HTML5 File API の FileReader オブジェクトに readAsDataURL メソッド を使って選択された画像ファイルを読み込む。
  3. FileReader の result プロパティ を使って、読み込んだ画像ファイルを Data url 形式("data:image/jpeg;base64, ..." という文字列)で取得し、それを image オブジェクトの src 属性に設定する。
  4. その image オブジェクトを HTML5 の canvas に CanvasRenderingContext2D.drawImage() メソッド を使って描画する。その際、描画する画像の最大サイズの制限を設け(今回のサンプルでは 500 x 500 とした)、それに入る場合はそのまま、入らない場合は幅・高さどちらか大きい方を 500px に縮小し他方をその縮小率と同じに縮小(要するに縦横比を保ったまま 500 x 500 に入るよう縮小)する。
  5. canvas 上の縮小後の画像データを取得して Web サーバーに fetch API を使って送信するメソッドを作る。canvas からの画像データの取得は HTMLCanvasElement.toDataURL() メソッド を用いる。Data url 形式で取得できるので、それを fetch API を用いて JSON 形式で送信する。(BASE64 でエンコードされているので、バイナリ形式よりサイズが約 1.3 倍大きくなってしまうが・・・)
  6. <input type="button" ... /> タイプのボタンを配置し、その onclick 属性に上記のメソッドを設定する。
  7. 非同期でクライアントから送信された BASE64 形式の画像データは、ASP.NET Core Web API のコントローラーで受け、デコードしてバイナリ形式に戻してファイルに保存する。
  8. 先の記事と同様に、画像ファイルのサイズを 500,000 bytes に、ファイルのタイプを image/jpeg に制限する機能も実装した。

上記を実現するための JavaScript のコードを実装した View のサンプルコードは以下の通りです。

@{
    ViewData["Title"] = "Canvas";
}

<h1>Canvas</h1>

<input type="file" id="file1" />
<input id="button1" type="button" value="Upload" style="display:none;" />
<br />
<div id="msg"></div>
<canvas id="mycanvas"></canvas>
<div id="result"></div>

@section Scripts {
    <script type="text/javascript">
    //<![CDATA[
        // 以下の操作を行っている:

        // File API の FileReader を利用して、input type="file" で
        // 選択された画像ファイルを読み込み、その画像の Data url 
        // 形式の文字列を取得。

        // そ��� Data url 形式文字列を image オブジェクトの src 属
        // 性に設定するとロード完了時に image.onload イベントが発
        // 生するので、そのイベントのリスナで image オブジェクトの
        // 画像を指定のサイズ以下に縮小して canvas に描画する。

        // [Upload] ボタンクリックで uploadImage メソッドを起動。
        // そのメソッド内で canvas の画像データを DataURL 形式で取
        // 得して JSON 文字列を組み立て、それを fetch API を使って
        // サーバーに送信する。

        // 上に述べた FileReader と image オブジェクトへの参照。
        // 初期化は下の DOMContentLoaded イベントのリスナで行う
        let fileReader;
        let image;

        // 画像のサイズとタイプの制限。ここではそれぞれ 500000 
        // バイト、image/jpeg に制限している
        const maxFileSize = 500000;
        const allowedContentType = "image/jpeg";
 
        // canvas に描く画像のサイズ制限。ここでは 500 x 500 以下
        // に制限している
        const maxWidth = 500;
        const maxHeight = 500;

        // 上に定義されている input type="file" などのオブジェクト
        // をいちいち getElementById で取得しなくて済むように以下
        // の変数に保持。代入は下の DOMContentLoaded イベントのリ
        // スナで行う
        let inputFile, uploadButton, msgDiv, myCanvas, resultDiv;         

        // DOMContentLoaded イベントのリスナ設定
        window.addEventListener('DOMContentLoaded', () => {
            inputFile = document.getElementById("file1");
            uploadButton = document.getElementById("button1");
            msgDiv = document.getElementById("msg");
            myCanvas = document.getElementById("mycanvas");
            resultDiv = document.getElementById("result");

            // ブラウザの HTML5 File API サポートを確認
            if (window.File && window.FileReader && window.FileList) {

                // fileReader と image オブジェクトの初期化
                fileReader = new FileReader();
                image = new Image();                

                // [Upload] ボタンクリックで uploadImage メソッド
                // を起動できるようリスナとして設定
                uploadButton.addEventListener('click', uploadImage);

                // input type="file" でファイルの選択が完了すると 
                // change イベントが発生するのでそれにリスナをアタ
                // ッチし、以下の処置を行う
                inputFile.addEventListener('change', () => {
                    resultDiv.innerText = "";

                    // 選択されたファイルのタイプ/サイズの検証
                    // ClientValidate ヘルパメソッドは下の定義参照
                    if (ClientValidate(inputFile) == false) {
                        uploadButton.style.display = "none";
                        myCanvas.style.display = "none";
                        return;
                    }

                    // fileReader オブジェクトに input type="file" 
                    // で選択された画像ファイルを読み込む
                    fileReader.readAsDataURL(inputFile.files[0]);
                });

                // 上の readAsDataURL メソッドは非同期で動くので、
                // 読み込み完了の onloadend イベントのリスナで 
                // FileReader から Data url を取得し image オブジ
                // ェクトの src 属性に設定する
                fileReader.onloadend = () => {
                    image.src = fileReader.result; 
                };
                
                // image オブジェクトで画像のロードが完了すると 
                // image.onload イベントが発生するので、そのイベン
                // トのリスナで image オブジェクトが保持する画像を
                // サイズ制限以下に縮小して canvas 描画する。
                // DrawImageOnCanvas メソッドは下の定義参照
                image.onload = DrawImageOnCanvas;
            }
            else {
                inputFile.style.display = "none";
                myCanvas.style.display = "none";
                msgDiv.innerText =
                    "File API がサポートされていません。";
            }
        });

        // 選択されたファイルの検証のためのヘルパ関数
        function ClientValidate(fileUpload) {
            msgDiv.innerText = "";

            if (fileUpload.files[0] == null) {
                msgDiv.innerText = "ファイルが未選択です。";
                return false;
            }

            if (fileUpload.files[0].type != allowedContentType) {
                msgDiv.innerText = "選択されたファイルのタイプが " + 
                    allowedContentType + " ではありません。";
                return false;
            }

            if (fileUpload.files[0].size > maxFileSize) {
                msgDiv.innerText = "ファイルのサイズが " + 
                    maxFileSize + " バイトを超えています。";
                return false;
            }

            return true;
        }

        // 上で定義した image オブジェクトの src 属性に Data url
        // が設定され画像のロードが完了すると発生する onload イ
        // ベントにアタッチするリスナ。これで image 要素の画像を
        // 指定されたサイズ以下に縮小して canvas に描画する
        function DrawImageOnCanvas() {
            // オリジナル画像のサイズ
            const w = image.width;
            const h = image.height;

            let targetW, targetH;
            const context = myCanvas.getContext('2d');

            if (w <= maxWidth && h <= maxHeight) {
                // w, h ともに制限 maxWidth, maxHeight 以内 ⇒
                // そのままのサイズで canvas に描画
                myCanvas.setAttribute('width', w);
                myCanvas.setAttribute('height', h);
                context.drawImage(image, 0, 0);
            }
            else if (w < h) {
                // w, h どちらかが制限オーバーで h の方が大きい ⇒
                // 高さを maxHeight に縮小
                targetH = maxHeight;
                // 幅は高さの縮小比率で縮小
                targetW = Math.floor(w * targetH / h);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }
            else {
                // w, h どちらかが制限オーバーで w の方が大きい ⇒
                // 幅を maxWidth に縮小
                targetW = maxWidth;
                // 高さは幅の縮小比率で縮小
                targetH = Math.floor(h * targetW / w);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }

            uploadButton.style.display = "inline-block";
            myCanvas.style.display = "block";
        }

        // canvas の画像データを DataURL 形式で取得し、JSON 文
        // 字列を組み立てて fetch API で送信。
        async function uploadImage() {
            const context = myCanvas.getContext('2d');
            const dataUrl = context.canvas.toDataURL("image/jpeg");
            const params = {
                method: "POST",
                body: '{"imgBase64":"' + dataUrl + '"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch("/api/Canvas", params);
            if (response.ok) { 
                const message = await response.text();
                resultDiv.innerText = message
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        }
    //]]>
    </script>
}

クライアントから JSON 形式で送信されてきた BASE64 形式の画像データは、以下のコードの ASP.NET Core Web API のコントローラーで受け、デコードしてバイナリ形式に戻してファイルに保存しています。

using Microsoft.AspNetCore.Mvc;

namespace MvcCore6App3.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CanvasController : ControllerBase
    {
        // 物理パスの取得用
        private readonly IWebHostEnvironment _hostingEnvironment;

        public CanvasController(IWebHostEnvironment hostingEnvironment)
        {
            this._hostingEnvironment = hostingEnvironment;
        }

        [HttpPost]        
        public async Task<string> Post([FromBody] JsonData data)
        {
            // 文字列先頭の "data:image/jpeg;base64," を除去。
            string imgBase64 = 
                data.ImgBase64.Replace("data:image/jpeg;base64,", "");

            // BASE64 エンコードされた画像データを元のバイト列に変換
            Byte[] imgByteArray = Convert.FromBase64String(imgBase64);

            // ファイル名を設定
            string filename = 
                $"img{DateTime.Now.ToString("yyyyMMddHHmmss")}.jpg";

            // アプリケーションルートの物理パスを取得
            string contentRootPath = 
                _hostingEnvironment.ContentRootPath;

            string filePath = 
                $"{contentRootPath}\\UploadedFiles\\{filename}";

            // フォルダ UploadedFile に画像ファイルを保存
            await System.IO.File
                 .WriteAllBytesAsync(filePath, imgByteArray);

            return $"ファイル名 {filename} として保存しました。";
        }
    }

    // 先の記事の ASP.NET Web Forms アプリの Web メソッドでは
    // ReceiveImage(string imgBase64) という形で引数の imgBase64
    // に直接  {"imgBase64":"value"} の value を受け取ることがで
    // きたが、Web API ではそのようなことはできず、以下のような
    // クラスを定義して、それで受け取る必要がある
    public class JsonData
    {
        public string ImgBase64 { get; set; } = null!;
    }
}

JavaScript はブラウザ依存なところがありますが、Edge 107.0.1418.52, Chrome 107.0.5304.107, Firefox 107.0, Opera 93.0.4585.11 では期待通り動くことは確認しました。

Tags: , , , , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。その後ブログ2を追加し、ここはプログラミング関係、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  November 2022  >>
MoTuWeThFrSaSu
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar