30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

JavaScript で Base64

この記事では Base64 やbtoa、そしてbtoaの挙動を理解するために必要な Latin1 について説明していく。

この記事に出てくるコードの動作確認は以下の環境で行った。

  • Deno 1.28.3
  • TypeScript 4.8.3

概要

Base64 はデータのエンコード方式の一種。
全てのデータをaz(26 文字)、AZ(26 文字)、09(10 文字)、そして+/を合わせた計 64 文字、さらにそこに=を組み合わせたテキストで表現する。

そうすることで、扱えるデータに制限のある環境において、その制限を超えたデータを扱えるようになる。
例えば電子メールではテキストデータしか扱えないが、バイナリデータを Base64 にエンコードしてしまうことで、問題なくバイナリデータを送信できるようになる。あとは受信側で Base64 をデコードすればよい。
他にも、 Data URL でバイナリデータを扱う際にも Base64 が使われている。

btoa と atob

JavaScript には、文字列を Base64 でエンコード・デコードするための関数が予め用意されている。

エンコードにはbtoaを使い、デコードにはatobを使う。

console.log(btoa("a")); // YQ==
console.log(atob("YQ==")); // a

だがbtoaは、あらゆる文字をエンコードできるわけではない。むしろエンコードできない文字のほうが多い。
例えば日本語を渡すとエラーになってしまう。

// error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
console.log(btoa("あ"));

Latin1 とは何か

エラーメッセージはThe string to be encoded contains characters outside of the Latin1 range.。「エンコードする文字列に、Latin1 の範囲外の文字が含まれている」とのこと。
これにより、btoaには Latin1 範囲内の文字しか渡せないことが分かる。

では Latin1 とは何か。

ISO/IEC 8859 という文字コードのパート 1 の通称が、 Latin1 。
ISO/IEC 8859 は 8 ビット 256 文字の文字コードであり、複数の「パート」がある。256 文字のうち前半 128 文字は全パート共通で、その部分は ASCII 文字コードと同一になっている。そして後半 128 文字は、パートによって内容が異なる。
目的に応じてパートを選択して使用するようになっているのだが、そのうちのパート 1 が Latin1 である。

Latin1 で使える文字は以下で見れる。
https://ja.wikipedia.org/wiki/ISO/IEC_8859-1#%E7%AC%A6%E5%8F%B7%E8%A1%A8

そして Unicode の最初の 256 個の Code Point(U+0000..U+00FF)は Latin1 と同じ内容になっている(Unicode や Code Point についてはこの記事で説明している)。
JavaScript は Unicode を採用しているため、 JavaScript の文脈においては「Latin1」と言ったときは、その範囲の文字を指すと考えてよい。

U+0100以降の文字をbtoaに渡してみると、確かに失敗する。

let str = String.fromCodePoint(0x61);
console.log(str); // a
console.log(btoa(str)); // YQ==

str = String.fromCodePoint(0xff);
console.log(str); // ÿ
console.log(btoa(str)); // /w==

str = String.fromCodePoint(0x100);
console.log(str); // Ā
console.log(btoa(str)); // error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.

このため、 Latin1 範囲外の文字をbtoaでエンコードするためには、その文字を何らかの方法で Latin1 範囲内の文字に変換する必要がある。そしてそれは、元の文字に戻せる方法でなければならない。そうしないとデコードできない。

具体的な処理の流れは以下のようになる。

  1. 文字を Latin1 範囲内の文字に変換する
  2. 変換後の文字をbtoaに渡す
  3. Base64 でエンコードされた文字が手に入る

デコードして元の文字を手に入れるときは、これと逆のことをやればよい。

  1. エンコードされた文字をatobに渡す
  2. atobから渡された文字に対して、「文字を Latin1 範囲内の文字に変換する」で行ったのと逆の処理を行う
  3. 元の文字が手に入る

encodeURIComponent

Latin1 の文字列を手に入れるための手段のひとつとして、encodeURIComponentがある。

この関数は以下の文字以外の全ての文字をエスケープする。

A-Z a-z 0-9 - _ . ! ~ * ' ( )

アルファベットや数字はもちろん、記号も全て Latin1 の範囲内に収まっている。
つまり Latin1 範囲外の文字は全てエスケープ対象となる。

// 2d
// 5f
// 2e
// 21
// 7e
// 2a
// 27
// 28
// 29
Array.from("-_.!~*'()").forEach((item) => {
  console.log(item.codePointAt(0)?.toString(16));
});

