hidekatsu-izuno 日々の記録

プログラミング、経済政策など伊津野英克が興味あることについて適当に語ります(旧サイト:A.R.N [日記])

最後の工数見積の海を漂う(画面・帳票・バッチ割合導出編)

前回のエントリで外れ値を分析したが、「画面、帳票、バッチの比率 1.5 : 1.0 : 0.7 という数値は感覚的なもので根拠がない」という問題については残されたままとなっていた。

hidekatsu-izuno.hatenablog.com

以前のエントリで書いた通り、比率を正しく求める手順としては、主成分分析からの重回帰分析が一般的なのだけど、正規分布から外れる場合にはうまく分析できないという問題がある(主成分分析を経由する場合、切片を0にする事ができないという問題もある)。

いろいろ考えたのだが、例えば画面については機能数あたりの画面数で人時工数に重みを付けた上で分位点回帰を行えば、画面あたりの係数が求まる。これを帳票、バッチについても行えば、それなりに妥当な結果が得られるのではなかろうか。

というわけでやってみた結果は以下の通り。なお、箱ひげ図における外れ値は対象外として分析している(前回は、第一分位点~第三分位点に絞ってみたが小規模案件が相対的に減ってしまい、むしろ変な結果になることがわかったため通常の外れ値を使うことにした)。

画面の係数=105.4706
帳票の係数=110.4000
バッチの係数=50.8271

だいたい、画像:帳票:バッチ=1.0:1.0:0.5 というところだろうか。この係数を元に分位点回帰を行うと係数は 1.0247 となる。人月工数に換算すると次の通り。

人月工数=0.68 * 画面数 + 0.71 * 帳票数 + 0.33 * バッチ数

わりあい妥当な線かもしれない。

最後の工数見積りの海を彷徨う(外れ値分析編)

過去、幾度となくIPAの資料に基づき工数見積りを計算するためのエントリを書いた。

hidekatsu-izuno.hatenablog.com

最後のエントリを書いてからしばらくが経ち「ソフトウェア開発データ白書2018-2019」が出ていたのも知っていたけど、前回の分析データで概ね満足を得ていたので放置していた。

とはいえ、課題がなかったわけでもなく、次の点はなんとかしたいな、と思っていた。

  • ソフトウェア開発データ白書2016-2017 だけで分析した時の数値が 2014-2015 の時の数値と大きく違っており、原因を調べる必要がある。
  • 画面、帳票、バッチの比率 1.5 : 1.0 : 0.7 という数値は感覚的なもので根拠がない

前者については、一旦、両方のデータをひとつにして分析することでむりやり解決したのだが、本来はきちんとデータを見る必要がある。今回のエントリは、そこらへんを検討しようという趣旨となっている。

実際どれくらい違うのかというと

データ 分位点回帰で得られた係数
2014-2015 65.05
2016-2017 116.27
2018-2019 137.05
全体 87.23

2014-2015 と 2018-2019 では倍の開きがある。分析手法に難がある証拠だ。

まず、データとなるソフトウェア開発データ白書は3回分ある。2014-2015、2016-2017、2018-2019 の3つ。このデータを工数でマッチングし、人時工数、画面数、帳票数、バッチ数の一覧を作成した。

どう分析するのがいいかなと思ったのだが、多次元のデータをそのまま分析するのは難しいので、人時工数、機能数(=画面数+帳票数+バッチ数)、機能あたりの人時工数の3つの指標を箱ひげ図で表示してみた。

人時工数

まず、人時工数を見ていく。

f:id:hidekatsu-izuno:20201122091333p:plain
人時工数(箱ひげ図)

予想はしていたが、想像を上回る外れ値の多さ。とはいえ、実は中央値にはさほどの開きがない(オレンジの線が中央値)。箱ひげ図が見えるように範囲を絞ってみる。

f:id:hidekatsu-izuno:20201122092508p:plain
人時工数(箱ひげ図/50000人時以下)

