ボットの機能が増えるにつれて、その中身であるコードの量は増えていきます。簡単なアプリケーションであればひとつのファイルにすべてを記述できますが、やがて限界がやってくるでしょう。

理論上はひとつのファイルですべての処理を書き切ることは可能ではありますが、複数の概念が混在することで見通しが悪くなりますし、ひとつのファイル内であっちこっち移動しなければなりません。最近のコードエディタはタブ切替機能があることが一般的なので、そういった面でも適切な処理の単位でコードを分割することが好ましいと言えるでしょう。

分割するにあたってはいくつかの方法があります。最初は簡単なものから始めて徐々に難しいものに進んで行きますが、アプリケーションの規模に応じて使い分けたり組み合わせたりしてみましょう。YAGNI原則というものがありますが、必要もないのに導入しても使われないばかりか問題を複雑にしただけ、ということも起きえるので見極めが大事です。

関数を作る

コードを書いていると同じようなコードが出てくることでしょう。毎回同じコードを書くのは煩わしいですし、そういった共通した処理に名前をつけて必要に応じて呼び出せたら楽です。

ここで登場する関数はそんな要求を満たすもののひとつです。中学や高校の数学で聞いたっきりでご無沙汰という人がいるかもしれません。さて、数学の関数というものはこういう形のものでした。

ここで は関数の名前、 は引数で、 の部分が関数の定義です。 とすると、 です。

プログラミング言語における関数も似たような形で記述できます。プログラミング言語は数学における数式と異なる部分があり、余分なキーワードや記号が必要ですが、やっていることはあまり変わりありません。まずは簡単な例を見てみましょう。

// 関数f()を定義する
function f(x, y) {
    return x * 2 + y
}
 
// 関数f()を呼び出す
f(5, 3)  // 13

JavaScriptの関数はキーワード function で始まり、次に関数名を書きます。そのあとに引数リストが続き、{ } の中身が処理となります。

関数は別の場所から呼び出すことができ、return文を実行するか、最後まで実行すると処理を終了します。return文を使わなかったときに関数から返される値(返値。かえりちと読みます)は、未定義値である undefined となります。返値は使っても使わなくても構いません。

もし同じような処理が何回もでてくるなら、関数を作って処理を共通化するといいでしょう。コードの見通しがよくなりますし、処理を変更することになっても、修正箇所は少なく済むようになります。

  • 関数を使うと処理に名前をつけて必要なときに呼び出せる
  • 処理を共通化してコード量を減らし、開発時間を短縮しよう

ファイルを分ける

ひとつのファイルに書ききれないのであれば、複数のファイルに分ければいいのです。簡単簡単。この方法は一見単純に見えますが、どういう単位でファイルを分割すればいいのかが悩ましく、答えが出しきれないものでもあります。

Node.jsではrequireキーワードを使って別ファイルのオブジェクトを参照できます。まずはa.jsからb.jsの内容を読み込んでみましょう。b.js に与えられたふたつの値を足し合わせて返す add() 関数を定義して、a.js から読み込んで実行するコードは次のように書けます。

// b.js
 
function add(a, b) {
    return a + b
}
 
// add関数を公開する
module.exports = add
// a.js
 
const add = require('./b.js')
 
console.log(add(2, 3))  // 5

読み込まれるファイル側(b.js)ではどのオブジェクトを公開するのか宣言する必要があり、それが module.exports への代入です。a.js 側では require('./b.js') の部分が module.exports であるかのように動作し、b.js 側のオブジェクトを受け取ることができるのです。

💬 ./ ってなんですか?

カレントディレクトリを表します。Node.jsではrequire('fs') のようにして標準ライブラリも読み込めますが、それと区別するためにユーザーが作ったファイルを読み込むときには明示的にパスを指定する必要があります。パスの書き方についてはCodeZineの相対パスと絶対パスもあわせてご覧ください。

試しにadd()関数を読み込めるようにしましたが、別の関数を作ってそれも読み込みたいという要求が生まれることは容易に想像ができます。しかし、module.exportsにはひとつの値しか設定できません。読み込みたいオブジェクトごとにファイルを作っていけばなんとかなりそうですが、オブジェクトごとにファイルが増えていくのは正直バカバカしいです。

そこで私たちはもう少し賢い方法がとれます。それはオブジェクト型を使い、その中に公開したいオブジェクトを入れ子にして設定するという方法です。

// b.js
 
function add(a, b) {
    return a + b
}
 
function sub(a, b) {
    return a - b
}
 
module.exports = { add, sub }
// a.js
 
const { add, sub } = require('./b.js')
// addしか使わないなら const { add } = require('./b.js')
 
console.log(add(2, 3))  // 5
console.log(sub(2, 3))  // -1

これで機能ごとに別のファイルにまとめておいて、必要なものだけ取り出して使えるようになりました。

💡 分割代入について

