LoginSignup
0
0

More than 5 years have passed since last update.

年末年始Webアプリ開発自習の記録5: Node.js+TypescriptでHabiticaにPOSTを送るWebアプリケーション作成

Last updated at Posted at 2019-01-05

はじめに

[連休を機に考える、怠惰な私の自習戦略][Plan]にて立てた計画に沿った自習の記録です。
前回:[年末年始Webアプリ開発自習の記録4: Typescript開発環境準備][Prev]

書き直したコード

大したことしていないコードなのに、すごい量が増えました。参考にしたページは最後にまとめます。
ハマったポイントもかなりあって忘れそうなので、ファイル毎に記録していきます。

Config

idにリクエスト送信情報を紐付けた設定ファイルですが、httpsモジュールからrequestモジュールに鞍替えしたためにjsonの構造を変える必要がありました。

config.json
[
    {
        "options":{
            "method": "POST",
            "headers":
            {
                "cookie": [""],
                "content-length": "0",
                "x-api-key": "調べていれとく",
                "x-api-user": "調べていれとく",
                "content-type": "application/json"
            }
        },
        "id": "なんかいれとく",
        "url": "https://habitica.com/api/v3/tasks/05084be1-4f85-42a8-90bb-6314361ac128/score/up"
    }
]

request()関数を使う時にIntelliSenseが引数を教えてくれたので、uri: stringoptions?: request.CoreOptionsを持つように変更しました。
request.CoreOptions型はF12押したらわかりました。ありがたいです。

EventDelegater

C#だとこんな感じでイベントを登録して発動できるのですが、あいにくこういう機能はありませんでした。

登録側
void RecieverMethod()
{
    var EventSender = new EventSender();
    EventSender.SomeEvent += SomeEventHandle; //イベントハンドラ追加
    EventSender.SomeEvent.Add(SomeEventHandle); //イベントハンドラ追加(旧式)
}
void SomeEventHandle(object sender, SomeEventArgs e)
{
    // イベント処理
}
発動側
class Sender
{
    event EventHandler<SomeEventArgs> SomeEvent;
    SenderMethod()
    {
        SomeEvent?.Invoke();
    }
}

ないのでそれっぽいものを作ってみたものがEventDelegaterです。

EventDelegater.ts
export default class EventDelegater<TEventArgs> {

    private subscribers: Array<(sender: object, args: TEventArgs) => void>;

    constructor() {
        this.subscribers = new Array();
    }

    public Add(method: (sender: object, args: TEventArgs) => void): void {
        this.subscribers.push(method);
    }

    public Remove(method: (sender: object, args: TEventArgs) => void): void {
        const target: number = this.subscribers.indexOf(method);
        if (target !== -1) {
            this.subscribers.splice(target, 1);
        }
    }

    public Invoke(sender: object, args: TEventArgs): void {
        if (this.subscribers.length !== 0) {
            this.subscribers.forEach((element) => {
                element(sender, args);
            });
        }
    }
}

本家はうまいこと発動側しかInvokeできないようになっているのですが、その仕組みを細部までわかっていない上に、それを調べるのは本筋ではないので、あくまでTypescriptに慣れる範囲で簡易的な実装に留めています。

関数型

関数型は(sender: object, args: TEventArgs) => voidのように書けることがわかりました。

配列の操作

配列の中身を消すのに手こずりました。C#ではコレクションの型が豊富で、適切なコレクションを選択すればリッチな機能で簡単に操作可能でしたが、TypescriptないしJavascriptでは配列や連想配列を駆使するため、下記のように付属のメソッドを組み合わせざるを得ませんでした。

この部分
public Remove(method: (sender: object, args: TEventArgs) => void): void {
    const target: number = this.subscribers.indexOf(method);
    if (target !== -1) {
        this.subscribers.splice(target, 1);
    }
}

拡張メソッド

ここまで書いてふと思ったのですが、拡張メソッドを実装できれば上記のように面倒なコードを書かずに一発で削除できるのではないかと思って調べたところ、案の定ありましたので書いてみました。

Array.Extended.ts
export { };

declare global {
    // tslint:disable-next-line:interface-name
    interface Array<T> {
        Remove(condition: ((item: T) => boolean)): T[];
        RemoveFirst(item: T): T[];
        RemoveAll(item: T): T[];
    }
}

Array.prototype.Remove = function(condition) {
    const removeList: number[] = new Array();
    for (const i of this) {
        if (condition(i)) {
            removeList.unshift(i);
        }
    }
    removeList.forEach((target) => {
        this.splice(target, 1);
    });
    return this;
};

Array.prototype.RemoveFirst = function(item) {
    const target: number = this.indexOf(item);
    if (target !== -1) {
        this.splice(target, 1);
    }
    return this;
};

Array.prototype.RemoveAll = function(item) {
    let removed: boolean = false;
    do {
        const target: number = this.indexOf(item);
        if (target !== -1) {
            this.splice(target, 1);
            removed = true;
        } else {
            removed = false;
        }
    } while (removed);
    return this;
};