分位点回帰は、本当の分位点とは異なり各分位点から遠いデータの重みを下げることで調整しているため、外れ値のデータの影響を完全に除去できていないのではと思われる。

機能数

次に機能数。

f:id:hidekatsu-izuno:20201122102258p:plain
機能数(箱ひげ図)

こちらも人時工数ほどではないが、同様の傾向が見られる。

f:id:hidekatsu-izuno:20201122102522p:plain
機能数(箱ひげ図/500機能以下)

機能あたりの工数

では、機能あたりの工数はどうか。傾向が同じということは打ち消し合うかと思ったが、そうでもない。

f:id:hidekatsu-izuno:20201122102807p:plain
機能あたりの人時工数(箱ひげ図)

f:id:hidekatsu-izuno:20201122103024p:plain
機能あたりの人時工数(箱ひげ図/800人時以下)

外れ値の除去

とりあえず外れ値が問題になっていそうなことはあきらかだ。外れ値の除去の方法としては、

  • スミルノフ・グラブス検定を使う
  • 箱ひげ図の第3分位点と第1分位点からIQR(第3分位点-第1分位点)の1.5倍以上離れたデータを外れ値とする
  • 機械学習の異常値検知を行う

がある。統計で使うのは、最初の2つだが、スミルノフ・グラブス検定は正規分布に従うことが前提であるので、今回のデータに使うのは適切とは言えない。箱ひげ図を使うべきだろう。

また、外れ値の除去の影響を緩和する方法として

  • ウィンソライズ:外れ値を最大値や最小値、特定のパーセンタイル値に置き換える
  • トリミング:外れ値を削除する

の2方式がある。影響緩和という意味ではウィンソライズの方がよさそうに思えるが、実際のデータを見ると、3バッチで201人月のような特殊すぎるものも含まれているし、経験的にも工数の極端に大きい機能があるようなプロジェクトは特殊なものだと思われるため、除去する方が適切だろう。

機能ごとの人時工数の外れ値を実際に除去した上で分位点回帰を行ったのが下記の結果となる。年ごとの結果のばらつきが多少は抑えられていることがわかる。

データ 分位点回帰で得られた係数
2014-2015 63.72
2016-2017 116.27
2018-2019 115.23
全体 78.36

とはいえ、まだまだばらつきが大きいようだ。

さらに除去するとどうなるか

だいぶ除去できたとはいえ、それでも1機能あたり5人月弱のデータが含まれている。これを第3分位点で切れば、1機能あたり3人月弱までのデータに制限することができる。統計的に適切かはわからないが、第1分位点~第3分位点のデータに限定して実施してみた結果は次のようになる。

データ 分位点回帰で得られた係数
2014-2015 116.27
2016-2017 117.79
2018-2019 137.05
全体 122.38

結果のばらつきがほとんどなくなってきたのがわかる。この結果を正とするなら、

人月工数=1.14 * 画面数 + 0.76 * 帳票数 + 0.54 * バッチ数

となる。

Python で主成分分析(PCA)する

工数の分析にあたり、画面、帳票、バッチの重み付けをシステマティックに行いたいという積年の課題があり、単純に考えると重回帰分析を行えばという話なのだけど、実際にやってみるとまったく予想外の結果がでる。

df= pd.read_csv('./IPA_2014-2019.csv')
model = smf.ols('MH ~ VC + RC + BC - 1', df)
result = model.fit()
result.summary()
      coef        std err   t         P>|t|    [0.025     0.975]
VC    276.7189    23.494    11.778    0.000    230.532    322.906
RC    173.3967    49.852     3.478    0.001     75.391    271.403
BC    -15.2673     9.259    -1.649    0.100    -33.469      2.934

なんとバッチの係数がマイナス! これを信じて工数計算を行うとバッチ本数を大量に増やすことでゼロカロリーならぬゼロ工数を実現できてしまう。*1

