argius note

プログラミング関連

ネットワークが切断されたことをアラームで知らせるツールを作る(解説付き)

我が家のパソコンのうちWindows8.1のノートパソコンだけ、ルーターとの相性が悪いのか、2日に1回程度はネットワークが切断されてしまいます。
そのため、2クリックでネットワーク接続を再起動するスクリプトを書いて対応しています。


これ自体は上手くいっているのですが、何か作業中に切断されてそれに気づかないで作業を続行したりすると、困る場合もけっこうあります。
切断されてしまうのは仕方ないとして、なるべく早くそれに気づきたい。


それなら、ネットワークがつながっているかを定期的にチェックして、切れていたらアラームで通知する仕掛けを用意すれば良いのでは?と思いました。
こういうのはフリーソフトなどでもありそうですが、カスタマイズが面倒な気がするので、自分で作ってみることにしました。

WindowsのアプリなのでC#とかで作った方が楽そうですね。今回は残念ながら私の知識不足により調べないとできないことが多いので、ほとんど調べずにできるJavaで書いてみます。


今回は、作る流れも含めて記事を書いてみました。


環境

いまさらJava7で書く必要あるのかと言われそうですが、今回はJava8の機能が絶対必要というところが無いので、Java7でも動くものにしてみます。

OSは、WindowsでなくてもOKなはずです。Macでは動かしてみました。


軽く設計

最低限必要な機能は、

  • ネットワークが切断されたことを検知する
  • 切断を検知したとき、アラーム通知する

だけです。

ネットワークが切断されたことを検知するには、インターネット上のファイルに定期的にアクセスして、そこにアクセスできなくなったら切断されたものと見做すことにしましょう。
インターバルは、15秒くらい。この値は、プロパティーで変更できるようにしておきます。

ファイルは、alive.txtをどこか自分が所有しているインターネットでアクセス可能なサーバーに置いて、中身はargiusと書いておきます。
どこかのポータルサイトでも良いのかも知れませんが、他人様のサイトを使うのは気が引けますのでこれで。


アラーム通知は、何か音を鳴らせば良いですね。
音声ファイルはカレントディレクトリーに置いて、後で置き換えられるようにします。


軽く実装

インターネット上のファイルにアクセス

インターネット上のファイルにアクセスするには、Apache HttpClientを使うのが便利ですね。


ただ今回は、1つの特定のファイルを読み込むだけなので、標準ライブラリーだけで十分でしょう。
主にjava.net.HttpURLConnectionを使っています。


いくつかの異常ケースが考えられますが、ファイルが読めて内容が一致している場合以外はすべてアウトにします。

  • コード:インターネット上の所定のファイルにアクセスを試みる

// import java.io.*;
// import java.net.*;
// import java.util.Arrays;

static final String urlString = "..."; // インターネット上のalive.txtの場所
static final byte[] expectedFileContent = "argius".getBytes();
    static final int expectedFileLength = expectedFileContent.length;

/**
 * インターネット上の所定のファイルにアクセスを試みる。
 * @return 正常にアクセスできた場合は <code>true</code>、そうでなければ <code>false</code>
 */
static boolean tryToAccessInternetFile() {
    try {
        URL url = new URL(urlString); // throws MalformedURLException
        HttpURLConnection conn = (HttpURLConnection)url.openConnection();
        int status = conn.getResponseCode();
        if (status == 200) {
            try (InputStream is = conn.getInputStream()) {
                byte[] bytes = new byte[expectedFileLength];
                if (is.read(bytes) == expectedFileLength && Arrays.equals(bytes, expectedFileContent)) {
                    return true; // 正常
                }
                else {
                    // 異常(想定外)
                    System.err.println("file content mismatch");
                }
            }
        }
        else {
            // 異常(想定外)
            System.err.println("unexpected response status: " + status);
        }
    } catch (IOException e) {
        // 異常(おそらくネットワーク切断)
        e.printStackTrace();
    }
    return false;
}


本当は結果を多値で返したいところですが、ここではbooleanだけとします。

このメソッドを定期的に呼び出して、falseだったら切断されているものとして処理します。


アラーム通知する

これも標準ライブラリーのJava Sound APIjavax.sound.*)のjavax.sound.sampledを使ってWAVファイルを再生させます。
MP3だと標準ライブラリーだけではできないみたいです。

