トラック
/
JavaScript
JavaScript
/
演習
/
翻訳サービス
翻訳サービス

翻訳サービス

学習演習

はじめに

Promiseオブジェクトは、非同期処理が最終的に完了する(または失敗する)ことと、その結果の値を表します。

Note

これは多くの人にとって難しいトピックです。特に、完全に_同期_な言語でプログラミングを学んだことがある人にとっては難しいでしょう。 圧倒されたと感じたり、並行性と並列性についてもっと学びたいと思ったら、素晴らしいトーク「Concurrency is not parallelism」の動画(go.dev経由)かVimeoの動画を観て、スライドを読むとよいでしょう。

プロミスのライフサイクル

Promiseには3つの状態があります。

  1. pending
  2. fulfilled
  3. rejected

作成された時点では、プロミスはpendingです。 将来のある時点で、_resolve_または_reject_されることがあります。 プロミスは一度resolveまたはrejectされると、二度とresolveもrejectもされることはなく、状態も変わりません。

言い換えると:

  1. pendingのとき、プロミスは:
    • fulfilledまたはrejectedの状態へ遷移できます。
  2. fulfilledのとき、プロミスは:
    • 他のどの状態にも遷移してはいけません。
    • 値を持たなければならず、その値は変わってはいけません。
  3. rejectedのとき、プロミスは:
    • 他のどの状態にも遷移してはいけません。
    • 理由を持たなければならず、その理由は変わってはいけません。

プロミスをresolveする

プロミスはさまざまな方法でresolveできます:

// Creates a promise that is immediately resolved
Promise.resolve(value);

// Creates a promise that is immediately resolved
new Promise((resolve) => {
  resolve(value);
});

// Chaining a promise leads to a resolved promise
somePromise.then(() => {
  // ...
  return value;
});

上記の例では、valueは_何でも_かまいません。エラー、undefined、null、あるいは別のプロミスでも構いません。 通常は、エラーではない値でresolveしたいはずです。

プロミスをrejectする

プロミスはさまざまな方法でrejectできます:

// Creates a promise that is immediately rejected
Promise.reject(reason)

// Creates a promise that is immediately rejected
new Promise((_, reject) {
  reject(reason)
})

// Chaining a promise with an error leads to a rejected promise
somePromise.then(() => {
  // ...
  throw reason
})

上記の例では、reasonは_何でも_かまいません。エラー、undefined、nullなども含まれます。 通常は、エラーでrejectしたいはずです。

プロミスのチェーン

プロミスは、resolveまたはrejectしたあと、将来の処理で_続ける_ことができます。

then

すべてのプロミスは「thenable」です。 つまり、元のプロミスがresolveすると実行されるthenという関数を利用できるということです。 promise.then(onResolved)とすると、コールバックonResolvedは元のプロミスがresolveされた値を受け取ります。 これは常に_新しい_「チェーンされた」プロミスを返します。

thenからvalueを返すと、「チェーンされた」プロミスはresolveされます。 thenの中でreasonをスローすると、「チェーンされた」プロミスはrejectされます。

const promise1 = new Promise(function (resolve, reject) {
  setTimeout(() => {
    resolve('Success!');
  }, 1000);
});

const promise2 = promise1.then(function (value) {
  console.log(value);
  // expected output: "Success!"

  return true;
});

これは約1000ms後に"Success!"をログに出力します。 promise1の状態と値はresolvedと"Success!"になります。 promise2の状態と値はresolvedとtrueになります。

元のプロミスがrejectしたときに実行される2つ目の引数も利用できます。 promise.then(onResolved, onRejected)とすると、コールバックonResolvedは元のプロミスがresolveされた値を受け取り、コールバックonRejectedはプロミスがrejectされた理由を受け取ります。

const promise1 = new Promise(function (resolve, reject) {
  setTimeout(() => {
    resolve('Success!');
  }, 1000);

  if (Math.random() < 0.5) {
    reject('Nope!');
  }
});

function log(value) {
  console.log(value);
  return true;
}

function shout(reason) {
  console.error(reason.toUpperCase());
  return false;
}

const promise2 = promise1.then(log, shout);
  • 約2分の1のケースでは、約1000ms後に"Success!"をログに出力します。
    • promise1の状態と値はresolvedと"Success!"になります。
    • promise2の状態と値はresolvedとtrueになります。
  • 約2分の1のケースでは、すぐに"NOPE!"をログに出力します。
    • promise1の状態と値はrejectedとNope!になります。
    • promise2の状態と値はresolvedとfalseになります。

ライフサイクルのルールのため、rejectしたときに約1000ms後に来るresolveは黙って無視されることを理解しておくことが大切です。一度rejectまたはresolveすると、内部状態は変えられないからです。 プロミスから値を返すとそのプロミスはresolveされ、値をスローするとrejectされることも理解しておくことが大切です。 promise1がresolveし、チェーンされたonResolved(then(onResolved))がある場合、その続きは新しいプロミスで、resolveまたはrejectできます。 promise1がrejectし、チェーンされたonRejected(then(, onRejected))がある場合、その続きは新しいプロミスで、resolveまたはrejectできます。