色々考えていたところ、uncorrelated 氏に「多重共線性が出ているので、何か説明変数を落とすか、主成分分析で変数を合成するなりした方が良さそう」との助言を頂いた。

ようは変数間の相関が無視できないということだ。この事自体は前から気付いていたのだけど、説明変数を落とすのは工数を決めるための式を求めるそもそもの目的にそぐわないし、主成分分析は軸を変えてフィッティングするという程度しか知識がなく画面、帳票、バッチの数値からどうやって軸を変えて工数を算出すればいいのかよくわからなかったので、手を出すのを躊躇していたのであった。

助言をもらったからには重い腰を上げてやってみようということで、今回のエントリはその作業メモ。

まず、主成分分析だけど基本的には、元の説明変数の軸を変換してよりフィットしやすい説明変数を作り出そうというもの。単に説明変数を作り出すだけだとあまり意味はないけれども、作り出された説明変数を落とすことで、元の説明変数すべての影響を残すことができる。一般的には累積寄与率が7、8割を超えるところまで残すのがよいとされているようだ。

Python で主成分分析をする方法を調べてみると、statsmodels を使う方法と scikit-learn を使う方法があることがわかった。ただ、statsmodels の方はあまりこなれていない印象で、scikit-learn を使う方がよさそうだ。

scikit-learn で主成分分析

from sklearn.decomposition import PCA
df = pd.read_csv('./IPA_2014-2019.csv')
X = df[["VC", "RC", "BC"]].values

pca = PCA()
pca.fit(X)

結果は、pca オブジェクト内に保持される。まず、寄与率を調べる。寄与率は pca.explained_variance_ratio_ で取得できる。

array([0.85622803, 0.11874323, 0.02502874])

結果を見る限り、最初の要素だけで8割超えているので、第1主成分だけでほとんど説明できると解釈できる(本当か?)。そこで、第一主成分のみで回帰を行う。

df2 = pd.DataFrame(columns=["C1", "C2", "C3"], data=pca.transform(X))
df2["MH"] = df["MH"]
model = smf.ols('MH ~ C1 - 1', df2) # C2、C3 は捨てる
ols = model.fit()

回帰分析の結果は、ols.params で取得でき、C1の係数 21.47954 だった。

これを画面、帳票、バッチ数からなる式にする必要がある。transform は、(元データの行列(X) - 元データの列ごとの平均(np.mean(X, axis=0))) × 主成分行列(pca.components_) という計算を行っているので、同じことを行って主成分に変換すればいい。

今回のデータの場合、最終的には次の結果が得られる。

人時工数 = 2.50 * 画面数 + 0.62 * 帳票数 + 21.32 * バッチ数 - 1459.15

見れば明らかだけれど、この結果も妥当とは言いがたい。係数がマイナスなのもそうだが、画面、帳票、バッチの比率も明らかにおかしい。これは線形回帰、主成分分析に共通する特徴として、外れ値に弱いことが原因と考えられる。

この問題に対応するためにロバストPCAという方法があるものの、scikit-learn にも statsmodels にも組み込まれておらず、野良ライブラリの dganguli/robust-pca を使う必要があるようだ(現段階では調査中)。

[追記] robust-pca を試してみたが、ほとんど外れ値として扱われてしまい元の行列とは似てもつかないものになってしまった。

以下の結果の S が外れ値、Lが外れ値を考慮した行列

import libs.r_pca as r_pca
pca3 = r_pca.R_pca(D=df[["VC", "RC", "BC"]])
L, S = pca3.fit()

statsmodels で主成分分析

statsmodels では次の方法で実行できるが、なぜか scikit-learn での結果とまったく一致せず断念してしまった。オプションの違いなのだろうか……

import statsmodels.multivariate.pca as pca
df = pd.read_csv('./IPA_2014-2019.csv')
result = pca.PCA(df[["VC", "RC", "BC"]])

