2023年にホームページのアクセスカウンタを作った話

この記事ははてなブログに書いたこの記事の続きです。

上記はてなブログにも書きましたが、Webサイトの移行に伴い、アクセスカウンタを作り直しました。ここではその内部を説明します。これを読んで、「よし俺もアクセスカウンタを作ってみよう」と思う人はいないかもしれませんが、サーブレット単体でリクエストを受けて、DBにアクセスして、レスポンスを返す、というプログラムというのは、他の物を作ろうという人にとっても参考にはなるかと思っています。

ソースはGitHubに上げてあります。

https://github.com/kmaebashi/accesscounter/

はてなブログに上げた図ですが、全体的な構成はこんな感じ。

サーブレットコンテナにはTomcat10を使っています。うちのサイトのWebサーバはApacheなので、Apacheで受けたリクエストをAJP(Apache JServ Protocol)でTomcatに流しています。DBはPostgreSQLでJDBCでアクセスしていますが、さすがにコネクションプールは使わないと性能が出ないので、Tomcatに付いてくるデフォルトのApache Commons DBCP2を使っています。その設定については後述。

画像について

このアクセスカウンタでは、以下の10枚の画像を貼り合わせて画像を作っています。

この画像の、デジタルっぽい7セグメントLEDの画像の形自体は、以前のレンタルサーバのアクセスカウンタのものと同じです。さすがにこの小さな画像では形を変えるのは無理でしたし、こんなので著作権云々で怒られることもないでしょう……。著作権で怒られないように、というわけでもないですが、光っているLEDの色を少し明るくして、光っていないLEDについても暗い色で表現するようにしました。

テーブル定義

アクセス数はRDBMS(PostreSQL)で管理しています。そのテーブル定義は以下の通り。

create table accesscounter.accesscounter (
  counterid varchar(32) not null primary key, -- カウンタID
  counter integer not null,                   -- カウンタ
  updated timestamp not null                  -- 最終更新時刻
);

はてなブログの記事で書いた通り、アクセスカウンタのところのHTMLには以下のようなimg要素が貼ってあるわけですが、

<img src="/accesscounter/show?counterid=kmaebashi" alt="アクセスカウンタ">

この「counterid」で指定しているkmaebashiというのが、このカウンタのIDであり、それを上記テーブルのcounterid列に指定しています。現時点でこのアクセスカウンタを使っているのはうちのサイトのトップページだけなので、このテーブルは1行だけです。もし、他のページにもアクセスカウンタを置きたいとか、「アクセスカウンタ貸し出しサービス」を始めて他のサイトの人にも使ってもらおうとか思えば、このテーブルに行を追加して、そのcounteridimg要素に指定すればOKです。

ディレクトリ構成

Tomcatのwebappsフォルダ以下にaccesscounterフォルダを作っています。その下の構成は以下の通り。

├─META-INF
│      context.xml    …Tomcatのコネクションプールの設定を行っています
└─WEB-INF
    │  web.xml        …おなじみweb.xmlです
    ├─classes        …Javaのプログラムはここに置く
    │  └─com
    │      └─kmaebashi
    │          └─accesscounter
    │              │  AccessCounterServlet.class       …サーブレット
    │              └─dbaccess
    │                      AccessCounterDbAccess.class  …サーブレットから呼び出すDBアクセスクラス
    ├─images
    │      0.png~9.png …アクセスカウンタを作るための0~9の画像
    └─lib
            postgresql-42.6.0.jar  …PostgreSQLにアクセスするためのライブラリ

プログラムについて

ではいよいよプログラムです。

このアクセスカウンタは、サーブレットであるAccessCounterServlet.javaと、そこから呼び出されるDBアクセス用のクラスAccessCounterDbAccess.javaから構成されています。

まずはサーブレットの方から。

package com.kmaebashi.accesscounter;

import java.io.*;
import java.awt.*;
import java.awt.image.*;
import javax.imageio.*;
import jakarta.servlet.*;
import jakarta.servlet.http.*;

import com.kmaebashi.accesscounter.dbaccess.AccessCounterDbAccess;

public class AccessCounterServlet extends HttpServlet {
    private static Image[] numberImages = null;

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException, ServletException {
        if (numberImages == null) {
            loadNumberImages();
        }

        try {
            String counterId = request.getParameter("counterid");

            int count = AccessCounterDbAccess.getCount(counterId);

            BufferedImage image = generateImage(count);

            response.setHeader("pragma","no-cache");
            response.setHeader("Cache-Control","no-cache");

            response.setContentType("image/png");
            ImageIO.write(image, "png", response.getOutputStream());
        } catch (Exception ex) {
            throw new ServletException("Exception happend in AccessCounter", ex);
        }
    }