catch

エラーを捕捉して、元のプロミスがrejectしたときだけ続行したい場合もあります。 promise.catch(onCatch)とすると、コールバックonCatchは元のプロミスがrejectされた理由を受け取ります。 これは常に_新しい_「チェーンされた」プロミスを返します。

catchからvalueを返すと、「チェーンされた」プロミスはresolveされます。 catchの中でreasonをスローすると、「チェーンされた」プロミスはrejectされます。

const promise1 = new Promise(function (resolve, reject) {
  setTimeout(() => {
    resolve('Success!');
  }, 1000);

  if (Math.random() < 0.5) {
    reject('Nope!');
  }
});

function log(value) {
  console.log(value);
  return 'done';
}

function recover(reason) {
  console.error(reason.toUpperCase());
  return 42;
}

const promise2 = promise1.catch(recover).then(log);

約2分の1のケースでは、約1000ms後に"Success!"をログに出力します。 残りの約2分の1のケースでは、すぐに42をログに出力します。

  • promise1がresolveすると、catchはスキップされてthenに到達し、値をログに出力します。
    • promise1の状態と値はresolvedと"Success!"になります。
    • promise2の状態と値はresolvedと"done"になります。
  • promise1がrejectすると、catchが実行され、そこで_値が返され_、チェーンはresolvedになり、thenに到達して値をログに出力します。
    • promise1の状態と値はrejectedと"Nope!"になります。
    • promise2の状態と値はresolvedと"done"になります。

finally

プロミスがresolveしてもrejectしても、プロミスが確定したあとにコードを実行したい場合もあります。 promise.finally(onSettled)とすると、コールバックonSettledは何も受け取りません。 これは常に_新しい_「チェーンされた」プロミスを返します。

finallyからvalueを返すと、元のプロミスの状態と値がコピーされ、valueは無視されます。 finallyの中でreasonをスローすると、「チェーンされた」プロミスはrejectされ、元のプロミスの状態と値、または理由が上書きされます。

例

さまざまなメソッドを組み合わせた例です:

const myPromise = new Promise(function (resolve, reject) {
  const sampleData = [2, 4, 6, 8];
  const randomNumber = Math.round(Math.random() * 5);

  if (sampleData[randomNumber]) {
    resolve(sampleData[randomNumber]);
  } else {
    reject('Sampling did not result in a sample');
  }
});

const finalPromise = myPromise
  .then(function (sampled) {
    // If the random number was 0, 1, 2, or 3, this will be
    // reached and the number 2, 4, 6, or 8 will be logged.
    console.log(`Sampled data: ${sampled}`);
    return 'yay';
  })
  .catch(function (reason) {
    // If the random number was 4 or 5, this will be reached and
    // reason will be "An error occurred". The entire chain will
    // then reject with an Error with the reason as message.
    throw new Error(reason);
  })
  .finally(function () {
    // This will always log after either the sampled data is
    // logged or the error is raised.
    console.log('Promise completed');
  });
  • randomNumberが0-3のケース:
    • myPromiseは値2, 4, 6, or 8でresolveされます
    • finalPromiseは値'yay'でresolveされます
    • ログが2つ出力されます:
      • Sampled data: ...
      • Promise completed
  • randomNumberが4-5のケース:
    • myPromiseは理由'Sampling did not result in a sample'でrejectされます
    • finalPromiseは理由Error('Sampling did not result in a sample')でrejectされます
    • ログが1つ出力されます:
      • Promise completed
      • 環境によっては、"uncaught rejected promise: Error('Sampling did not result in a sample')"というログが出力されます

上記のように、rejectは文字列でも機能し、プロミスはErrorでrejectすることもできます。

Note

プロミスのチェーンや一般的な使い方がよくわからない場合は、MDNのチュートリアルが良い学習資料になります。

説明

この演習では、無料会員に基本的な翻訳サービスを、プレミアム会員に品質保証付きの高度な翻訳を提供するTranslationServiceを作成します。

API

宇宙にある翻訳APIを見つけました。これはどんな翻訳のrequestも手頃な時間で処理してくれます。 これを活用したいところです。 宇宙の翻訳者たちはとても気まぐれで、無駄を嫌います。そこで、彼らを煩わせることなく過去の翻訳をfetchできる_API storage_の衛星も用意してくれています。

翻訳の取得

api.fetch(text)は、_API storage_からtextの翻訳を取得し、2つの値を提供するpromiseを返します。

  • translation: 実際の翻訳
  • quality: 数値で表される品質

_API storage_に翻訳が見つからない場合、APIはNotAvailableエラーを投げます。 翻訳はapi.requestメソッドを使って追加できます。 'text'が翻訳できない場合、APIはUntranslatableエラーを投げます。

api.fetch('jIyaj');
// => Promise({ resolved: 'I understand' })