EventDelegater.tsの冒頭にimport "./Array.Extended";を追加して使えるようになりました。

最初のexport { };の意味はよくわかりません、そのうち調べる羽目になるでしょう。
ややハマりかけたのは拡張の実装がArray<T>.prototype.メソッド名ではなくArray.prototype.メソッド名となることです。つまり中でTが使えません。

オーバーロード

はじめはRemove(condition: ((item: T) => boolean)): T[];Remove(item: T): T[];を作ろうとしていましたが、TSLintにRemove(condition: ((item: T) => boolean))|T: T[];とするよう怒られました。
全然意味が違うのに何でだろうと思っていながら渋々従ったところ、実装のArray.prototype.Remove = function(condition) {...}にあるconditionanyになってしまったことで気がつきました。
Typescriptは静的型付け言語ですが、生成されるJavascriptはそうではありません。引数の型が異なることを知っているのはTypescriptのトランスコンパイラだけであり、Javascriptになってしまったら区別ができないということですね。

EventArgs

いくらC#の流儀に寄せたとはいえ、EventArgsのクラスをいちいち作っていくのは面倒でした。仕方ないですけど、もっと良い方法はないですかね。

GetRequestEventArgs.ts
import http = require("http");
import url = require("url");

export default class GetRequestEventArgs {
    public get Query(): url.UrlWithParsedQuery|null {
        return this.query;
    }
    public get Response(): http.ServerResponse {
        return this.response;
    }
    constructor(private query: url.UrlWithParsedQuery|null, private response: http.ServerResponse) {

    }
}
PostRequestEventArgs.ts
import http = require("http");

export default class PostRequestEventArgs {
    public get Body(): Buffer {
        return this.body;
    }
    public get Response(): http.ServerResponse {
        return this.response;
    }
    constructor(private body: Buffer, private response: http.ServerResponse) {

    }
}

Nullableな型

C#ではリテラルな型以外はnullの可能性があったので、てっきりTypescriptもそうかなと思ってしまったのですが違いました。
よくよく考えてみると、そもそもそのようにリテラルな型以外はnullの可能性があることのほうがおかしい気がしてきました。

null許容型の例
public get Query(): url.UrlWithParsedQuery|null {
    return this.query;
}

上記のようにurl.UrlWithParsedQuery|nullと書くことで実現できます。この記法はnullだけに限らずundefinedやその他の型も追加できます。
先の拡張メソッドの部分で既に登場してしまいましたが、私がこれを知ったのは本来はここでした。

型の読み込み

他のクラスの型を参照する方法がわからずにハマりました。EventDelegaterを最初に書いたので気がつかなかったのですが、ファイルの先頭がいきなりクラスだった場合は同じ場所にあれば参照可能でした。理由はわかりません。
その後上記のクラスらを書いたところ、この後説明するReceiverクラスで参照できずに困りました。調べるとnamespaceやらなんやら出てきて試してみるも、ことごとくTSLint先生が古いだの非推奨だのとお怒りになられ、ついにわかったのはexport defaultという修飾子をつけてimportすることでした。defaultとするとimportした変数そのままで型が参照可能になることはわかりましたが、exportの正確な仕様はまだ理解していません。

Receiver

ポートを指定してhttpのサーバーを立てて、リクエストが来たらイベントを発動するクラスです。

Receiver.ts
import http = require("http");
import url = require("url");
import EventDelegater from "./EventDelegater";
import GetRequestEventArgs from "./GetRequestEventArgs";
import PostRequestEventArgs from "./PostRequestEventArgs";

export default class Receiver {

    public get Port(): number {
        return this.port;
    }
    public get GetRequestEvent(): EventDelegater<GetRequestEventArgs> {
        return this.getRequestEvent;
    }
    public get PostRequestEvent(): EventDelegater<PostRequestEventArgs> {
        return this.postRequestEvent;
    }
    public get IsRunning(): boolean {
        return this.isRunning;
    }

    private server: http.Server;
    private getRequestEvent: EventDelegater<GetRequestEventArgs> = new EventDelegater();
    private postRequestEvent: EventDelegater<PostRequestEventArgs> = new EventDelegater();
    private isRunning: boolean = false;

    constructor(private port: number) {
        const onRequestComingHandler = this.OnRequestComing.bind(this);
        this.server = http.createServer(onRequestComingHandler);
    }

    public StartServer(): void {
        if (!this.isRunning) {
            this.isRunning = true;
            this.server.listen(this.port);
        }
    }

    public StopServer(): void {
        if (this.isRunning) {
            this.isRunning = false;
            this.server.close();
        }
    }

    private OnRequestComing(request: http.IncomingMessage, response: http.ServerResponse): void {
        if (request.method === "GET") {
            let urlQueries = null;
            if (request.url !== undefined) {
                urlQueries = url.parse(request.url, true);
            }
            this.GetRequestEvent.Invoke(this, new GetRequestEventArgs(urlQueries, response));
        }

        if (request.method === "POST") {
            const chunks: Uint8Array[] = new Array();
            request.on("data", (chunk) => {
                chunks.push(chunk);
            });

            request.on("end", () => {
                const body = Buffer.concat(chunks);
                this.PostRequestEvent.Invoke(this, new PostRequestEventArgs(body, response));
            });
        }
    }
}