そしてencodeURIComponentの返り値は%XXXXは 16 進数)という形式の文字列になる。%も Latin1 なので(Code Point U+0025)、返り値は必ず Latin1 範囲内の文字列になることが保証される。

そのためencodeURIComponentを使えば、 Latin1 範囲外の文字を含む文字列を、 Latin1 範囲内に収まる形に変換できる。あとはそれをbtoaに渡せばよい。

const original = "あ";

const latin1 = encodeURIComponent(original);
console.log(latin1); // %E3%81%82

const base64 = btoa(latin1);
console.log(base64); // JUUzJTgxJTgy

// 逆の処理を行うと元の文字列が手に入る

const decoded = atob(base64);
console.log(decoded); // %E3%81%82

const restore = decodeURIComponent(decoded);
console.log(restore); // あ

ArrayBuffer を活用する

ArrayBuffer を使うことでも、 Latin1 範囲内の文字列に変換することができる(ArrayBuffer そのものについてはこの記事で説明している)。

JavaScript では文字を、符号なし 16 ビット整数を使って表現している(UTF-16)。
これを、符号なし 8 ビット整数による表現に変えてしまう。そうするとひとつひとつの要素は0..255の範囲内に収まるので、それを Code Point として利用すればそれは必ず Latin1 の範囲内に文字になる。

const str = "あ";

// 符号なし 16 ビット整数を入れていくための「箱」として Uint16Array を用意する
const ta16 = new Uint16Array(str.length);

// JavaScript の Code Unit は符号なし 16 ビット整数なので、そのまま Uint16Array に格納できる
const codeUnit = str.charCodeAt(0);
console.log(codeUnit); // 12354
ta16[0] = codeUnit;
console.log(ta16); // Uint16Array(1) [ 12354 ]

// Uint8Array による表現を手に入れる
const ta8 = new Uint8Array(ta16.buffer);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

// Uint8Array の各要素は 0..255 の範囲内になるので、それを Code Point として利用すれば必ず Latin1 の範囲内の文字列になる
const latin1 = String.fromCodePoint(...ta8);
console.log(latin1); // B0

// 問題なく btoa を使える
const encoded = btoa(latin1);
console.log(encoded); // QjA=

元の文字列を得るには逆の処理を行う。

const decoded = atob("QjA=");
console.log(decoded); // B0

const ta8 = new Uint8Array(decoded.length);
ta8[0] = decoded.charCodeAt(0);
ta8[1] = decoded.charCodeAt(1);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

const ta16 = new Uint16Array(ta8.buffer);
console.log(ta16); // Uint16Array(1) [ 12354 ]

const original = String.fromCodePoint(...ta16);
console.log(original); // あ

上記の内容を任意の文字列に対して行えるように関数化したものが、以下になる。

const toBase64 = (str: string): string => {
  const ta16 = new Uint16Array(str.length);
  for (let i = 0; i < ta16.length; i += 1) {
    ta16[i] = str.charCodeAt(i);
  }
  const latin1 = String.fromCodePoint(...new Uint8Array(ta16.buffer));
  return btoa(latin1);
};

const fromBase64 = (encoded: string): string => {
  const decoded = atob(encoded);
  const ta8 = new Uint8Array(decoded.length);
  for (let i = 0; i < ta8.length; i += 1) {
    ta8[i] = decoded.charCodeAt(i);
  }
  return String.fromCodePoint(...new Uint16Array(ta8.buffer));
};

console.log(toBase64("あ")); // QjA=
console.log(fromBase64("QjA=")); // あ

console.log(toBase64("abcあいうえお🐶")); // YQBiAGMAQjBEMEYwSDBKMD3YNtw=
console.log(fromBase64("YQBiAGMAQjBEMEYwSDBKMD3YNtw=")); // abcあいうえお🐶

参考資料

『優れた技術者の集まる会社にする方法 ソフトウェア開発者採用ガイド』を読んだ

前回読んだ『Joel on Software』の Joel Spolsky が、ソフトウェア開発者の採用について論じた一冊。
自身が優秀な開発者であり経営者でもある Joel が、多くのソフトウェア開発者が採用に対して何となく感じていることを平易な文章で明快に説明していく。

詳しく調べてはいないが、本書の内容も基本的に Joel on Software で公開されている記事をまとめたものだと思う。
例えば「第 2 章 優れた開発者を見つけるには」は、以下の記事を翻訳したものになっている。