カレントディレクトリーにalarm.wavという名前のファイルを置いて、それを再生させるようにします。音を変えたい場合は、このファイルを差し替えます。

  • コード:WAVファイルを再生する

// import java.io.*;
// import javax.sound.sampled.*;

/**
 * アラームを鳴らす。
 * @throws Exception 例外
 */
static void soundAlarm() throws Exception {
    File file = new File("./alarm.wav");
    AudioInputStream ais;
    try {
        ais = AudioSystem.getAudioInputStream(file);
    } catch (UnsupportedAudioFileException e) {
        throw e;
    } catch (IOException e) {
        throw e;
    }
    AudioFormat format = ais.getFormat();
    DataLine.Info info = new DataLine.Info(Clip.class, format);
    try (Clip line = (Clip)AudioSystem.getLine(info)) {
        line.open(ais);
        ais.close();
        line.start();
        while (!line.isRunning()) {
            Thread.sleep(100L); // throws InterruptedException
        }
        while (line.isRunning()) {
            Thread.sleep(100L); // throws InterruptedException
        }
        line.drain();
    } catch (LineUnavailableException e) {
        throw e;
    } catch (IOException e) {
        throw e;
    }
}


どのような例外が発生するかが分かるようにtry-catchが不要と思われる箇所も書いています。まとめてしまっても構いません。

スリープしている箇所が2か所ありますが、これは、line.start()の直後ではline.isRunning()trueになっていないことがあるため、一度line.isRunning() == trueになっていることを確認してから本来のスリープに入るようにしています。

一定時間ごとに処理を繰り返す

前述の2つの処理を組み合わせて、定期的に呼び出すようにします。

ここは、ScheduledExecutorServiceを使います。



  • コード:一定時間ごとに処理をスケジュール

// import java.util.concurrent.*;