thisのスコープ

リクエストが来た時に実行されるOnRequestComingメソッドですが、この中のthis.GetRequestEvent.Invoke(this, new GetRequestEventArgs(urlQueries, response));を実行したときにCannot read property 'Invoke' of undefinedと出て「は?」となりました。
このメソッドは非同期なのでもしやと思い、DebugしてわかったのはOnRequestComingメソッド内でのthisはどうやらインスタンス自身とは別物であるということでした。Javascriptにおいてthisはコードの存在する場所ではなく、誰がそれを実行したかに依存するみたいです。

bindする
constructor(private port: number) {
    const onRequestComingHandler = this.OnRequestComing.bind(this);
    this.server = http.createServer(onRequestComingHandler);
}

上記のようにbindメソッドでthisを明示的に指定することができました。

response処理のさせかた

response: http.ServerResponseをイベントに乗せて送っていますが、イベントの受け手側がhttpモジュールに依存することになるため、本当はやりたくありませんでした。しかし良い感じにラップする方法も思いつかなかったのでこのようになりました。

Main

これが最後のファイルで、エントリポイントとなるMainです。

main.ts
import http = require("http");
import request = require("request");
import data from "./config.json";
import GetRequestEventArgs from "./GetRequestEventArgs";
import Receiver from "./Receiver";

const port = 3000;
const receiver: Receiver = new Receiver(port);
receiver.GetRequestEvent.Add(GetRequestEventHandler);
receiver.StartServer();

// tslint:disable-next-line:variable-name
function GetRequestEventHandler(_sender: object, eventArgs: GetRequestEventArgs): void {
    if (eventArgs.Query !== null) {
        const queries = eventArgs.Query;
        const result: [request.CoreOptions, string]|null = FindOptionsById(queries.query.id as string);

        if (result !== null) {
            const options: request.CoreOptions = result[0];
            const requestUrl: string = result[1];
            request(requestUrl, options, (error: any, response: request.Response, body: any) => {
                if (error === undefined) {
                    console.log(error);
                } else {
                    if (response.headers["set-cookie"] !== undefined) {
                        UpdateCookieById(queries.query.id as string, response.headers["set-cookie"]);
                    }
                    eventArgs.Response.writeHead(response.statusCode, response.headers);
                    eventArgs.Response.write(body);
                }
                eventArgs.Response.end();
            });
        } else {
            SendResponseForError(eventArgs.Response);
        }

    } else {
        SendResponseForError(eventArgs.Response);
    }
}

function SendResponseForError(response: http.ServerResponse): void {
    response.writeHead(400, {"Content-Type": "application/json"});
    response.write(JSON.stringify({
        error: "Bad Request",
        message: "Wrong request.",
        success: false,
    }));
    response.end();
}

function FindOptionsById(id: string): [request.CoreOptions, string]|null {
    let options: request.CoreOptions|null = null;
    let resultUrl: string|null = null;
    data.forEach((element) => {
        if (id === element.id) {
            options = element.options;
            resultUrl = element.url;
        }
    });
    if (options !== null && resultUrl !== null) {
        return [options, resultUrl];
    }
    return null;
}

function UpdateCookieById(id: string, cookie: string[]): void {
    data.forEach((element) => {
        if (id === element.id) {
            element.options.headers.cookie = cookie;
        }
    });
}

Jsonのロード

どうしたらconfig.jsonの、それも中にあるoptionsを都合良くrequest.CoreOptionsとできるかにかなり悩みました。デシリアライズだのキャスタブルだの調べましたが結果はシンプルでした。resolveJsonModuleオプションをtsonfig.jsonに追加することで、下記の1行でdataとして認識されました。

import data from "./config.json";

そしてFindOptionsByIdメソッドで取り出した要素が普通にキャストされました。Json側でrequest.CoreOptionsのインターフェースにない要素を持っている場合はエラーとなります。動的に生成されるJsonの場合はどうなるんでしょうね。

タプルみたいな配列

これを本当にタプルと言っていいのか疑問ですが、FindOptionsByIdメソッドのように配列に型を書くとタプルが定義できます。
実際はobjectの配列で、トランスコンパイラが型をチェックしているのではないかと思います。

おわりに

思っていたより時間がかかりましたが、初めてにしてはよく書けたような充実感があります。制約が厳しいので大規模開発には向いていると思いますが、コード量の少ないプロトタイピングにおいてはJavascriptで書いた方が良さそうですね。
これで最初に作りたかったものについては一度切り上げます。次は自習戦略の2番目に作りたかったものに挑戦します。
次回:[年末年始Webアプリ開発自習の記録6: Node.js+Electron+Vue.js開発環境準備][Next]

参考にしたページ

[Plan]:https://qiita.com/dmorita/items/7a85ee9c2de506c27502
[Prev]:https://qiita.com/dmorita/items/36bafecd1763328a6d59
[Next]:https://qiita.com/dmorita/items/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0