技術とか戦略とか

IT技術者が技術や戦略について書くブログです。

「新たな導線の追加」という現行踏襲案件

要件が「現行踏襲」である案件の代表的なリスクとして、「現行の仕様を調査してその仕様に合わせる工数が過小評価されやすい」というリスクがあります。
そして、要件が「新たな導線を追加する」である案件は、一見現行踏襲でないように見えて、実際は現行踏襲案件と同じようなリスクを抱えます。
 
今回は、私が体験した案件を例に出して説明します。
 
----
 
今回例として挙げるシステムは、「営業員がプランを勧めて、最終的にエンドユーザーが個人情報を入力する」という営業システムです。
この営業システムのUIは、前半の営業員画面と、後半のエンドユーザー画面に大別できます。
前半は営業員が使うことを想定したシンプルな画面であり、プランを表示・入力する機能があります。
後半はエンドユーザーが直接目にするリッチな画面であり、個人情報を入力する機能や、入力内容を確認する機能があります。
 
この営業システムについて、「営業員によるプランの勧誘もエンドユーザー画面上で行うようにして、エンドユーザーだけで操作が完結するようにしたい」という要件の保守案件が発生しました。
システム的に見た場合は、前半の営業員画面だけではなく、別のエンドユーザー画面(以下「新エンドユーザー画面」)でもプランの表示・入力を可能とする、というものです。言い換えれば、前半の導線を増やす、というものです。
 
ここまで説明したことを図に表すと以下の通りです。
 

f:id:akira2kun:20211110003349j:plain
 
新エンドユーザー画面もエンドユーザーが直接目にするため、リッチなUIが必要になります。要件として示されたデザイン案も、これまでシステムでは取り入れていなかったデザインが採用されていました。
そのため、見積もりの段階では、リッチなUIに対応することが焦点に置かれ、技術的なリスクが重く見られました。
 
しかし、技術的な問題は、一旦技術検証が済んでしまえば、その後にリスクとして顕在化することはありませんでした。
代わりに、実際にリスクとして顕在化し、工数増大の原因となったのは、現行踏襲のリスクでした。
 
新エンドユーザー画面では、営業員画面と同じ機能を提供する必要があります。
また、最終的には後半のエンドユーザー画面に合流するため、内部的なデータ構造も営業員画面と同じように保持する必要がありました。
更に、営業員画面と新エンドユーザー画面では、見せ方の違いにより入力順や入力内容が微妙に異なっていたため、単純な機能移植で済むというものでもありませんでした。
 
これらの要因により、「現行仕様をデータ構造まで踏み込むレベルで調査・理解した上で、営業員画面と新エンドユーザー画面の差異に気を付けながら仕様を策定する」という作業が発生しました。そして、この作業の工数は見積もり時点では過小評価されました。
そして、設計工程の工数増加や、仕様考慮漏れによるテスト工程での大量のバグ検出に繋がりました。
 
----
 
「要件が現行踏襲である案件は危ない」というのは良く言われることですが、要件に「現行踏襲」と書いてなくとも似たようなリスクを持つ案件というのも存在します。
この類のリスクは、「要件に現行踏襲と書いてあるか」という観点ではなく、「現行の仕様にどの程度合わせ込む必要があるのか」という観点で見つけるべきです。

例外ケースは処理の始めに除外する

プログラムで何かしらの処理を記述する場合、本当に実装したい処理(本処理)に入る前に、例外ケースを除外するテクニックがあります。
このように記述することで、本処理では例外ケースを考えずに済むため処理内容を考えやすくなりますし、例外ケースの場合に時間がかかる本処理を実行しなくて済むようになるので性能面でもメリットがあります。
 
例として、競技プログラミングの問題を取り上げて説明してみます。
 
今回取り上げる問題は以下です。
 100円玉がA枚、
 10円玉がB枚あります。
 X円を作る方法が何通りあるか出力しなさい。
 
ループ処理を実装して総当たりを行えばこの問題を解くことはできますが、その場合は性能面が犠牲になるため、望ましい解答ではありません。
今回は、ループ処理を使わずにこの問題を解いてみます。
(言語はJavaです。なお、本来は、標準入力を入力としますが、今回はソース中の定数を入力としています。)
 
----
 
【解答例】
・HundredYenTenYen.java
public class HundredYenTenYen {