    private static BufferedImage generateImage(int counter) throws Exception {

        final int NUMBER_WIDTH = 15;
        final int NUMBER_HEIGHT = 20;
        final int NUMBER_OF_DIGITS = 7;

        int workCounter = counter;
        BufferedImage image;
        Graphics g = null;

        try {
            image = new BufferedImage(NUMBER_WIDTH * NUMBER_OF_DIGITS,
                                      NUMBER_HEIGHT,
                                      BufferedImage.TYPE_INT_RGB);
            g = image.createGraphics();
            for (int i = 0; i < NUMBER_OF_DIGITS; i++) {
                int num = workCounter % 10;

                g.drawImage(numberImages[num],
                            (NUMBER_OF_DIGITS - i - 1) * NUMBER_WIDTH,
                            0, null);

                workCounter /= 10;
            }
        } finally {
            g.dispose();
        }

        return image;
    }

    private void loadNumberImages() throws IOException {
        String imagesDirectory = this.getServletContext().getRealPath("WEB-INF/images");

        numberImages = new Image[10];
        for (int i = 0; i <= 9; i++) {
            String path = imagesDirectory + File.separator + i + ".png";
            numberImages[i] = ImageIO.read(new File(path));
        }
    }
}

img要素はGETメソッドで画像を取りに来るので、当然doGet()を実装します。

このアクセスカウンタは、10個の画像を貼り合わせてカウンタの画像を作りますが、その10個の画像は初回アクセス時にメモリにキャッシュしています。そのメソッドがloadNumberImages()で、画像が未設定ならまずそれを呼んでいます(18~20行目)。loadNumberImages()(70行目~)で画像を読み込んでいますが、その際、ServletContextgetRealPath()を使用することで、WEB-INF以下のフォルダの絶対パスを知ることができます。サーブレットで外部ファイルを読みたければここに置けばよいでしょう※1

なお、今回は使っていませんが、何らかの設定を外部ファイルに書きたいのなら、application.propertiesファイルをWEB-INF/classesの下に置いて、java.util.ResourceBundleクラスを使って以下のように読めます※2

  // たとえばapplicatio.prpoertiesに
  //「accesscounter.logdir=C:\\accesscounter\\log」と書いてあったとして
  // これでlogDirectoryに「C:\accesscounter\log」が取得できる
  ResourceBundle rb = ResourceBundle.getBundle("application");
  String logDirectory = rb.getString("accesscounter.logdir");

画像が読み込めたら、次はDBにアクセスして現在の値を持ってきて(25行目)、それを元にgenerateImage()メソッドで画像を作って、Content-Typeをimage/pngに指定したレスポンスにその画像を流し込みます(33行目)。

画像を作るgenerateImage()メソッドは、「10で割った余りを取ることを繰り返す」ことで、下の桁から順に0~9の値を取得し、該当の画像をGraphicsdrawImage()で返却用の画像に描画しています。

さて、次は、DBアクセスを行うAccessCounterDbAccessクラスです。サーブレットから呼び出したgetCount()メソッドはここに実装されています。

package com.kmaebashi.accesscounter.dbaccess;
import java.sql.*;
import javax.naming.*;
import javax.sql.*;

public class AccessCounterDbAccess {
    public static int getCount(String counterId) throws Exception {
        Context context = new InitialContext();
        DataSource ds = (DataSource)context.lookup("java:comp/env/jdbc/accesscounter");

        int current;
        try (Connection conn = ds.getConnection()) {
            conn.setAutoCommit(false);
            current = getCurrentCount(conn, counterId);
            updateCounter(conn, counterId, current + 1);
            conn.commit();
        }

        return current;
    }

    private static int getCurrentCount(Connection conn, String counterId) throws Exception {
        final String sql = """
SELECT COUNTER FROM ACCESSCOUNTER
WHERE COUNTERID=?
FOR UPDATE
""";
        PreparedStatement ps = conn.prepareStatement(sql);
        ps.setString(1, counterId);
        ResultSet rs = ps.executeQuery();
        rs.next();
        int ret = rs.getInt("COUNTER");

        return ret;
    }

