Webサービス開発で遭遇する「簡単に考えがちだけど意外とハマる」問題たち

この記事では、Webサービスを開発する上で「一見簡単に考えてしまいがちだけど、実際に手をつけてみると意外とちゃんと考えなきゃいけない」問題を、僕の経験を振り返りながらリストアップしてみます。

技術的な問題だけでなく、サービスの性質や仕様によって最適解が変わる場合が多いために一概に「こうすればOK!」が言えない類の、とても困ったちゃんたちを挙げてみました。

仕様検討や設計をする上での観点のひとつとして読んでいただけたらと思います。

本文

「1か月後っていつ?」問題

たとえば「利用登録した日の1か月後に期限切れ」という処理を考えてみます。

このとき、利用登録した日が7/21だった場合、期限切れになる日付は8/21と直感的に答える方は多いと思います。

では、利用登録した日が6/21だった場合はどうでしょうか?

これを7/21としてしまうと、6月は30日しかありませんので6月に登録したユーザーは7月に登録したユーザーよりも1日短い期間しかそのサービスを利用できない、ということになってしまいます。

1日ならまだ許容範囲かもしれませんが、これが2月の登録となると、3日間(うるう年は2日間)もサービスを利用できる期間が少なくなってしまいます。これでは不公平だとユーザーから指摘されてしまいそうです。

では「1か月後=31日後」と定義しておけばよいのでしょうか。

上記の例では良い解決策となります。しかし、たとえば別の例として「利用登録した日の1か月後にクーポンプレゼント!」のような場合を考えてみると、「1か月後=31日後」では2/21に登録した人がクーポンを受け取れるのは3/24となってしまい、ユーザーは「あれ?2/21の登録から1か月経って3/21になったのにクーポンが届かないぞ?」となってしまいます。

このように、一言に「1か月後」といっても場合によって適切なカウント方法が変わり、それにより実装方法も変わります。何も考えずに言語が提供する「1か月加算する」処理をしてしまうと想定外の挙動になることがありますので、仕様を考える段階で月ごとの日数の差をどう扱うのかを考えておく必要があります。

ちなみに、Javaで何も考えずに「2/21の1カ月後」を実装すると以下の結果になります。

LocalDateTime now = LocalDateTime.of(2017, 2, 21, 00, 00); // 2017/2/21に対して
LocalDateTime nextMonth = now.plusMonths(1); // 1か月加算
System.out.println(nextMonth.toString());
// => 2017-03-21T00:00

後者の例の場合で使えそうな挙動ですね。ただし、1/31の1か月後は2/28となってしまう点に注意してください。

LocalDateTime now = LocalDateTime.of(2017, 1, 31, 00, 00); // 2017/1/31に対して
LocalDateTime nextMonth = now.plusMonths(1); // 1か月加算
System.out.println(nextMonth.toString());
// => 2017-02-28T00:00

「有効なメールアドレス」問題

メールアドレスの欄に入力された文字列が「メールアドレスとして有効な文字列かどうか」の判定は想像以上に複雑です。

と言うと「え、そんなの [英数字]@[ドメイン(xxx.xxxの形式)] かどうかチェックすればいいだけでしょ」とか、「RFC見ればできるでしょ」とかいう声が聞こえてくるような気がしますが(実際言われた)、ことはそう単純ではありません。

僕も詳しく理解しているわけではないのでざっくりとですが、「簡単でしょ?」って言ってくる人に対して「簡単じゃないよ!」ってとりあえず言えるレベルで理由を挙げると以下の2点です。

RFCを理解するのがそもそも難しい

メールアドレスの書式についてはRFC5322に記載されています。しかし、その内容を読んでもまず「で、フォーマットの規則について書かれているところはどこ?」となり、やっとのことで3.4.1. Addr-Spec Specificationがお目当ての項目であることがわかっても今度は別のRFCへのリンクがはってあったり、それらのリンクを見ているうちにだんだんこんがらがってきたり、という具合でなかなか理解が進みません。