    public static void main(String[] args){

        /* 問題
         * 100円玉がA枚、
         * 10円玉がB枚あります。
         * X円を作る方法が何通りあるか出力しなさい。
         * 入力: A B X
         */

        // 100円玉の枚数
        int A = 6; // 入力

        // 10円玉の枚数
        int B = 25; // 入力

        // 作りたい円
        int X = 550; // 入力

        System.out.println("入力:" + A + " " + B + " " + X);
        System.out.println("回答:" + execute(A, B, X));

    }

    public static int execute(int A, int B, int X) {

        // ①作りたい円が100円玉・10円玉の合計金額を超えていないか確認
        if ((A * 100) + (B * 10) < X) {
            return 0;
        }

        // ②作りたい円が10の倍数か確認
        if (X % 10 != 0) {
            return 0;
        }

        // ③持っている10円玉の枚数で、作りたい円の10の位を実現できるか確認
        if (B < (X % 100) / 10) {
            return 0;
        }

        // ④10円玉10枚の組はいくつあるか
        int BAfterPaidTensPlace = B - ((X % 100) / 10);
        int bulkOfTenTenYen = BAfterPaidTensPlace / 10;

        // ⑤100円玉だけの場合、作りたい円の100以降の位がどれくらい足りなくなるか
        int hundredsPlaceOfX = X / 100;
        int lackOfHundredYen = Math.max(hundredsPlaceOfX - A, 0);

        // ⑥10円玉で100円玉を代替するパターンがいくつあるか
        int patternOfReplacedHundredYenByTenYen
            = bulkOfTenTenYen - lackOfHundredYen;

        // ⑦総パターン数を返却
        return 1 + patternOfReplacedHundredYenByTenYen;

    }

}
 
【実行例】
・100円玉だけで100以降の位を払え、10の位を払った時に10円玉10枚組が減らない場合
入力:6 25 550
回答:3
 
 備考:以下の3パターンがある。
  100円玉5枚+10円玉5枚
  100円玉4枚+10円玉15枚
  100円玉3枚+10円玉25枚
 
・100円玉だけで100以降の位を払え、10の位を払った時に10円玉10枚組が減る場合
入力:6 25 460
回答:2
 
 備考:以下の2パターンがある。
  100円玉4枚+10円玉6枚
  100円玉3枚+10円玉16枚
 
・100円玉だけでは100以降の位を払えない場合
入力:6 25 840
回答:1
 
 備考:以下の1パターンがある。
  100円玉6枚+10円玉24枚
 
・持っている10円玉の枚数で、作りたい円の10の位を実現できない場合
入力:6 5 460
回答:0
 
・作りたい円が10の倍数ではない場合
入力:6 5 445
回答:0
 
・作りたい円が100円玉・10円玉の合計金額を超えている場合
入力:6 25 860
回答:0
 
----
 
この解答例では、「10円玉10枚の組み合わせは、100円玉1枚を代替できる」という点に着目し、④~⑦の箇所でパターン数を割り出しています。
 
そして、ポイントは、①~③で例外ケースを除外している所にあります。
①~③の例外処理を事前に噛ませることで、④~⑦に到達した時点で
・入力された金額は100円玉と10円玉で払い切れる
・入力された金額は必ず10の倍数である
・10の位を払う時に10円玉が足りなくなることはない
ということが保証されるため、④~⑦の処理を考えやすくなっています。
 
また、今回の例では当てはまりませんが、もし④~⑦が時間のかかる処理である場合は、例外ケースの場合に処理時間の短縮をすることもできます。
 
このように、例外ケースを事前に除外するテクニックが役に立つことがあるので、覚えておいて損はないでしょう。

処理時間はデータ量に比例するとは限らない

処理時間(計算量)はデータ量に比例するとは限りません。
例えば、データ量が10倍になったからと言って、処理時間も10倍になるとは限りません。
 
処理時間が何倍になるかは、アルゴリズムにより決まります。
アルゴリズム次第では、データ量が10倍になった時に処理時間が100倍になることも有り得ます。
 
今回は、試しに、Java作成したソート処理の実行時間を測ってみます。
アルゴリズムバブルソートです。
 
----
 
【サンプルコード】
・BubbleSort.java
import java.util.ArrayList;
import java.util.Collections;

public class BubbleSort {