final int intervalSeconds = 15;
Runnable task = new Runnable() {
    @Override
    public void run() {
        try {
            boolean ok = tryToAccessInternetFile();
            System.out.printf("%s: check = %s%n", new Date(), ok);
            if (!ok) {
                soundAlarm();
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(0);
        }
    }
};
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(task, 0, intervalSeconds, TimeUnit.SECONDS);
future.get();


例外処理とかFutureの扱いが雑ですが...まあ良いでしょう。

インターバルをシステムプロパティーで指定する機能も、後で追加します。


これで、最低限の処理ができました。Jarなりを作ってコマンドプロンプトから実行すれば、希望した動作をしてくれます。


でも、これだけだとさすがにちょっと不便なので、ユーザーインターフェイスを付けてみましょう。



ユーザーインターフェイスを追加

ウィンドウが開きっぱなしなのは邪魔なので、タスクトレイに入れて常駐させるようにしてみます。

タスクトレイを使うには、java.awt.SystemTrayクラスを使います。

タスクトレイに格納した後で、監視の処理を呼び出します。

ついでに、インターバルをシステムプロパティーで指定する機能も追加しました。


適当なicon.pngを用意して、Javaファイルと同じ場所に置きます。

タスクトレイに格納するアイコンファイルは、サイズが縦横16pxの正方形でなければいけませんのでご注意ください。

  • コード:タスクトレイに常駐させる

※一部省略しています

import java.awt.AWTException;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.Date;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.swing.ImageIcon;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;

public final class NetworkAvailableChecker {

    static final String title = "ネットワーク死活監視";
    static final String urlString = "..."; // TODO ファイルのURLを設定
    static final byte[] expectedFileContent = "argius".getBytes(); // TODO ファイルの内容を設定
    static final int expectedFileLength = expectedFileContent.length;

    final AtomicBoolean suspend = new AtomicBoolean(false);

    NetworkAvailableChecker() {
        if (!SystemTray.isSupported()) {
            throw new UnsupportedOperationException("プラットフォームがシステムトレイに対応していません");
        }
        SystemTray tray = SystemTray.getSystemTray();
        Image image = new ImageIcon(getClass().getResource("icon.png")).getImage();
        TrayIcon icon = new TrayIcon(image, title, buildPopupMenu());
        try {
            tray.add(icon);
        } catch (AWTException e) {
            throw new IllegalStateException("タスクトレイにアイコンが追加できませんでした", e);
        }
    }

    PopupMenu buildPopupMenu() {
        PopupMenu popup = new PopupMenu();
        final MenuItem suspendItem = new MenuItem("保留");
        suspendItem.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                String oldLabel = suspendItem.getLabel();
                boolean state = suspend.get();
                String newLabel = state ? "保留" : "再開";
                suspend.compareAndSet(state, !state);
                suspendItem.setLabel(newLabel);
                System.out.printf("%s: [%s]が実行された%n", new Date(), oldLabel);
            }
        });
        MenuItem exitItem = new MenuItem("終了");
        exitItem.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.printf("%s: 終了が実行された%n", new Date());
                exitApp();
            }
        });
        popup.add(suspendItem);
        popup.add(exitItem);
        return popup;
    }

    void exitApp() {
        System.exit(0);
    }

    void runChecker() {
        /*
         * このメソッドはイベントディスパッチスレッドで実行しないこと
         */

        // システムプロパティーを読み込む
        final int intervalSeconds = Integer.getInteger("NetworkAvailableChecker.interval", 15);
        System.out.println("intervalSeconds: " + intervalSeconds);
        Runnable task = new Runnable() {
            @Override
            public void run() {
                if (suspend.get()) {
                    return; // 保留中なら何もしないで抜ける
                }
                try {
                    boolean ok = tryToAccessInternetFile();
                    System.out.printf("%s: check = %s%n", new Date(), ok);
                    if (!ok) {
                        soundAlarm();
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    exitApp();
                }
            }
        };
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(task, 0, intervalSeconds, TimeUnit.SECONDS);
        try {
            future.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * インターネット上の所定のファイルにアクセスを試みる。
     * @return 正常にアクセスできた場合は <code>true</code>、そうでなければ <code>false</code>
     */
    static boolean tryToAccessInternetFile() {
        // 省略
    }

    /**
     * アラームを鳴らす。
     * @throws Exception 例外
     */
    static void soundAlarm() throws Exception {
        // 省略
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                try {
                    final NetworkAvailableChecker o = new NetworkAvailableChecker();
                    Executors.newSingleThreadExecutor().execute(new Runnable() {
                        @Override
                        public void run() {
                            o.runChecker();
                        }
                    });
                } catch (Exception e) {
                    e.printStackTrace();
                    JOptionPane.showMessageDialog(null, e.getMessage());
                }
            }
        });
    }

}


ここで注意が必要なのが、イベントディスパッチスレッドで監視の処理を呼び出してはいけないということです。
イベントディスパッチスレッドのまま通常のループ処理を呼び出してしまうと、本来イベントディスパッチスレッドで行うべき処理、つまりGUI関連の処理が完全にストップしてしまいます。
監視処理はmainメソッドから直接呼び出すか、新たなスレッドで実行するようにします。

今回のケースでは、状態を持たせる関係上、イベントディスパッチスレッドから新たなスレッドを起動して実行させるようにしています。



実行すると、タスクトレイにアイコンが出て、タイトルのツールチップが表示されました。

f:id:argius:20160224164515p:plain


右クリックすると、ポップアップメニューが表示されます。

f:id:argius:20160224164520p:plain


保留を選択すると、監視処理が常にスキップする状態になります。そして、メニューが「再開」に変わるようになっています。



Macでは、上のバーのネットワークのインジケーターの近くに表示されますね。DockにJavaアイコンが表示されてしまうのが不恰好ですが...



まとめ

最後に、ひとつのファイルにまとめた完成版を、Gistにアップしておきます。


Javaでなくても良いので、こうパパッと思いついたものを作れるように何か手に馴染んだ言語があると便利ですね。



自分以外の方にも使ってもらうなら、改善すべき点がたくさんあります。

この程度の規模のアプリなら必要があれば都度改善していけば良いですが、もっと本格的なツールを作ったなら、ハードコーディングしているパラメーターが多いので設定ファイルにするとか、国際化とか、メッセージはログに出力するとか、いろいろしないといけません。



なお、このツールを動かしているおかげなのか、ネットワーク接続が安定しているような気がします。
ツールの意味って...
まあそれはそれで喜ばしいことですけどね。



(おわり)