書かれている内容も、同じ記号でも並びによってOK / NGが変わったり、ドメインではなくIPアドレスでもOKだったりと実装するにはなかなかに複雑です。

そもそもRFCに準拠しない形式のアドレスの取得を日本の携帯キャリアが許していた時期がある

RFCはあくまで「仕様」であり、そこで決定したことが世界中すべてのシステムで実際に適用されているわけではありません。日本でも、過去に携帯キャリアがRFCに沿っていないメールアドレスも発行を許可してしまっていたことがあったりします。(「携帯キャリア RFC違反」とかでググるといろいろ出てきます)

現実にRFC違反のメールアドレスを今も使い続けている人が存在するわけですから、「RFCに準拠していないから」という理由で実際に使われているメールアドレスを拒否してしまうことはユーザーの不利益につながり、サービス提供者としても獲得できるはずだったユーザーを取り逃す結果になることがあります。

どこまで厳密なチェックをするのか、必ず仕様を決める段階ではっきりさせましょう。併せて、メールアドレスのバリデーションを提供している各ライブラリがどのレベルで対応しているのかについても確認が必要です。

メールアドレスを実サービスで扱う難しさは、以下の記事などがとても参考になります。

など

「国と地域」問題

海外のユーザーに向けてサービスを提供するとき、アンケートなどで出身の「国」を選択させる場面があるかと思いますが、ここでたとえば「台湾」を選択肢に含めてしまうとそれがトラブルに発展する可能性がある、という問題があります。

これも僕が詳しく理解できているわけではないのでざっくりとした説明になってしまいますが、中国(中華人民共和国)の人は台湾を中国の一部として認識している一方で、台湾の人は台湾を「中華民国」という名前で、中国やモンゴルなども領土として含めた広大な国として認識しています。(国民が本当にそう考えているかは不明ですが、少なくとも政府はそのような立場をとっているようです)

つまり、「国」一覧に「中国」と「台湾」を列挙してしまうことは、中国の人からすれば「台湾は国じゃない!中国の一部だ!」となりますし、台湾の人からすれば「中国でも台湾でもない!国名は中華民国だ!」となってしまい、下手をすると「日本は台湾を国として認めるのか!」といった国際問題に発展する可能性さえあるのです。(ちなみに日本政府は台湾を国として認めてはいません)

この問題の悩ましいところは、同じような問題が世界の至るところに存在するということです。「国」の認識は立場によって我々が思う以上に食い違うことがあるため、表示上は「国」と限定するのではなく「国・地域」と少し曖昧にした表現にする必要がある、というのがこの「国と地域」問題です。

そしてそのうえで、列挙する国・地域名も「台湾」なのか「中華民国」なのかなどを精査する必要があります。

どこかのサイトやサービスから機械的にリストアップするのではなく、そのような国際問題が存在しないかどうかを一つずつ確認し、すべてを精査できない場合は「その他の国・地域」でまとめてしまえないかを検討しましょう。

住所問題

入力された住所文字列をそのまま受け取ってそのまま表示するだけ、であれば特に問題にはなりません。ここで問題になるのは、その文字列を階層で分けて別々に保存したり集計たり、とやろうとした場合です。

「え?そんなの『都道府県』と『市区町村』でsplitすればいいんじゃ」違います。

たとえば、「京都府」は2文字目に「都」という文字が含まれるため、単純に「都道府県」で分割しようとすると「京」という県になってしまいます。

都道府県レベルでは47種類なので一つひとつ確認もできますが、市区町村レベルになるとその数は約1,700まで増え、とても一つひとつについて同じ問題が発生していないかを確認することは困難です。「川崎市高津区」など、政令指定都市などでは「市」と「区」の両方が含まれる場合もあります。

さらに市区町村よりも細かいレベル分割しようとすると、京都通り名や無番地、大字小字の要素なども考慮しなければならず、これらすべてを考慮して正確に分割することは不可能と言えるのではないかと思います。

以下の記事を読むと、市区町村レベルの分割でも試行錯誤や議論が発生することがよくわかります。