*1:例によって、データはIPAソフトウェア開発データ白書のものを加工して利用。

PyMC3 を使ったベイズ一般化線形モデル回帰分析メモ

久々にIPAソフトウェア開発データ白書のデータを分析してみようと思い、ベイズ推定を使ってみたのだけど、データの分散が大きすぎるので分位点回帰みたいな方法使わないとだめだということがわかったのでメモだけ。

インストール

環境は Windows 10 の WSL2 Ubuntu 20.04 LTS。WSL2 は本当に便利。

sudo apt install python3 python3-pip python3-pandas jupyter-notebook
pip3 install pymc3

WARNING (theano.tensor.blas): Using NumPy C-API based implementation for BLAS functions. という Waning が出る場合は、MKL という NumPy を高速化する Intel 謹製ライブラリをインストールすると消える。

pip3 install mkl

ライブラリとデータの読み込み

ここは例によって例の通り。以下、y、x1、x2 の三列がある前提で書いている。

import pandas as pd
import matplotlib.pyplot as plt
import pymc3 as pm

indata = pd.read_csv('./indata.csv') # y, x1, x2

ベイズGLM

GLM用の便利関数が用意されているし、formula も使えるので一見簡単に使える。 これが実際には、いろいろと初期設定を変える必要があり結構詰まる。

with pm.Model() as model:
    pm.glm.GLM.from_formula('y ~ x1 + x2', indata)
    trace = pm.sample(draws=5000, chains=4, cores=1, start=pm.find_MAP(), step=pm.NUTS())

formula は indata の中の変数名を使える。y ~ x なら単回帰、y ~ x1 + x2 なら重回帰、切片はデフォルトであり扱いなので不要な場合は y ~ x1 + x2 -1 のように -1 をつけると切片=0になる。

draws はサンプルの生成回数。chains は生成するサンプル列の数。ベイズ推定の場合、MCMCを使うためいつも異なる結果を返すので、複数回実行して安定した結果を得られているか確認することができる。cores は並列実行数。

start は、初期の事前分布を設定するもので、find_MAP は MAP推定値を設定するものらしい。正しい使い方なのかはよくわからない。

step は、サンプルを生成するサンプラー。2値の場合は BinaryMetropolis、離散値の場合は Metropolis、連続値の場合は NUTS を使うと良いらしい。

from_formula メソッドの引数に family でリンク関数を priors を付けることで説明変数の事前分布を変えることができる。family に指定できるリンク関数は、Normal(正規分布)、StudentT(スチューデントのt分布)、Binomial(2項分布)、Poisson(ポアソン分布), NegativeBinomial (負の2項分布)の5つ。priors で使える分布はここで説明されているものが利用できる。後者はグラフとともに説明されているので大変わかりやすい。

    pm.glm.GLM.from_formula('y ~ x1 + x2', indata, 
         family = pm.glm.families.Normal(),
         priors = {
            'x1': pm.Normal.dist(mu=0., sigma=1.),
            'x2': pm.Normal.dist(mu=0., sigma=1.),
            'Intercept': pm.Normal.dist(mu=0., sigma=1.), // 切片は Intercept
        }
    )

trace にはサンプリングの結果が入っている。この結果は PyMC3 付属のプロット系関数でグラフ化して確認できる

pm.traceplot(trace[4000:]);
pm.summary(trace[4000:])
pm.plot_posterior(trace[4000:]);

trace[4000:] と書いているのは、5000回サンプルを生成したうち、安定したであろう 4000回以降を使うという意味。

traceplot でデータが収束しているかを判断できる。左側のグラフは同じ形で重なっている、右側は特定の値周辺に散らばっていれば、収束しているとみなしていい。summary の r_hat が 1.1 以下という判断でも良いらしい。

summary は各値の詳細、plot_posterior は summary 内容を可視化したもの。