エッセイ集なので採用業務について体系立てて論じているわけではないが、それゆえに非常に読みやすい。しかし内容は薄くない。
「分かってはいるが実践は困難」「この基準を満たす開発者がどれだけいるんだ」と思いたくなる内容もあるが、主張している内容はいずれも真っ当なものばかり。
Joel 自身が本書の内容を実践しており、その結果 Stack Overflow や Trello など日本でも知られているサービスが複数生まれているため、とにかく説得力がある。
ソフトウェア開発者が読んでも面白いが、開発者にとっては常識であったり感覚的に理解しているような事柄について丁寧に論じているので、非開発者が読むとより大きな示唆を得られるかもしれない。

本書の内容を自分なりに要約すると、以下のようになる。

ソフトウェアの機能や品質がそのまま競争力になるような事業の場合、優れたソフトウェア開発者を採用できるかが事業の成否を左右する。
優れた開発者でなければ作れないものがある。凡庸な人間をいくら集めても優れた人材の代替にはならないし、そういう人たちに膨大な時間を与えたところで結果は同じ。
そのため、候補者が本当に優れた開発者なのかを注意深く慎重に見極めなければならない。
そして優れた開発者は引く手数多なので、基本的に採用市場には出てこないし、出会えたとしても自社に来てくれるかは分からない。彼らはいくつも選択肢を持っているのだから。
さらに、幸運にも優れた開発者が入社してくれたところで、彼らをうんざりさせるような政治や彼らを軽んじるようなマネージメントを繰り返していれば、彼らの能力を活かすことはできないし、いずれは去っていくだろう。
このように、優れた開発者を雇って事業を成功させるためには、いくつものハードルを越えなければならない。

候補者と企業、どちらも同じ課題を抱えており、それぞれに「強者」と「弱者」が生まれているんだろうなと、本書を読んで感じた。
お互いに「誰でもいい」わけではなく、だからこそ、たくさんの選択肢を持つ「強者」と、苦しくなる一方の「弱者」が生まれてしまう。
市況の変化によってトレンドは変わるが、基本的な構造は変わらない。

例えば本書には「一括送信の履歴書は死にものぐるいの徴候に思える」という表現が出てくる。
志望動機が薄くて汎用的な内容の履歴書を提出されると、本当にこの仕事をしたいと思っているのか疑わしくなってしまう、という話なのだが、これは企業側にも言える。
パーソナライズされておらず誰にでも言えるような内容のスカウトメールを一括送信しているようでは、候補者からよい印象を持たれる可能性は低い。「死にものぐるい」と映るだろう。
そして「死にものぐるい」が採用市場で高く評価されることはない。魅力があり「強者」である企業や候補者は多くの選択肢を持っており、そんなことしないからだ。

どうやって自分を知ってもらうか、自分を見てもらうか、にも同じことが言える。
採用広報の重要性と難しさが本書で出てくる。どんなに魅力を持った企業であっても、それを上手く発信していかないと目に留めてもらえないのが現実で、まず知ってもらうということのハードルが高い。
そして候補者も同じ状況にある。たくさんの応募の中から、どうやって自分を見つけてもらうのか、どうやって面接を受けるチャンスを掴むのか。

面接には多くのリソースを必要とするので、希望する人全てと面接するのは企業にとって現実的ではない。
他の業務との兼ね合いもあるので、自然と効率性を求めることになる。そのため、フィルタリングが行わる。
本書では履歴書と電話面接によるフィルタリングを紹介しているが、履歴書によるフィルタリングでは、「(人によっては)何年も前の過去の話」がそれなりに大きなウェイトを占めている。
詳しくは実際に読んでもらいたいが、選考プロセスが厳しい大学や企業に入ったことがあるか、大学の成績はよいか、大学対抗プログラミングコンテンストに出場したことはあるか、など。これらによって、どの候補者と優先的に面接するかを決めている。

高校を中退しており無名文系私大卒の私にとっては、かなり厳しい内容だ。経歴だけでなくアクティビティについても同様で、若い頃を引きこもりとして無為に過ごしたため、セキュリティ・キャンプに参加したこともなければ、未踏ジュニアに応募したこともない。
そして残念ながら、過去の経歴で判断するのはそれなりに妥当だろうなとは思う。情報工学を修めているかでソフトウェア開発者の履歴書を選別するのは、別に間違ってはいないと思う。

とはいえ、それらがフィルタリング条件として最適というわけではなく、企業と候補者の双方に機会損失が生まれているのも事実だと思う。
履歴書から読み取れる情報は少なく、書類上の評価が高くても能力が低い候補者はいるし、その逆のケースもあると、本書にも書かれている。
企業にとっても候補者にとっても、出会いたい相手に出会うのは難しいのが現状と言える。今はまだ何のアイディアもないが、もっと上手い仕組みを作れるとよいなとは思っている。