【第7回】サイトから閲覧や投稿ができるアプリケーションを作ろう(閲覧編)

前回ついにデータベースをプログラム上から操作することができるようになりました。今回はそれを利用して、Webサイトから閲覧や投稿ができるアプリケーションを作っていきます。

Homeブログ一覧【第7回】サイトから閲覧や投稿ができるアプリケーションを作ろう(閲覧編)

おさらい

前回ついにデータベースをプログラム上から操作することができるようになりました。 sqlite3というモジュールを使って、queries.jsに定義したSQL文を実行することで、データベースを操作することができました。

ファイル分割をすることで適切なファイルに適切な処理を書き分けることができ、プログラムが見やすくなりました。

SQLインジェクションという脆弱性についても学び、SQL文を実行する際には、ユーザーからの入力をそのまま使わず、プレースホルダー(?)を使って実行するようにしました。

HTMLがブラウザに表示される仕組み(HTTP)

以前の講義でHTMLを学んだ時に、それをブラウザで表示する方法として、htmlファイルを作りブラウザで開くという方法を学びました。 しかし実は、ブラウザはHTMLファイルを直接読み込んでいるわけではありません。これについて解説します。

HTTPとは

ブラウザがHTMLを読み込む際に使うのがHTTPというプロトコルです。皆さん聞いたことがあると思いますが、HTTPとはHyperText Transfer Protocolの略で、ハイパーテキストを転送するためのプロトコルです。

じつは、ブラウザはHTMLファイルを直接読み込んでいるのではなく、HTTPを使ってHTML形式の文字列をサーバーから取得しています。そして、その文字列をブラウザが解釈して、HTMLとして表示しているのです。

つまりファイルである必要はなく、HTMLだよっていう命令と、HTML形式の文字列が取得できれば、ブラウザはそれを表示することができるのです。

通信にはヘッダーボディがあり、ヘッダーには通信に関する情報が、ボディには実際に取得したデータが入っています。

実験してみよう

実際に、ブラウザがHTMLファイルを読み込んでいるのではなく、HTTPを使ってHTML形式の文字列を取得していることを確認してみましょう。

まず、適当な場所にindex.htmlを作成し、以下のように書きます。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>これはHTMLファイルです</title>
</head>
<body>
    <h1>こんにちは、これはHTMLファイルです</h1>
</body>
</html>

ブラウザでindex.htmlを開いて、表示されることを確認してください。

次に検証ツールからネットワークタブを開き、サイトをリロードしてください。 通信があったHTMLの要素をクリックし、Headersタブをクリックすると、以下のような画面が表示されます。

ネットワークタブ

ここのResponse HeadersContent-Typeを見てください。text/htmlとなっていることがわかります。 HTTP通信には ブラウザはHTTP通信のヘッダーを見て、Content-Typetext/htmlであることを確認したら、ボディの内容(文字列)をHTMLとして解釈し表示しているのです。

ではわざとHTMLファイルを使わずに、HTTP通信のボディにHTML形式の文字列を入れてみましょう。

次に、server.jsをこれまた適当な場所に作成し、以下のように書きます。

server.js
const http = require('http');

const HTML = `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>これはただの文字列です</title>
</head>
<body>
    <h1>こんにちは、これはただの文字列です</h1>
</body>
</html>
`;

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/html'});
    res.end(HTML);
});

server.listen(3000);

そして、node server.jsとしてサーバーを起動し、ブラウザでhttp://localhost:3000にアクセスしてみましょう。

先ほどのHTMLファイルをブラウザで開いたときと同じように、ちゃんとHTMLとして表示されていることがわかります。

ちなみに、今回はContent-Typetext/htmlにしていますが、text/plainにすることもできます。 この場合、ブラウザはHTMLとして解釈せず、ただの文字列として表示します。

データベースを繋げてWebサイトを作ってみよう

今回はより簡単に実装ができるように、最近話題のフレームワークであるHonoを使ってWebサイトを作っていきます。

Honoのインストール

まずはHonoをインストールします。前回の講義でつかった例のプロジェクトのディレクトリに移動し、以下のコマンドを実行してください。

npm install hono @hono/node-server
Nodejs18が必要です

@hono/node-serverはNodejs18以上のバージョンが必要です。もしNodejs18以上のバージョンがインストールされていない場合は、以下のコマンドを実行してください。

sudo apt update
sudo apt autoremove nodejs
sudo apt remove nodejs
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs

でNodejs18をインストールしてください。

サーバーをたててみよう

次に、サーバーをたててみましょう。index.jsを以下のように書き換えてください。