続・Javaのクラス内が依存しているフィールド、メソッドの一覧を表示する (Java11 対応版)

大昔、「Javaのクラス内が依存しているフィールド、メソッドの一覧を表示する」という記事を書いたのだが、久々に使ってみるとどうも一部のメソッド呼び出しが抜けている。いろいろ調べていたのだが、なるほど invoke dynamic の仕業だった。

なんせ前回の記事を書いたのが 2013 年だから 7 年前か。無駄に歳を重ねてしまったものだ。おそらく当時は Java 1.7 だった……と考えるとそれほど変わったわけでもないな。1.6 と 1.8 の時代が大変長かったし、世の中的にはまだまだ Java 8 が現役だ。

閑話休題invoke dynamic 命令自体は Java 1.7 の頃から存在はしたものの、コンパイラが生成したコードに含まれるようになったのは Java 8 からで、主にラムダ式に使われている。そのため、前回の記事の時点ではあのコードで十分だったのだが、Java 8 以降で使うと漏れが発生してしまうわけだ。

そういうわけで、Javassistinvoke dynamic を扱う方法を調べていたのだが、これが意外とやっかいで困ってしまった。invoke dynamic 命令自体は、クラスの属性として定義されている Bootstrap Methods 属性に定義されている Bootstrap Method 配列の番号しか持っていない。そして Bootstrap Method は、実際のメソッドの呼び出しをラッピングするメソッド ハンドルを持っている。結局、invoke dynamic 命令⇒Bootstrap Methods 属性⇒Bootstrap Method 配列のひとつ⇒メソッド ハンドル⇒実際のメソッドと4回たどる必要があった。

最終的なソースコードは以下の通り。Bootstrap Method を取得するところと invoke virtual の前処理を除けば前回から大きな違いはない。

ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get(クラス名);

BootstrapMethod[] bsms = null;
for (AttributeInfo attr : cc.getClassFile().getAttributes()) {
    if (attr instanceof BootstrapMethodsAttribute) {
        BootstrapMethodsAttribute bsma = (BootstrapMethodsAttribute)attr;
        bsms = bsma.getMethods();
    }
}