要件としてどの程度の精度で正確に分割する必要があるのか、そもそも分割する必要が本当にあるのかなどを確認し、正確さを妥協してプログラムだけで対応するのか、DBに全国の住所をあらかじめ保存してそれとマッチングするなど、試行錯誤のための時間とコストを確保して精度を上げるのか、などを決めていく必要があります。

「バージョン判定」問題

モバイルアプリが絡むを開発していると、バージョン判定が必要になる場面があるかと思います。2.12.32.13.3 のような2つのバージョン文字列からなんとかしてその大小を判定することになるのですが、これがまた難しかったりします。

この判定ロジックとして、以前に以下のようなコードを見かけて驚いた記憶があるのでダメな例として挙げておきます。

// version1, version2にはString型でバージョン文字列が入っている

int version1Int = Integer.parseInt(version1.replaceAll("\\.", ""));
int version2Int = Integer.parseInt(version2.replaceAll("\\.", ""));

if (version1Int == version2Int) {
    // 同じバージョンの場合
} else if (version1Int > version2Int) {
    // version1の方が新しい
} else {
    // version2の方が新しい
}

バージョン文字列から桁区切りのピリオドを抜いて、int型に変えたものを比較する、という作戦なのですが、この実装では以下のような問題が発生します。

2.2.0よりも2.1.10の方が新しくなってしまう

まず上記のプログラムはバージョン番号の各階層の数字が2桁以上になることを全く考慮していません。そのため、各階層がすべて1桁のバージョンよりも、どこかの階層が2桁になったバージョンが常に新しいことになってしまいます。

バージョン文字列内に数値以外が含まれたらNumberFormatException

バージョン文字列はあくまで「文字列」であり、数値のみで構成しなければならないようなルールは全くありません。そのため、2.1.10betaのような、中にアルファベットが含まれるバージョン文字列に遭遇した途端に例外が発生します。

バージョン文字列の構成はそもそも自由

これも上記同様、バージョン文字列にそもそもルールはありません。たまたま XX.XX.XX の形式をよく見かける、というだけであって、これが4階層になったりbetaがついたり、buildXXXのようなビルド番号がついたりビルド日付がついたり、そのシステムの管理方法によってさまざまです。

つまり、バージョン文字列から大小を判定したい場合、まずはバージョン文字列のフォーマットをしっかりと決める、という作業が発生します。そしてそのためには開発者や検証者がどの程度バージョンを分けて管理したいのか、バージョン文字列にどのような情報を持たせたいのか、バージョン文字列を何の用途に使うのか、などの要件を洗い出すことが必要になり、またそこで決めたルールを将来にわたって強制する仕組みが必要になります。

つまり、そのルールを制御できない、自分たちの管理外のシステム(他社製アプリなど)のバージョン判定は現実的にとても難しいということになります。

この記事の他の問題と同じく、バージョン判定においても何のシステムのバージョンをどのような精度で判定させ、もし判定に失敗した場合はどうするのか、といった仕様をよく検討することが重要になってきます。

一つの解決策として、AndroidアプリではVersionName(String型)のほかにVersionCode(int型)をそれぞれ定義し、アップデートの際は必ずVersionCodeの方をインクリメントしなければならない、というルールになっていて、バージョン判定には常にVersionCodeを使うことができる設計になっています。個人的にこの方法がとても好きです。

まとめ

総じて、コンピューターというものが生まれる前から存在する身近な概念をプログラムで表現しようとすると、この手の問題が発生しやすいように思います。

そして、ここでの問題は技術的に解決が難しいことではなく、「こんなの簡単だよね」と決めつけて話を進めたりスケジュールを引いたりして(されて)しまった場合にあとから問題が発覚し、想定外の工数が必要になりがちであることです。

特に身近であればあるほど、自分の見えていないところで発生している例外や問題を見落としやすく、結果としてプロジェクトが炎上したりや想定外のバグや指摘が発生したり、というのを何度か目にした記憶があります。

すぐに解決策が提示できなくても、これらの要素が話題に上がった時点で警戒できるようになれば、少し開発が楽になるのではないかと思います。

他にも何かあればぜひコメントください!