index.js
const sqlite3 = require("sqlite3").verbose();
const queries = require("./queries");
const { serve } = require("@hono/node-server");
const { Hono } = require("hono");

const db = new sqlite3.Database("database.db");

db.serialize(() => {
    db.run(queries.Tweets.createTable);
    db.run(queries.Users.createTable);

    db.run(queries.Users.create, 'りんご太郎', '[email protected]', '2022-08-15 00:00:00');
    db.run(queries.Users.create, 'みかん次郎', '[email protected]', '2022-08-15 00:00:01');
    db.run(queries.Users.create, 'ぶどう三郎', '[email protected]', '2022-08-15 00:00:02');

    db.run(queries.Tweets.create, 'あけおめ!', 3, '2023-01-01 00:00:00');
    db.run(queries.Tweets.create, '今年もよろしくお願いします!', 2, '2023-01-01 00:00:01');
    db.run(queries.Tweets.create, '今年こそは痩せるぞ!', 1, '2023-01-01 00:00:02');
});

const app = new Hono();

app.get("/", (c) => {
    return c.text("Hello World!");
});

serve(app);

process.stdin.on("data", (data) => {
  if (data.toString().trim() === "q") {
    db.close();
    process.exit();
  }
});

そして、node index.jsとしてサーバーを起動し、ブラウザでhttp://localhost:3000にアクセスしてみましょう。 c.textContent-Typetext/plainにして、引数の文字列を返す関数です。 app.getは、GETリクエストが第一引数のパスに来たときに、第二引数の関数を実行するという関数です。 serveは、第一引数のHonoインスタンスを使ってサーバーを起動する関数です。

ブラウザにHello World!と表示されていれば成功です。 ネットワークタブでもContent-Typetext/plainであることがわかります。

いままでプログラムをCtrl+Cで終了していましたが、今回はqを入力すると終了するようにしています。こうすることで、サーバーを終了するときに、確実にデータベースを閉じることができます。

コミット[サーバーを立てるプログラムを作成]

git add .
git commit -m "サーバーを立てるプログラムを作成"

データベースの情報を表示してみよう

次に、データベースの情報を表示してみましょう。index.jsを以下のように書き換えてください。

index.js
const sqlite3 = require("sqlite3").verbose();
const queries = require("./queries");
const { serve } = require("@hono/node-server");
const { Hono } = require("hono");

const db = new sqlite3.Database("database.db");

db.serialize(() => {
    db.run(queries.Tweets.createTable);
    db.run(queries.Users.createTable);

    db.run(queries.Users.create, 'りんご太郎', '[email protected]', '2022-08-15 00:00:00');
    db.run(queries.Users.create, 'みかん次郎', '[email protected]', '2022-08-15 00:00:01');
    db.run(queries.Users.create, 'ぶどう三郎', '[email protected]', '2022-08-15 00:00:02');

    db.run(queries.Tweets.create, 'あけおめ!', 3, '2023-01-01 00:00:00');
    db.run(queries.Tweets.create, '今年もよろしくお願いします!', 2, '2023-01-01 00:00:01');
    db.run(queries.Tweets.create, '今年こそは痩せるぞ!', 1, '2023-01-01 00:00:02');
});

const app = new Hono();

app.get("/", async (c) => {
    const tweets = await new Promise((resolve) => {
        db.all(queries.Tweets.findAll, (err, rows) => {
            resolve(rows);
        });
    });

    return c.json(tweets);
});

serve(app);

process.stdin.on("data", (data) => {
    if (data.toString().trim() === "q") {
        db.close();
        process.exit();
    }
});

ちょっと複雑な処理が出てきましたね。 db.allは、SQLを実行して結果を全て取得する関数でしたね。 db.allはコールバック関数を第二引数に取り、第一引数のSQLを実行した結果を第二引数のコールバック関数の第二引数に渡します。 コールバック関数の第一引数にはエラーが渡されるので、エラーがなければrowsに結果が渡されるという仕組みです。 (本当はエラー処理をちゃんとするべきですが、今回は省略しています)

db.allは同期関数(コールバック関数を第二引数に取る関数)なので、Promiseを使って非同期関数に変換して、取得が終わるまで待つという処理をしています。

c.jsonContent-Typeapplication/jsonにして、引数のオブジェクトをJSON形式の文字列に変換して返す関数です。 今回、findAllクエリで帰ってくるデータはオブジェクトなので、c.textを使って返そうとすると、[object Object]という文字列が返ってきてしまいます。 (オブジェクトを無理やり文字列に変換するとこのような文字列になります) なので、c.jsonを使ってJSON形式の文字列に変換して返しているというわけです。

ブラウザでhttp://localhost:3000にアクセスしてみましょう。