    private static int updateCounter(Connection conn, String counterId, int nextCount)
        throws Exception {
        final String sql = """
UPDATE ACCESSCOUNTER SET
COUNTER = ?,
UPDATED = now()
WHERE COUNTERID=?
""";
        PreparedStatement ps = conn.prepareStatement(sql);
        ps.setInt(1, nextCount);
        ps.setString(2, counterId);
        int ret = ps.executeUpdate();

        return ret;
    }

    public static void main(String[] args) throws Exception {
        int counter = getCount("kmaebashi");
        System.out.println(counter);
    }
}

DBにアクセスするにはConnection(java.sql.Connection)が必要ですが、ここでは、以下の2行で「DataSource」を取得し、そのgetConnection()メソッドでConnectionを取得しています(12行目)。

  8:         Context context = new InitialContext();
  9:         DataSource ds = (DataSource)context.lookup("java:comp/env/jdbc/accesscounter");

このContextというのは、JNDI(Java Naming and Directory Interface)の基本となるクラスで、今回はcontext.xmlという設定ファイルにTomcatのコネクションプールの設定を記述して「jdbc.accesscounter」という名前でデータソースを登録し、それを9行目のcontext.lookupメソッドで取得しています。context.xmlでのデータソースの登録については後述します。

さて、こういうカウンタ的なものを作るのであれば、たとえば以下のような方法が考えられるでしょう。

  1. 現在のカウンタの値を取得する(これを表示に使う)。
  2. 取得したカウンタの値に1を加えたものを、新しいカウンタの値としてセットする。

このカウンタでも、この方法を使っています。しかし、Webで公開するアクセスカウンタというものは、世界中のブラウザからアクセスされるわけなので、マルチスレッドで動作することを考慮する必要があります。たとえば、現在のカウンタの値が100で、ほぼ同時に2か所からアクセスがあったとき、以下のような流れになってしまうかもしれません。

  1. スレッドAが、現在のカウンタの値100を取得する
  2. スレッドBが、現在のカウンタの値100を取得する
  3. スレッドAが、取得した値に1を加えた101を新しいカウンタの値としてセットする。
  4. スレッドBが、取得した値に1を加えた101を新しいカウンタの値としてセットする。

この例では、2回のアクセスがあったのに、カウンタは1しか増えません。

そこで、このプログラムでは、トランザクションを使って(13行目のconn.setAutoCommit(false)でauto commitを切って)、カウンタの値を読みだすときにSELECT ~ FOR UPDATEを使って行ロックをかけています。

――いや、実は、アクセスカウンタなら、

  1. UPDATE ACCESSCOUNTER SET COUNTER = COUNER + 1 ~でカウンタを1増やす。
  2. その後で、現在のカウンタの値を取り出す。

とすれば別にauto commitのままでいいし、カウントアップした後の値を表示するほうがカウンタとしては自然な気がするし、こっちの方がよいのでは、と後になって気付いたのですが気付かなかったことにします。

Tomcatのコネクションプールを使う

上のAccessCounterDbAccessクラスでは、「Context」からlookup()メソッドでDataSourceを取得しました。このDataSourceの設定は、META-INFフォルダの下のcontext.xmlに記述してあります。

<?xml version="1.0" encoding="UTF-8"?>
<Context path="/accesscounter" docBase="/accesscounter" reloadable="true">
  <Resource name="jdbc/accesscounter"
    auth="Container"
    type="javax.sql.DataSource"
    username="accesscounteruser"
    password="XXXXXXXXX"
    driverClassName="org.postgresql.Driver"
    url="jdbc:postgresql://localhost/accesscounterdb?currentSchema=accesscounter"
    validationQuery="SELECT * FROM ACCESSCOUNTER" />
  />
</Context>

DBの接続先や、接続のためのユーザ、パスワード等はここに記述しています。「jdbc/accesscounter」を設定しているname属性は、AccessCounterDbAccessクラスでlookup()メソッドにて検索している際のキーになります。

上の例ではデフォルトなので省略していますが、Resource要素にはfactoryという属性があります。factoryjavax.naming.spi.ObjectFactoryインタフェースを実装したファクトリクラスの完全修飾クラス名を指定しておくと、lookup()された時にJNDIがファクトリのgetObjectInstance()メソッドを呼び出して何らかのオブジェクトを生成して返すようになっているようです。コネクションプールの場合、何度lookup()を呼ばれても同じオブジェクトを返さないといけませんが、その指定はsingleton属性で行います。ただし、これのデフォルトはtrueなので、上では指定していません。

その他、Resource要素に指定できる属性についての説明は以下にあります。