{ add, sub }{ add: add, sub: sub } を省略した表現であり、さらに冗長に書けば、{ 'add': add, 'sub': sub } となります。詳しくはMDNの分割代入のページも参考にしてください。

クラスを作る

近年のプログラミング言語の多くでは、オブジェクト指向と呼ばれる概念を採用しています。細かい定義はさておき、クラスという仕組みを使って、概念を構造化データとして扱い、内部状態を持たせ、振る舞いをつけられるようにしたものです。

プログラム自体もなんらかの問題を解決するために、入力を与えられて、内部状態を変更させ、結果を出力する仕組みになっていますが、オブジェクト指向プログラミングでは、プログラムを構成する要素についても、クラスという単位で同じようなことを行います。

こんな抽象的なことを言っていてもなにを言ってるのかさっぱりだと思うので、ブタさん貯金箱の例を出しましょう。ここで考えるブタさん貯金箱の機能は次の通りです。

  • ブタさん貯金箱は内部状態として預金(amount)と破壊状態(broken)を持つ
  • 購入時の預金は0円で、壊れてもいない
  • 壊れていなければ、貯金できる
  • 壊れていなければ、壊すことで貯金を引き出すことができる

💡 仕様を作ろう

概念が持つ性質や動作を定義したものを仕様と呼びます。適切な仕様を定義することはソフトウェア設計においてコードを書くことよりも重要な作業です。抜けや漏れがあると、人によって解釈が異なったり、そのときの状況で都合のよい実装になったりしてしまいます。

なお、上記仕様にも抜けがあります。貯金箱の性質を思い浮かべながら考えてみてください。

これをコードにしてみるとおおよそこんな感じになるでしょう。

class PiggyBank {
    // 最初の状態を設定する
    constructor() {
        this.amount = 0
        this.broken = false
    }
 
    // 貯金箱への入金。壊れていない限りお金を貯められる
    deposit(amount) {
        if (!this.broken) {
            this.amount += amount
        }
    }
 
    // 貯金箱から出金。取り出すときに壊してしまう
    withdraw() {
        const amount = this.amount
        if (!this.broken) {
            this.amount = 0
            this.broken = true
        }
        return amount
    }
}

ブタさん貯金箱の動作の模倣に必要な要素がPiggyBankクラスに集約されることで扱いやすくなりました。もしクラスを使わなかったらどうなるでしょうか?例えば以下のコードでは、何の総量(amount)なのかわかりませんし、何が壊れて(broken)いるのかわかりません。

let amount = 0
let broken = false

接頭辞を付ければ多少マシになるかもしれませんが、それでも概念が散らばってしまっていることには変わりありません。なんらかの処理をするたびにふたつの値を与えないといけない煩わしさがありますし、預金や出金という振る舞いも定義していないので、しっかり管理していないと一部のデータだけを操作してしまったり、不特定多数のデータ操作元を作ってしまったりして整合性が取れなくなります。

// ブタさん貯金箱であることはわかるものの…
let piggyBankAmount = 0
let piggyBankBroken = false

ある概念が登場したら、それはクラスという単位でコードに書き起こせるかもしれません。普段よく目にするものを注意深く観察して、どういう内部状態や振る舞いがあるのかといった思考訓練もクラス設計のためには役立ちます。トグルスイッチ、電気ケトル、自動販売機…等々、身の回りに存在するものに考えを巡らせてみましょう。

  • クラスを使って、概念を内部状態と振る舞いの組合せとして表現しよう
  • 概念を注意深く観察して、本質的な部分を抽出しよう

階層型アーキテクチャを採用する

小規模〜中規模程度のアプリケーション(各々の主観で構いません)であれば、適切にクラスを作りファイルに分割すれば十分整理されたアプリケーションが開発できます。実際にあちこちのプロジェクトを見ると、それほど細分化されていないこともありますし、1ファイルで数千行に及ぶコードが記述されていることもあります。ほとんどの場合はこれで十分なのです。

しかし、中には一度作って終わりではなく、開発や保守、運用を続けていかなければならないこともあります。この連載で取り上げているDiscordのボットもそれに該当します。開発を続けていく中で、なにか機能追加や変更を要望されることもありますし、使っているライブラリの破壊的変更が発生するかも知れません。そうしたときになるべく既存のコードへの影響が小さくなるような設計であることが望ましいと言えます。ライブラリの変更でサービスが提供できなくなる、なんて悲しい事態は避けたいのです。

クラスを作りファイルを分割することも設計ではありますが、ここで紹介するレイヤードアーキテクチャはさらにその先の持続可能な開発を目指しています。機能や責任によって階層(レイヤー)を分割し、依存関係にまつわる問題を解決することで、変更に強いアプリケーションが構築できるようになります。

難しい概念なのでこの連載では詳しく説明しませんが、個々の重要な概念については都度解説していきます。全体像についてはわかりやすく解説した書籍やサイトがあるので、興味が湧いたら一度読んでみてください。

第5回 避けるべきこと