データベースの情報がJSON形式で表示されていることがわかりますね?

コミット[データベースの情報をサーバーから返すプログラムを作成]

git add .
git commit -m "データベースの情報をサーバーから返すプログラムを作成"

HTMLを表示してみよう

次に、HTMLを表示してみましょう。index.jsを以下のように書き換えてください。

index.js
const sqlite3 = require("sqlite3").verbose();
const queries = require("./queries");
const { serve } = require("@hono/node-server");
const { Hono } = require("hono");

const db = new sqlite3.Database("database.db");

db.serialize(() => {
    db.run(queries.Tweets.createTable);
    db.run(queries.Users.createTable);

    db.run(queries.Users.create, 'りんご太郎', '[email protected]', '2022-08-15 00:00:00');
    db.run(queries.Users.create, 'みかん次郎', '[email protected]', '2022-08-15 00:00:01');
    db.run(queries.Users.create, 'ぶどう三郎', '[email protected]', '2022-08-15 00:00:02');

    db.run(queries.Tweets.create, 'あけおめ!', 3, '2023-01-01 00:00:00');
    db.run(queries.Tweets.create, '今年もよろしくお願いします!', 2, '2023-01-01 00:00:01');
    db.run(queries.Tweets.create, '今年こそは痩せるぞ!', 1, '2023-01-01 00:00:02');
});

const HTML = (body) => `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>これはただの文字列です</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    ${body}
</body>
</html>
`;

const app = new Hono();

app.get("/", async (c) => {
    const tweets = await new Promise((resolve) => {
        db.all(queries.Tweets.findAll, (err, rows) => {
            resolve(rows);
        });
    });

    const response = HTML(`
        <h1 class="title">ツイート一覧</h1>
    `);

    return c.html(response);
});

serve(app);

process.stdin.on("data", (data) => {
    if (data.toString().trim() === "q") {
        db.close();
        process.exit();
    }
});

HTMLという関数を定義して、引数の文字列をHTML形式の文字列に変換して返すようにしています。

ブラウザでhttp://localhost:3000にアクセスしてみましょう。 すると大きく「ツイート一覧」と書かれた文字が表示されていることがわかります。

実際に検証ツールのNetworkタブから通信の内容を見てみると、Content-Typetext/htmlになっていること、そしてタイトルの内容(引数に書いた内容)が埋め込まれたHTML関数の戻り値が返ってきていることがわかります。

コミット[HTMLをサーバーから返すプログラムを作成]

git add .
git commit -m "HTMLをサーバーから返すプログラムを作成"

ツイートの内容も表示してみよう

では、ツイートの内容も表示してみましょう。index.jsを以下のように書き換えてください。

index.js
const sqlite3 = require("sqlite3").verbose();
const queries = require("./queries");
const { serve } = require("@hono/node-server");
const { Hono } = require("hono");

const db = new sqlite3.Database("database.db");

db.serialize(() => {
    db.run(queries.Tweets.createTable);
    db.run(queries.Users.createTable);

    db.run(queries.Users.create, 'りんご太郎', '[email protected]', '2022-08-15 00:00:00');
    db.run(queries.Users.create, 'みかん次郎', '[email protected]', '2022-08-15 00:00:01');
    db.run(queries.Users.create, 'ぶどう三郎', '[email protected]', '2022-08-15 00:00:02');

    db.run(queries.Tweets.create, 'あけおめ!', 3, '2023-01-01 00:00:00');
    db.run(queries.Tweets.create, '今年もよろしくお願いします!', 2, '2023-01-01 00:00:01');
    db.run(queries.Tweets.create, '今年こそは痩せるぞ!', 1, '2023-01-01 00:00:02');
});

const HTML = (body) => `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>これはただの文字列です</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    ${body}
</body>
</html>
`;

const app = new Hono();

app.get("/", async (c) => {
    const tweets = await new Promise((resolve) => {
        db.all(queries.Tweets.findAll, (err, rows) => {
            resolve(rows);
        });
    });

    const tweetList = tweets.map((tweet) => `<div class="tweet">${tweet.content}</div>`).join("\n");

    const response = HTML(`
        <h1 class="title">ツイート一覧</h1>
        <div class="tweet-list">
            ${tweetList}
        </div>
    `);

    return c.html(response);
});

serve(app);

process.stdin.on("data", (data) => {
    if (data.toString().trim() === "q") {
        db.close();
        process.exit();
    }
});

tweetListという変数に、それぞれのツイートの内容を<div class="tweet">で囲んだ文字列を配列にして格納しています。 もし気になる人はconsole.log(tweetList)をしてみてください。