https://tomcat.apache.org/tomcat-10.0-doc/config/context.html#Resource_Definitions

ユーザ名やパスワードの設定はコネクションプールというリソースに依存するものです。Apache Commons DBCP2で指定できる属性の説明は以下にあります。

https://commons.apache.org/proper/commons-dbcp/configuration.html

Tomcatには、Apache Commons DBCP2の他、Tomcat独自のコネクションプールであるTomcat JDBC Poolも付属しています。これを使うにはfactory属性にorg.apache.tomcat.jdbc.pool.DataSourceFactoryを指定すればよいです。

上の例では、validationQuery属性にSELECT文を指定してますが、これはDBがちゃんと動いているかを確認するためのSQLを指定します。もちろん指定しなくても構いませんが、指定する場合、SELECT文で、かつ最低でも1件のレコードを返すようなSQLにしなければいけません。

コンパイル

たとえばWindows環境で、Tomcat10をCドライブ直下に入れている場合、以下のようにservlet-api.jarにclasspathを通せばコンパイルできます。

javac -classpath C:\Tomcat10\lib\servlet-api.jar^
 com\kmaebashi\accesscounter\*.java ^
 com\kmaebashi\accesscounter\dbaccess\*.java

行末に「^」が入っているのは、私がこれをバッチファイルにしたからで、バッチファイルではコマンドの途中で改行を入れる時には「^」を書くからです。今回、私は、IDEとかは使わずただのテキストエディタ(xyzzy)を使ってJavaのソースを書いて、バッチファイルでビルドして、C:\Tomcat10\webappsの下に置いて動かす、というサイクルでこれを作りました(AccessCounterDbAccessにテスト用のmain()が残っているのもその名残です)。たとえばこのサイトの掲示板などはIntelliJで書きましたが、この程度のプログラムならテキストエディタでガリガリ書くのもありかと思っています。掲示板にしろアクセスカウンタにしろ、今回のサイト移転まで20年近く使い続けたことを考えたら、アクセスカウンタも次のメンテは20年後になるかもしれず、その頃IntelliJがどうなってるかもわかりませんし。

web.xml

web.xmlは、まあ、普通です。/showというURLを、AccessCounterServletに対応付けています。

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                      http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
  version="3.1"
  metadata-complete="true">

  <servlet>
    <servlet-name>AccessCounter</servlet-name>
    <servlet-class>com.kmaebashi.accesscounter.AccessCounterServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>AccessCounter</servlet-name>
    <url-pattern>/show</url-pattern>
  </servlet-mapping>
</web-app>

warを作る

開発中はこれでよいとして、これを公開用のサーバに置く際には、warファイルに固めるのが普通でしょう※3

warファイルを作るには、たとえばwebapps以下のaccesscounterディレクトリで、以下のコマンドを実行すればOKです。

C:\Tomcat10\webapps\accesscounter>jar cvf accesscounter.war *

あとはこれを公開サーバのwebappsフォルダに置けばTomcatが勝手に展開して動作させてくれます(私はいつも念のため、Tomcatを止めて配置し、その後起動していますが)。

おわりに

たかがアクセスカウンタでもこうやってサーブレットから作ってデプロイまでやるとなると私自身何かと勉強になりました。特にWeb系のプログラミングだと、最近は「ライブラリやフレームワークをどう組み合わせるか?」みたいな話になりがちですが、自力でゴリゴリコードを書くのは楽しいですよやっぱり。

いやいやゴリゴリコードを書くと言いながらTomcatを使うなど軟弱な、と思う方には、拙著「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」をお勧めします。Javaで、Webサーバを作るところから始め、へなちょこ版TomcatであるサーブレットコンテナHenacatを作るという本です。DBは使ってませんがレスポンスに画像を返す例としてアクセスカウンタも扱っています。Webアプリケーションを、基礎から知りたい、という方には、著者としての商売っ気抜きでもお勧めできる本だと自負しております。2016年の本なので紙の本は本屋では見つけにくいかもしれませんが、amazonにはあるようですし、Kindle版なら今すぐ読めます!

以下、アフィリエイトリンクです。Amazonで画像付きリンクが作れなくなっているので自分で画像貼った……(´・_・`)

Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門

Kinde版はこちら

  • 出版社…技術評論社
  • ISBN…978-4-7741-8188-2
  • 定価…本体2,680円 + 税

技術評論社さんによる紹介ページ

公開日: 2023/12/30



ひとつ上のページへ戻る | トップページへ戻る