for (CtBehavior cb : cc.getDeclaredBehaviors()) {
    MethodInfo info = cb.getMethodInfo2();
    ConstPool pool = info.getConstPool();
    CodeAttribute code = info.getCodeAttribute();

    if (code == null)
        return;

    CodeIterator i = code.iterator();
    while (i.hasNext()) {
        int pos = i.next();
        int opecode = i.byteAt(pos);
        int dynIndex = -1;
        if (opecode == Opcode.INVOKEDYNAMIC) {
            int index = i.u16bitAt(pos + 1);
            int bootstrap = pool.getInvokeDynamicBootstrap(index);
            BootstrapMethod bm = bsms[bootstrap];

            for (int arg : bm.arguments) {
                int tag = pool.getTag(arg);
                if (tag == ConstPool.CONST_MethodHandle) {
                    switch (pool.getMethodHandleKind(arg)) {
                        case ConstPool.REF_invokeVirtual:
                            opecode = Opcode.INVOKEVIRTUAL;
                            dynIndex = pool.getMethodHandleIndex(arg);
                            break;
                        case ConstPool.REF_invokeSpecial:
                            opecode = Opcode.INVOKESPECIAL;
                            dynIndex = pool.getMethodHandleIndex(arg);
                            break;
                        case ConstPool.REF_invokeStatic:
                            opecode = Opcode.INVOKESTATIC;
                            dynIndex = pool.getMethodHandleIndex(arg);
                            break;
                        case ConstPool.REF_invokeInterface:
                            opecode = Opcode.INVOKEINTERFACE;
                            dynIndex = pool.getMethodHandleIndex(arg);
                            break;
                    }
                    break;
                }
            }
        }

        switch (opecode) {
        case Opcode.NEW:
        case Opcode.ANEWARRAY:
        case Opcode.MULTIANEWARRAY: {
            int index = i.u16bitAt(pos + 1);
            
            System.out.print(cc.getName());
            System.out.print('\t');
            System.out.print(cb.getLongName());
            System.out.print('\t');
            System.out.print("new");
            System.out.print('\t');
            
            String className = pool.getClassInfo(index);
            if (opecode == Opcode.ANEWARRAY || opecode == Opcode.MULTIANEWARRAY) {
                className = "new " + className + "[]";
            }
            
            System.out.print(className);
            System.out.println();
            break;
        }
        case Opcode.NEWARRAY: {
            System.out.print(cc.getName());
            System.out.print('\t');
            System.out.print(cb.getLongName());
            System.out.print('\t');
            System.out.print("new");
            System.out.print('\t');
            
            String className = null;
            switch (i.byteAt(pos + 1)) {
            case Opcode.T_BOOLEAN: className = "new boolean[]"; break;
            case Opcode.T_CHAR: className = "new char[]"; break;
            case Opcode.T_BYTE: className = "new byte[]"; break;
            case Opcode.T_SHORT: className = "new short[]"; break;
            case Opcode.T_INT: className = "new int[]"; break;
            case Opcode.T_LONG: className = "new long[]"; break;
            case Opcode.T_FLOAT: className = "new float[]"; break;
            case Opcode.T_DOUBLE: className = "new double[]"; break;
            }
            
            System.out.print(className);
            System.out.println();
            break;
        }
        case Opcode.CHECKCAST: {
            int index = i.u16bitAt(pos + 1);
            
            System.out.print(cc.getName());
            System.out.print('\t');
            System.out.print(cb.getLongName());
            System.out.print('\t');
            System.out.print("cast");
            System.out.print('\t');
            
            String className = "(" + pool.getClassInfo(index) + ")";
            
            System.out.print(className);
            System.out.println();
            break;
        }
        case Opcode.GETSTATIC:
        case Opcode.PUTSTATIC:
        case Opcode.GETFIELD:
        case Opcode.PUTFIELD: {
            int index = i.u16bitAt(pos + 1);
            
            System.out.print(cc.getName());
            System.out.print('\t');
            System.out.print(cb.getLongName());
            System.out.print('\t');
            System.out.print("field");
            
            String className = pool.getFieldrefClassName(index);
            String fieldName = pool.getFieldrefName(index);
            String desc = pool.getFieldrefType(index);
            String typeName = Descriptor.toClassName(desc);
            
            System.out.print('\t');
            System.out.print(typeName);
            System.out.print(' ');
            System.out.print(className);
            System.out.print('.');
            System.out.print(fieldName);
            System.out.println();
            
            break;
        }
        case Opcode.INVOKEVIRTUAL:
        case Opcode.INVOKESPECIAL:
        case Opcode.INVOKESTATIC:
        case Opcode.INVOKEINTERFACE: {
            int index = dynIndex == -1 ? i.u16bitAt(pos + 1) : dynIndex;
            
            System.out.print(cc.getName());
            System.out.print('\t');
            System.out.print(cb.getLongName());
            System.out.print('\t');
            System.out.print("method");
            
            String className;
            if (opecode == Opcode.INVOKEINTERFACE) {
                className = pool.getInterfaceMethodrefClassName(index);
            } else {
                className = pool.getMethodrefClassName(index);
            }
            
            String methodName;
            if (opecode == Opcode.INVOKEINTERFACE) {
                methodName = pool.getInterfaceMethodrefName(index);
            } else {
                methodName = pool.getMethodrefName(index);
            }
            
            String desc;
            if (opecode == Opcode.INVOKEINTERFACE) {
                desc = pool.getInterfaceMethodrefType(index);
            } else {
                desc = pool.getMethodrefType(index);
            }

            String ret = null;
            List<String> params = new ArrayList<String>();
            if (desc.charAt(0) == '(') {
                int start = 1;
                boolean inClass = false;
                for (int j = start; j < desc.length(); j++) {
                    char c = desc.charAt(j);
                    if (inClass) {
                        if (c == ';') {
                            params.add(Descriptor.toClassName(desc.substring(start, j + 1)));
                            start = j + 1;
                            inClass = false;
                        }
                    } else if (c == ')') {
                        ret = Descriptor.toClassName(desc.substring(j + 1));
                        break;
                    } else if (c == 'L') {
                        inClass = true;
                    } else if (c != '[') {
                        params.add(Descriptor.toClassName(desc.substring(start, j + 1)));
                        start = j + 1;
                    }
                }
            } else {
                ret = Descriptor.toClassName(desc);
            }
            
            System.out.print('\t');
            System.out.print(ret);
            System.out.print(' ');
            System.out.print(className);
            System.out.print('.');
            System.out.print(methodName);
            System.out.print('(');
            for (int j = 0; j < params.size(); j++) {
                if (j > 0) System.out.print(',');
                System.out.print(params.get(j));
            }
            System.out.println(')');
            break;
        }
        }
    }
}