そして、tweetListHTML関数の引数に渡しています。

ブラウザでhttp://localhost:3000にアクセスしてみましょう。

ツイートの内容が表示されていることがわかりますね?

コミット[ツイートの内容をHTMLで表示するプログラムを作成]

git add .
git commit -m "ツイートの内容をHTMLで表示するプログラムを作成"

CSSを適用してみよう

表示するだけじゃ面白くないので、デザインを適用してみましょう。

まず、static/style.cssを作成します。 staticディレクトリは静的ファイルを置くディレクトリです。作ってください。 静的ファイルとは、サーバーが動作している間に変更されないファイルのことです。 今回はCSSファイルを置くために使います。

static/style.css
body {
  margin: 0;
  background-color: #edf3f6;
}

.title {
    font-size: 60px;
    text-align: center;
}

.tweet-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 768px;
  padding: 16px;
  box-sizing: border-box;
  width: 100%;
  margin: 0 auto;
}

.tweet {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 16px;
  border-radius: 8px;
  background-color: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
}

そして、index.jsにこのような追加をします

index.js
const sqlite3 = require("sqlite3").verbose();
const queries = require("./queries");
const { serve } = require("@hono/node-server");
const { serveStatic } = require("@hono/node-server/serve-static");
const { Hono } = require("hono");

const db = new sqlite3.Database("database.db");

db.serialize(() => {
    db.run(queries.Tweets.createTable);
    db.run(queries.Users.createTable);

    db.run(queries.Users.create, 'りんご太郎', '[email protected]', '2022-08-15 00:00:00');
    db.run(queries.Users.create, 'みかん次郎', '[email protected]', '2022-08-15 00:00:01');
    db.run(queries.Users.create, 'ぶどう三郎', '[email protected]', '2022-08-15 00:00:02');

    db.run(queries.Tweets.create, 'あけおめ!', 3, '2023-01-01 00:00:00');
    db.run(queries.Tweets.create, '今年もよろしくお願いします!', 2, '2023-01-01 00:00:01');
    db.run(queries.Tweets.create, '今年こそは痩せるぞ!', 1, '2023-01-01 00:00:02');
});

const HTML = (body) => `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>これはただの文字列です</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    ${body}
</body>
</html>
`;

const app = new Hono();

app.get("/", async (c) => {
    const tweets = await new Promise((resolve) => {
        db.all(queries.Tweets.findAll, (err, rows) => {
            resolve(rows);
        });
    });

    const tweetList = tweets.map((tweet) => `<div class="tweet">${tweet.content}</div>`).join("\n");

    const response = HTML(`
        <h1 class="title">ツイート一覧</h1>
        <div class="tweet-list">
            ${tweetList}
        </div>
    `);

    return c.html(response);
});

app.use("/static/*", serveStatic({ root: "./" }));

serve(app);

process.stdin.on("data", (data) => {
    if (data.toString().trim() === "q") {
        db.close();
        process.exit();
    }
});

app.useは、第一引数のパスに来たリクエストに対して、第二引数のプラグインを実行するという関数です。

serveStaticは、第一引数のオプションを使って、静的ファイルを返すプラグインです。 rootオプションは、静的ファイルを置くディレクトリを指定します。

app.useを使って、/static/*に来たリクエストに対して、静的ファイルを返すプラグインを実行するようにしています。

ブラウザでhttp://localhost:3000にアクセスしてみましょう。

なんとなくデザインが適用されていれば成功です。

コミット[サイトにCSSを適用するように拡張]

git add .
git commit -m "サイトにCSSを適用するように拡張"

SSR

今までやってきた、サーバーで取得したデータをHTMLに埋め込んで返すという方法は、サーバーサイドレンダリングと呼ばれます。以降SSRと呼びます。

SSRは、サーバーでHTMLを生成してからブラウザに返すので、ブラウザはHTMLを受け取ってすぐに表示することができます。

以前、このSSRが主流でした。たとえば私たちが使っているWebClassSSRで動いています。

この方法は、前回講義で説明した3層アーキテクチャには従っていません。というのもアプリケーション層プレゼンテーション層が混ざってしまっているからです。

これを避ける方法が二つあって

  • クライアントサイドレンダリング(CSR)にする
  • プレゼンテーション層を別のサーバーにする(アプリケーションサーバーとは別のSSR用のサーバーを用意する)

最近は2つ目の方法が主流です。Next.jsNuxt.jsなどのフレームワークは、この方法を採用しています。

しかし、こちらはとてもハイコンテキストで習得に技術力と時間を要するので、今回はやりません。 代わりに、1つ目の方法であるCSRを次回からやっていきます。