    public static void main(String[] args) {

        // データ量(どちらかの行をコメントアウト
        int num = 10000;
        // int num = 100000;
        
        // ソート対象の配列
        ArrayList<Integer> array = new ArrayList<>();
        for (int i = 0; i < num; i++) {
            array.add(i);
        }
        Collections.shuffle(array);

        // 回答の配列
        ArrayList<Integer> answer = new ArrayList<>();
        for (int i = 0; i < num; i++) {
            answer.add(i);
        }

        // ソート処理実行
        long startTime = System.currentTimeMillis();
        sort(array);
        long endTime = System.currentTimeMillis();

        // 回答通りか確認
        int diffCount = 0;
        for (int i = 0; i < num; i++) {
            if (array.get(i).intValue() != answer.get(i).intValue()) {
                System.out.println( (i + 1) + "つ目の要素の内容を出力");
                System.out.println(" 結果:" + array.get(i));
                System.out.println(" 回答:" + answer.get(i));
                diffCount++;
            }
        }
        if (diffCount == 0) {
            System.out.println("差異無し。");
        }

        // 秒数出力
        System.out.println( (endTime - startTime) + " ミリ秒");

    }

    static void sort(ArrayList<Integer> array) {
        // 左から順番に値を確定させていく
        // 添字の最小は0、最大はarray.size() - 1
        for (int i = 0; i < array.size() - 1; i++) {
            // 走査は右から
            for (int j = array.size() - 1; j > i; j--) {
                // 入れ替え処理(左が右より大きければ入れ替え)
                if (array.get(j - 1) > array.get(j)) {
                    int tmp = array.get(j - 1);
                    array.set(j - 1, array.get(j));
                    array.set(j, tmp);
                }
             }
        }
    }

}
 
【実行時間】
・データ量が10000レコードの場合
 1回目:1272ミリ秒
 2回目:2826ミリ秒
 3回目:1075ミリ秒

・データ量が100000レコードの場合
 1回目:335986ミリ秒
 2回目:305728ミリ秒
 3回目:289040ミリ秒
 
----
 
以上のように、データ量が10倍になった場合に、実行時間も100倍(実測ではそれ以上)になりました。
 
なぜ実行時間がデータ量に比例しないのかと言うと、このアルゴリズムでは二重ループが発生するからです。
二重ループの中の処理が行われる回数は、10000レコードの場合は「1万 * 1万」(正確には「1万 * 9999」)回ですが、100000レコードの場合は「10万 * 10万」(正確には「10万 * 99999」)回となり、100倍の差となります。
この差が、実行時間にも反映されます。
 