翻訳のリクエスト

必ず存在するはずの翻訳でも、まだ_API storage_に追加されていないことがあります。これがNotAvailable(ストレージにはないが、リクエストできる)とUntranslatable(翻訳できない)の違いです。

api.request(text, callback)は、textの翻訳を実行して_API storage_に追加するようリクエストします。 完了すると、callback関数が呼び出されます。

  • 成功した場合、callbackにはundefinedが渡されます。これは翻訳が成功し、api.fetchメソッドで取得できることを示します。
  • 失敗した場合、callbackにはerrorが渡されます。これは何か問題が起きたことを示します。 宇宙のAPIは_不安定_なので、しばしば失敗します。 そうなった場合は、もう一度api.requestしてかまいません。
api.request('majQa’', callback);
// => undefined
//
// later: the passed callback is called with undefined
//        because it was successful.

⚠ 警告! ⚠

Caution

APIは、requestが届くとさまざまな翻訳者をテレポートさせて魔法のような処理を行います。 これは非常にコストのかかる操作なので、翻訳が利用できるときには呼び出すべきではありません。 残念ながら、誰もがマニュアルを読むわけではないので、悪質な利用者を追い出す仕組みが用意されています。

利用可能なtextに対してapi.requestを呼び出すと、APIはその呼び出しでAbusiveClientErrorを投げ、それ以降のすべての呼び出しでも投げます。 すでに翻訳済みのものに対しては、決して翻訳をリクエストしないようにしてください。

1. 品質を無視して翻訳を取得する

無料サービスは、現在_API storage_にある翻訳だけを提供します。

無料会員に、_API storage_にすでにある翻訳を提供するメソッドfree(text)を実装します。 品質は無視し、APIが投げるエラーはそのまま伝えます。

  • 取得できれば、品質に関係なく翻訳を返します
  • 翻訳APIからのエラーをそのまま伝えます
  • api.fetchメソッドを使います(api.fetchはpromiseを返します)
service.free('jIyaj');
// => Promise<...> resolves "I understand."

service.free("jIyajbe'");
// => Promise<...> rejects Error("Not yet translated")

2. 複数の翻訳をまとめて取得する(オール・オア・ナッシング)

無料会員向けに、無料サービスを使ってテキストの配列を翻訳するメソッドbatch([text, text, ...])を実装します。すべての翻訳を返すか、1つのエラーを返します。

  • すべて利用可能であれば、すべての翻訳を(同じ順序で)resolveします
  • 最初に発生したエラーでrejectします
  • テキストが1つも渡されなかった場合は、BatchIsEmptyエラーでrejectします
service.batch(['jIyaj', "majQa'"]);
// => Promise<...> resolves ["I understand.", "Well done!"]

service.batch(['jIyaj', "jIyajbe'"]);
// => Promise<...> rejects new Error("Not yet translated")

service.batch([]);
// => Promise<...> rejects BatchIsEmpty()

3. 翻訳をリクエストする(最大2回まで再試行)

_API storage_に翻訳を追加するよう_リクエスト_する、プレミアム会員向けのメソッドrequest(text)を実装します。 リクエストは、失敗すると自動的に再試行する必要があります。 同じリクエストに対して3回までしか呼び出してはいけません(宇宙の翻訳者を怒らせないでください!!!)。

  • api.requestがエラーを返さない場合は、undefinedでresolveします
  • api.requestがエラーを返す場合は、最大2回再試行します
  • 再試行を使い切った場合は、最後に受け取ったエラーでrejectします
service.request("jIyajbe'");
// => Promise<...> resolves (with nothing), can now be retrieved using the fetch API

4. 翻訳を取得し、品質を確認する、またはリクエストする

翻訳を取得するプレミアム会員向けのメソッドpremium(text, quality)を実装します。 翻訳がNotAvailableの場合は、翻訳をリクエストし、_API storage_に追加されたあとに取得します。 このメソッドは、一定のqualityしきい値を満たす場合にのみ翻訳を返すようにします。

  • api.fetchがresolveした場合は、resolveする前に品質を確認します
  • api.fetchがrejectした場合は、代わりに翻訳を_リクエスト_します
  • api.requestがrejectした場合は、エラーをそのまま伝えます
service.premium("jIyajbe'", 100);
// => Promise<...> resolves "I don't understand."

service.premium("'arlogh Qoylu'pu'?", 100);
// => Promise<...> rejects QualityThresholdNotMet()

service.premium("'arlogh Qoylu'pu'?", 40);
// => Promise<...> resolves "What time is it?"

注意

Note

'arlogh Qoylu'pu'?の正しい翻訳は**How many times has it been heard?**です。

GitHubで編集する リンクは新しいウィンドウまたはタブで開きます
JavaScript Exercism

翻訳サービスを始める準備はできましたか?

Exercismに登録すれば、37個のコンセプト159個の演習、そして本物の人間によるメンタリングとともに、JavaScriptを学んでマスターできます。すべて無料です。