nuxt-history-state という Nuxt.js 用モジュールをリリースした

仕事で Nuxt.js を使っている。戻るボタンの扱いについて聞かれたので調べてみたところ、これが予想外に難しい。

戻るボタン対応は、Struts を代表とするポストバック系のフレームワークでも鬼門だったけれど、SPAの時代になってもあいかわらず鬼門のようだ。

Backボタンを押した際のブラウザ動作としては、タブ切り替えと同じ挙動の方がありがたいのではと思うのだけど、残念ながらそうはならない。元々、静的ページを前提にした仕組みだから、以前訪れたページがリロードされるような動きとなる。ただし、HTMLにはフォームというものがあるから、そこだけは考慮されていて、キャッシュが残っていればフォームの値だけは復元、なければ完全リロードとなかなかに際どい動きとなる。

もちろん、この動作はJavaScript全盛の今となっては好ましいものではない。フォームの値だけ残されたところで JavaScript のメモリ状態と整合性が取れず中途半端になってしまう。とはいえ、onunload など既存仕様と整合性が取れないことやメモリ状態も含めてキャッシュに保管するのは現実的ではないなど様々な理由が考えられ、この動作が変わることはないだろう。

Nuxt.js に関して言うと、すべての状態が JavaScript 管理となるため、フォームの値だけが残されるという中途半端な状態にはならない。$router.push による画面遷移とさして変わらない動作をする。逆に言えば、一般の静的ページで期待される動作を再現するためには工夫が必要になる、ということでもある。

きっと誰かがプラグインを作っているだろうと高をくくっていたのだけれど、意外にもこれといったものがない。たしかに多くの Web サービスでは、状態保持が必要な画面遷移もあまりないし、細かいことを気にしなければ Vuex で十分かもしれない。

とはいえ、私が仕事で関わるような業務系アプリケーションの場合、状態を伴う画面遷移の方がむしろ一般的なため、何らかの対応が必要となる。本来、仕事の一環なのだから業務時間中に作ればいいとは思うのだが、開発に専念できない現状で片手間にできるような内容ではないし、今後、別案件や個人的な開発にも流用したいことから、プライベートな時間を使ってモジュールを作成した。*1

そうして出来上がったのがこの nuxt-history-state だ。

このモジュールを作るにあたっては、Vue-Router の scrollBehavior と「vue-routerでbackwardかforwardかを判定する - Qiita」が参考になった。

基本的な方針としては、各ページにページ番号を持ち、画面遷移時にインスタンスの$data をページ番号に紐付け保管するという対応になる。ページ番号はブラウザ内で管理される履歴と紐付ける必要があるため、URL のクエリパラメータか history.pushState のいずれかで保持する必要がある。