専門用語で言うと、このような概念は「O(オーダ)」と呼ばれます。
(詳しくは、以前の記事(

https://cyzennt.co.jp/blog/2019/08/17/o%e3%82%aa%e3%83%bc%e3%83%80%e3%81%ae%e6%a6%82%e5%bf%b5%e3%81%a8%e5%ae%9f%e5%8b%99%e3%81%a7%e3%81%ae%e4%bd%bf%e3%81%84%e9%81%93/)で紹介しています)
 
実務でも、大量のデータを処理する必要がある場合は、「O(オーダ)」の概念や、最適なアルゴリズムを意識する必要があります。
競技プログラミングでは最適なアルゴリズムを考えさせる問題が頻出なので、詳しく学びたい方は手を出してみると面白いと思います。

JavaScript:String型かNumber型かわからない変数をNumber型に置き換えるコード

JavaScriptは動的型付けを採用した言語であるため、実行するまで変数の型が分からない状態になります。
この特徴により、数値計算を行う時を以下のような問題を引き起こすことがあります。
 
・加算を行うつもりが文字列結合になってしまう
 (例えば、100 + "100" は "100100"になる)
 
・計算結果がNaNになってしまう
 (例えば、カンマ等を含むString型をNumber()でNumber型に変換しようとした場合)
 
・桁が途中で落ちてしまう
 (例えば、カンマ等を含むString型をparseInt()でNumber型に変換しようとした場合)
 
これらの問題は、計算に使おうと思っていた変数がString型であり、そのString型を正しくNumber型に変換できていないことに起因します。
 
これらの問題は、以下のようなコードにより変数をNumber型に正しく変換することで、回避することができます。
 
【コード】
Number(input.toString().replace(/[^0-9\.]/g, ''));
 
【サンプルの実行結果】
・inputが整数のNumber型
var input = 1234;
var output = Number(input.toString().replace(/[^0-9\.]/g, ''));
console.log(output); // 1234
 
・inputが小数のNumber型
var input = 1234.5;
var output = Number(input.toString().replace(/[^0-9\.]/g, ''));
console.log(output); // 1234.5
 
・inputが整数のString型
var input = "1234";
var output = Number(input.toString().replace(/[^0-9\.]/g, ''));
console.log(output); // 1234
 
・inputが小数のString型
var input = "1234.5";
var output = Number(input.toString().replace(/[^0-9\.]/g, ''));
console.log(output); // 1234.5
 
・inputがカンマを含むのString型
var input = "1,234";
var output = Number(input.toString().replace(/[^0-9\.]/g, ''));
console.log(output); // 1234
 
【解説】
・Number型に含まれる文字は"0-9"と"."のみなので、それ以外の文字はreplace関数により削除する。
・replace関数は変数がString型でないと使用できないので、変数を事前にtoString関数によりString型に変換する(変換する代わりに型を判定する分岐を入れて処理を分けても良い)。

Springセキュリティ:ログインユーザー名をセッションから取得する方法

Springセキュリティによるログインが成功すると、UserDetailsクラスのオブジェクトを含むorg.springframework.security.core.context.SecurityContextHolderがセッションに保持されます。
UserDetailsクラスのオブジェクトには、ログインしたユーザー名の情報が含まれます。
ログイン後の画面でのログインユーザー名の取得は、セッションに保持されているSecurityContextHolderからUserDetailsクラスのオブジェクトを取得することで実現できます。
 
サンプルコードは以下の通りです。
(ログインユーザー名の取得に関係しないコードは省略します)
 
【サンプルコード】
・SecuritySession.java
package com.example.util;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

@Component
public class SecuritySession {

    public String getUsername() {
        // SecurityContextHolderから
        // org.springframework.security.core.Authenticationオブジェクトを取得
        Authentication authentication = SecurityContextHolder.getContext()
                .getAuthentication();
        if (authentication != null) {
            // AuthenticationオブジェクトからUserDetailsオブジェクトを取得
            Object principal = authentication.getPrincipal();
            if (principal instanceof UserDetails) {
                // UserDetailsオブジェクトから、ユーザ名を取得
                return ( (UserDetails) principal ).getUsername();
            }
        }
        return null;
    }

}
 
・MainCotroller.java
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.util.SecuritySession;
import org.springframework.ui.Model;
import java.util.Locale;

@Controller
@RequestMapping("/main")
public class MainCotroller {

    @Autowired
    private SecuritySession securitySession;

    /** メイン画面を表示 */
    @GetMapping("/main")
    public String getMain(Model model, Locale localem) {

        System.out.println(securitySession.getUsername());

        return "main/main";
    }

}
 
【実行結果】
・「0001」というユーザー名でログイン

f:id:akira2kun:20211024220715j:plain
 
・ログイン後のコンソール表示の確認
0001

C#:バイナリファイルのデータを置換するツール

文字列置換はサクラエディタを使用すると楽ですが、サクラエディタを使用した場合は大量データの処理ができないという問題があります。
 
そこで、C#をプログラムにより置換を行うというのが有効になります。
プログラムでのファイルのストリーム読み込みであれば、高速に処理することができ、大量データにも対応することができます。
また、C#コンパイラはWindowsOSに標準で搭載されているので、環境設定が不要なのも便利な点です。
 
サンプルコードは以下です。
サンプルコードでは文字列の"hoge"を"fuga"に変換しているだけですが、バイナリファイルとして読み書きしている上に変換する文字は16進コードで指定しているため、ASCII文字以外の置換にも対応可能です。
また、ロジックを書き変えれば、より複雑な条件での置換も可能になります。
 
【フォルダ構成】
execute.bat
replace.cs
files─input.txt
 
ソースコード
・execute.bat
@echo off

C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe replace.cs
replace.exe
del replace.exe

pause
 
・replace.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.IO;

namespace Program
{
    class Program
    {
        static void Main(string[] args)
        {
            // ファイルオープン
            BinaryReader br = new BinaryReader
                (new FileStream(@"files\input.txt", FileMode.Open));
            BinaryWriter bw = new BinaryWriter
                (new FileStream(@"files\output.txt", FileMode.Create));
            
            try
            {
                int state = 0; // 0…初期状態
                               // 1…"h"を読みこんだ状態
                               // 2…"ho"を読みこんだ状態
                               // 3…"hog"を読みこんだ状態
                const byte Byte_h = (byte)0x68; // "h"の文字コード
                const byte Byte_o = (byte)0x6F; // "o"の文字コード
                const byte Byte_g = (byte)0x67; // "g"の文字コード
                const byte Byte_e = (byte)0x65; // "e"の文字コード
                const byte Byte_f = (byte)0x66; // "f"の文字コード
                const byte Byte_u = (byte)0x75; // "u"の文字コード
                const byte Byte_a = (byte)0x61; // "a"の文字コード
                ArrayList tmpDataList = new ArrayList(); // 読込文字の一時保存
                
                for (;;)
                {
                    // 1バイト読み込む
                    byte data = br.ReadByte();
                    
                    // stateが0、かつ読み込んだ文字が"h"の場合
                    if (state == 0 && data == Byte_h)
                    {
                        state = 1;
                        tmpDataList.Add(data);
                    }
                    // stateが1、かつ読み込んだ文字が"o"の場合
                    else if (state == 1 && data == Byte_o)
                    {
                        state = 2;
                        tmpDataList.Add(data);
                    }
                    // stateが2、かつ読み込んだ文字が"g"の場合
                    else if (state == 2 && data == Byte_g)
                    {
                        state = 3;
                        tmpDataList.Add(data);
                    }
                    // stateが3、かつ読み込んだ文字が"e"の場合
                    else if (state == 3 && data == Byte_e)
                    {
                        // "fuga"を出力
                        bw.Write(Byte_f);
                        bw.Write(Byte_u);
                        bw.Write(Byte_g);
                        bw.Write(Byte_a);
                        
                        // 一時保存した文字を消し、stateを元に戻す
                        tmpDataList.Clear();
                        state = 0;
                    }
                    // それ以外の場合
                    else
                    {
                        // 一時保存した文字と読み込んだ文字を出力
                        for (int i = 0; i<tmpDataList.Count; i++)
                        {
                            bw.Write((byte)tmpDataList[i]);
                        }
                        bw.Write(data);
                        
                        // 一時保存した文字を消し、stateを元に戻す
                        tmpDataList.Clear();
                        state = 0;
                    }
                }
            }
            
            //最後まで読んだらループを抜ける
            catch(EndOfStreamException)
            {
            }
            
            finally
            {
                br.Close();
                bw.Close();
            }
        }
    }
}
 
【入力ファイル】
・input.txt
    h ho hog hoge   12
3456
 
【出力ファイル】
・output.txt
    h ho hog fuga   12
3456

Vue.js:プルダウンの選択値を動的に変更する

画面の各項目で設定された値に応じて、プルダウンの選択値(初期値)を動的に変更したい場合があります。
 
Vue.jsの場合、これは、値の状態を監視するウォッチャー(watch)と双方向データバインディング(v-model)を併用することで実現できます。
画面の各項目の設定値をwatchで監視し、監視結果として返す値(プルダウンの選択値)をv-modelでバインディングさせる、という形で実装します。
 
以下、サンプルコードです。
 
【サンプルコード】
・test.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Vue.js - Test</title>
</head>
<body>

<div id="app">
  <input type="checkbox" v-model="checked">
  <select v-model="selected">
    <option>item1</option>
    <option>item2</option>
    <option>item3</option>
  </select>
</div>

<script src="https://unpkg.com/vue"></script>
<script>
  var app = new Vue({
    el: '#app',
    // 変数の初期値定義
    data: {
      checked: false,
      selected: ''
    },
    // 変数の値を監視するイベントを定義
    watch: {
      checked: function(newVal, oldVal) {
        this.selected = (newVal) ? 'item2' : '';
      }
    }
  })
</script>
</body>
</html>
 
【実行結果】
チェックボックスをチェックしないとプルダウンの選択値が空白、チェックすると"item2"になります。

f:id:akira2kun:20220118003618j:plain

f:id:akira2kun:20220118003630j:plain

もちろん、プルダウンの選択値を変更することも可能です。

f:id:akira2kun:20220118003650j:plain