クエリパラメータで紐付ける方が簡単なのはわかっていたが、URLが汚れる点が気になった。だから、history.pushState で何とかならないかと努力してみたものの、リロード後に Back ボタンを押した場合、popstate イベントが発生しない場合があることが判明したため、デフォルト動作ではリロードをサポートせず、オプションを設定することでクエリパラメータを使ったリロード対応モードになるようにした。

 このモジュールを使うと、通常は難しい Back/Forward の判定やリロード後の Back/Forward 継続など、履歴管理が一通りできるようになる。まだ、開発して間もないので、こうした方が良いのではなど意見があれば、Issue に起票してほしい。

*1:最近、NGINXの開発者が当時在籍していた会社からの著作権侵害報告で強制捜査を受けたというニュースが出たこともあり、センシティブな話かもしれないので、プライベートな時間を使って開発されたことを強調しておこう。私が公開しているライブラリは、NGINXがそうであったように、いずれも業務時間で開発したものではなく、プライベートな時間で開発したものを公開し、職場からは一般のライブラリと同じ形で利用するようにしている。いやほんと、業務時間中にOSS開発できる会社が羨ましいよ!

JEF4J v0.9.0 をリリースした。そして謎の文字の話。

JEF4J を久々に更新した。

以前、「JEF4J をリリースしてみた。あるいは、メインフレームの文字コードの話。」というエントリを書いたが、その時点では JEF の最終仕様が掲載されている「FACOM JEF 文字コード索引辞書 (1987年/第三版)」が手に入らなかったため、第二版をベースに他資料を援用して作成していた。

福岡大学の図書館に存在していることはわかっていたのだけれども、さすがに遠方で放置してたのだ。別件で福岡に旅行に来ているので、ついでにコピーを入手してきた。

いろんな文字が追加されているのではという予想を裏切り、JIS83 改正対応がほとんでだったようで、結果的に修正はほぼ必要なかった。

残る謎文字

ワープロ用途の特殊記号や同じ字母を持つものが登録済みの変体仮名を除くと Unicodeマッピングできない文字は次のものだけとなった。

JEF 字形 備考
71E0 武を字母とする変体仮名Unicode には存在せず。
71F7 変体仮名なのだが、字母すら不明。
72A8 得を字母とする変体仮名Unicode には存在せず。
72E1 女を字母とする変体仮名Unicode には存在せず。
73D5 富を字母とする変体仮名Unicode には存在せず。
74B9 変を字母とする変体仮名Unicode には存在せず。
75F2 雄を字母とする変体仮名Unicode には存在せず。
43C7 儲の異体字。違いが微妙すぎるのか、文字情報基盤にも同一字体なし。
6CF4 顛の異体字。違いが微妙すぎるのか、文字情報基盤にも同一字体なし。

マッピングして気づくのは、Unicode 登録の変体仮名はかなり中途半端な代物なのでは、という点だ。JEF が変体仮名をサポートしていたのなら、既存規格に合わせた方がまだ良かったように思える。とはいえ 71F7 の変体仮名については、それ系のサイトをいくつか調べてみたものの該当する文字すら見当たらず本当に謎なのではあるが。

儲、顛の異体字については、JEF79 で採用していた拡張漢字とJIS83 で採用された文字に微妙に差異があったため発生したもので、互換文字みたいなものだと思われる。

今後の予定

文字のマッピングアルゴリズムに多少改善の余地があるのと、Adobe Japan-1 対応を入れる可能性がある。ノー豆腐を目指した Noto Font を含め多くのフォントは Adobe Japan-1 の異体字セレクタはサポートしても、汎用電子の異体字セレクタはサポートしていない。

正直、あまりにもマイナーすぎてそんなに使われているとも思えないライブラリだけれども、今後発生するだろう公共系システムの移行に役立つ局面もあるとは思っているので、要望があればご連絡いただければと。