ソフトウェアのリエンジニアリング:スパゲッティからクリーンなデザインまで

公開: 2022-03-11

私たちのシステムを見ていただけますか? ソフトウェアを書いた人はもういません、そして私たちは多くの問題を抱えています。 私たちは誰かがそれを調べて私たちのためにそれをきれいにする必要があります。

妥当な期間ソフトウェアエンジニアリングに携わってきた人なら誰でも、この一見無邪気な要求が「災害がそこらじゅうに書かれている」プロジェクトの始まりであることが多いことを知っています。 他の誰かのコードを継承することは、特にコードの設計が不十分でドキュメントが不足している場合、悪夢になる可能性があります。

そのため、最近、お客様の1人から既存のsocket.ioチャットサーバーアプリケーション(Node.jsで記述)を調べて改善するようにというリクエストを受け取ったとき、私は非常に警戒していました。 しかし、丘に向かって走る前に、私は少なくともコードを見ることに同意することにしました。

残念ながら、コードを見ただけで私の懸念が再確認されました。 このチャットサーバーは、単一の大きなJavaScriptファイルとして実装されていました。 この単一のモノリシックファイルを、すっきりと設計され、保守が容易なソフトウェアに再設計することは、確かに課題です。 しかし、私は挑戦を楽しんでいるので、同意しました。

ソフトウェアのリエンジニアリング

出発点-リエンジニアリングの準備

既存のソフトウェアは、1,200行の文書化されていないコードを含む単一のファイルで構成されていました。 うわぁ。 さらに、いくつかのバグが含まれ、いくつかのパフォーマンスの問題があることが知られていました。

さらに、ログファイル(他の人のコードを継承する場合は常に開始するのに適した場所)を調べると、潜在的なメモリリークの問題が明らかになりました。 ある時点で、プロセスは1GBを超えるRAMを使用していると報告されました。

これらの問題を考えると、ビジネスロジックをデバッグまたは拡張する前に、コードを再編成してモジュール化する必要があることがすぐに明らかになりました。 そのために、対処する必要のある初期の問題のいくつかは次のとおりです。

  • コード構造。 コードには実際の構造がまったくなく、構成とインフラストラクチャ、およびビジネスロジックを区別することが困難でした。 基本的に、モジュール化や関心の分離はありませんでした。
  • 冗長コード。 コードの一部(すべてのイベントハンドラーのエラー処理コード、Web要求を行うためのコードなど)が複数回複製されました。 複製されたコードは決して良いことではなく、コードの保守が非常に難しくなり、エラーが発生しやすくなります(冗長なコードが一方の場所で修正または更新され、もう一方の場所では更新されない場合)。
  • ハードコードされた値。 コードには、ハードコードされた値がいくつか含まれていました(めったに良いことではありません)。 (コード内のハードコードされた値に変更を加える必要はなく)構成パラメーターを介してこれらの値を変更できると、柔軟性が向上し、テストとデバッグが容易になります。
  • ロギング。 ロギングシステムは非常に基本的でした。 分析や解析が困難で不器用な単一の巨大なログファイルが生成されます。

主要なアーキテクチャの目的

コードの再構築を開始する過程で、上記で特定された特定の問題に対処することに加えて、ソフトウェアシステムの設計に共通する(または少なくとも共通である必要がある)主要なアーキテクチャの目的のいくつかに対処し始めたいと思いました。 。 これらには以下が含まれます:

  • 保守性。 それを維持する必要がある唯一の人であると期待してソフトウェアを書かないでください。 あなたのコードが他の誰かにとってどれほど理解しやすいか、そして彼らが修正したりデバッグしたりするのがどれほど簡単かを常に考えてください。
  • 拡張性。 現在実装している機能が、これまでに必要となるすべてのものであると思い込まないでください。 拡張しやすい方法でソフトウェアを設計します。
  • モジュール性。 機能を論理モジュールと個別モジュールに分離し、それぞれに独自の明確な目的と機能を持たせます。
  • スケーラビリティ。 今日のユーザーはますます焦り、即時の(または少なくとも即時に近い)応答時間を期待しています。 パフォーマンスが低く、待ち時間が長いと、最も有用なアプリケーションでさえ市場で失敗する可能性があります。 同時ユーザー数と帯域幅要件が増加すると、ソフトウェアはどのように機能しますか? 並列化、データベースの最適化、非同期処理などの手法は、負荷とリソースの需要が増加しているにもかかわらず、システムの応答性を維持する能力を向上させるのに役立ちます。

コードの再構築

私たちの目標は、単一のモノリシックmongoソースコードファイルから、モジュール化された一連のクリーンに設計されたコンポーネントに移行することです。 結果として得られるコードは、保守、拡張、およびデバッグが大幅に容易になるはずです。

このアプリケーションでは、コードを次の個別のアーキテクチャコンポーネントに編成することにしました。

  • app.js-これはエントリポイントです。コードはここから実行されます
  • config-これは構成設定が存在する場所です
  • ioW-すべてのIO(およびビジネス)ロジックを含む「IOラッパー」
  • ロギング-すべてのロギング関連コード(ディレクトリ構造には、すべてのログファイルを含む新しいlogsフォルダも含まれることに注意してください)
  • package.json -Node.jsのパッケージ依存関係のリスト
  • node_modules -Node.jsに必要なすべてのモジュール

この特定のアプローチには魔法はありません。 コードを再構築するには、さまざまな方法があります。 私は個人的に、この組織は過度に複雑になることなく、十分にクリーンでよく組織されていると感じました。

結果のディレクトリとファイルの編成を以下に示します。

再構築されたコード

ロギング

ロギングパッケージは、今日のほとんどの開発環境と言語用に開発されているため、今日では、「独自の」ロギング機能が必要になることはめったにありません。

Node.jsを使用しているため、基本的にNode.jsで使用するlog4jsライブラリのバージョンであるlog4js-nodeを選択しました。 このライブラリには、いくつかのレベルのメッセージ(WARNING、ERRORなど)をログに記録する機能などのいくつかの優れた機能があり、たとえば毎日分割できるローリングファイルを作成できるため、開くのに時間がかかり、分析や解析が難しい巨大なファイルを処理します。

私たちの目的のために、log4js-nodeの周りに小さなラッパーを作成して、特定の追加の必要な機能を追加しました。 log4js-nodeのラッパーを作成することを選択したことに注意してください。このラッパーは、コード全体で使用します。 これにより、これらの拡張ロギング機能の実装が1つの場所にローカライズされるため、ロギングを呼び出すときにコード全体の冗長性と不要な複雑さが回避されます。

I / Oを使用しており、複数の接続(ソケット)を生成する複数のクライアント(ユーザー)が存在するため、ログファイルで特定のユーザーのアクティビティを追跡できるようにし、また知りたい各ログエントリのソース。 したがって、アプリケーションのステータスに関するいくつかのログエントリと、ユーザーアクティビティに固有のログエントリがあることを期待しています。

ロギングラッパーコードでは、ユーザーIDとソケットをマップできます。これにより、ERRORイベントの前後に実行されたアクションを追跡できます。 ロギングラッパーを使用すると、イベントハンドラーに渡すことができるさまざまなコンテキスト情報を使用してさまざまなロガーを作成できるため、ログエントリのソースを知ることができます。

ロギングラッパーのコードは、こちらから入手できます。

構成

多くの場合、システムのさまざまな構成をサポートする必要があります。 これらの違いは、開発環境と本番環境の違いである場合もあれば、さまざまな顧客環境や使用シナリオを表示する必要性に基づく場合もあります。

これをサポートするためにコードに変更を加えるのではなく、一般的な方法は、構成パラメーターを使用してこれらの動作の違いを制御することです。 私の場合、異なる設定を持つ可能性のある異なる実行環境(ステージングと本番)を持つ機能が必要でした。 また、テストされたコードがステージングと本番の両方で適切に機能することを確認したかったので、この目的でコードを変更する必要があった場合、テストプロセスが無効になります。

Node.js環境変数を使用して、特定の実行に使用する構成ファイルを指定できます。 したがって、以前にハードコーディングされたすべての構成パラメーターを構成ファイルに移動し、必要な設定で適切な構成ファイルをロードする単純な構成モジュールを作成しました。 また、すべての設定を分類して、構成ファイルにある程度の編成を適用し、ナビゲートしやすくしました。

結果の構成ファイルの例を次に示します。

 { "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }

コードフロー

これまで、さまざまなモジュールをホストするフォルダー構造を作成し、環境固有の情報をロードする方法を設定し、ロギングシステムを作成したので、ビジネス固有のコードを変更せずにすべての要素を結び付ける方法を見てみましょう。

コードの新しいモジュラー構造のおかげで、エントリポイントapp.jsは非常にシンプルで、初期化コードのみが含まれています。

 var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);

コード構造を定義したとき、 ioWフォルダーはbusinessおよびsocket.io関連のコードを保持すると述べました。 具体的には、次のファイルが含まれます(リストされているファイル名のいずれかをクリックすると、対応するソースコードが表示されます)。

  • index.js – socket.ioの初期化と接続、およびイベントサブスクリプションを処理し、さらにイベントの集中エラーハンドラーを処理します
  • eventManager.js –すべてのビジネス関連ロジック(イベントハンドラー)をホストします
  • webHelper.jsリクエストを実行するためのヘルパーメソッド。
  • linkedList.js –リンクリストユーティリティクラス

Webリクエストを作成するコードをリファクタリングして別のファイルに移動し、ビジネスロジックを同じ場所に変更せずに維持することができました。

重要な注意事項:この段階では、 eventManager.jsには、実際には別のモジュールに抽出する必要のあるいくつかのヘルパー関数が含まれています。 ただし、この最初のパスの目的は、ビジネスロジックへの影響を最小限に抑えながらコードを再編成することであり、これらのヘルパー関数はビジネスロジックに複雑に関連付けられているため、これを後続のパスに延期して、コード。

Node.jsは定義上非同期であるため、「コールバック地獄」というネズミの巣に遭遇することがよくあります。これにより、コードのナビゲートとデバッグが特に困難になります。 この落とし穴を回避するために、私の新しい実装では、promiseパターンを採用し、非常に優れた高速のpromiseライブラリであるbluebirdを特に活用しています。 Promiseを使用すると、コードが同期しているかのようにコードを追跡できるようになり、エラー管理と、呼び出し間の応答を標準化するためのクリーンな方法も提供されます。 コードには、一元化されたエラー処理とロギングを管理できるように、すべてのイベントハンドラーがpromiseを返さなければならないという暗黙のコントラクトがあります。

すべてのイベントハンドラーは、(非同期呼び出しを行うかどうかに関係なく)promiseを返します。 これにより、エラー処理とロギングを一元化でき、イベントハンドラー内に未処理のエラーが発生した場合に、そのエラーを確実にキャッチできます。

 function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };

ロギングの説明では、すべての接続にコンテキスト情報を含む独自のロガーがあると述べました。 具体的には、ソケットIDとイベント名を作成時にロガーに関連付けているため、そのロガーをイベントハンドラーに渡すと、すべてのログ行にその情報が含まれます。

 var sLogger = logging.createLogger(socket.id + ' - ' + eventName);

イベント処理に関して言及する価値のあるもう1つのポイント:元のファイルには、socket.io接続イベントのイベントハンドラー内にあるsetInterval関数呼び出しがあり、この関数を問題として識別しました。

 io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });

このコードは、取得するすべての接続要求に対して、指定された間隔(この場合は1分)でタイマーを作成しています。 したがって、たとえば、常に300のオンラインソケットがある場合、毎分300のタイマーが実行されます。 上記のコードでわかるように、これに関する問題は、イベントハンドラーのスコープ内で定義されたソケットや変数が使用されていないことです。 使用されている唯一の変数は、モジュールレベルで宣言されているmessageHub変数です。これは、すべての接続で同じであることを意味します。 したがって、接続ごとに個別のタイマーはまったく必要ありません。 そのため、これを接続イベントハンドラーから削除し、一般的な初期化コード(この場合はinitialize関数)に含めました。

最後に、応答の処理でwebHelper.jsに、デバッグプロセスに役立つ情報をログに記録する認識されない応答の処理を追加しました。

 if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }

最後のステップは、Node.jsの標準エラーのログファイルを設定することです。 このファイルには、見逃した可能性のある未処理のエラーが含まれています。 Windowsのノードプロセス(理想的ではありませんが、ご存知のとおり)をサービスとして設定するには、標準出力ファイル、標準エラーファイル、および環境変数を定義できるビジュアルUIを備えたnssmというツールを使用します。

Node.jsのパフォーマンスについて

Node.jsは、シングルスレッドのプログラミング言語です。 スケーラビリティを向上させるために、採用できるいくつかの選択肢があります。 ノードクラスターモジュールがあるか、単にノードプロセスを追加して、それらの上にnginxを配置して、転送と負荷分散を行います。

ただし、この場合、すべてのノードクラスターサブプロセスまたはノードプロセスに独自のメモリスペースがあるため、これらのプロセス間で情報を簡単に共有することはできません。 したがって、この特定のケースでは、さまざまなプロセスでオンラインソケットを利用できるようにするために、外部データストア(redisなど)を使用する必要があります。

結論

これらすべてが整ったので、最初に渡されたコードの大幅なクリーンアップを達成しました。 これは、コードを完璧にすることではなく、コードを再設計して、サポートと保守が容易になり、デバッグが容易になり、簡素化されるクリーンなアーキテクチャ基盤を作成することです。

先に列挙した主要なソフトウェア設計原則(保守性、拡張性、モジュール性、スケーラビリティ)に準拠して、さまざまなモジュールの責任を明確かつ明確に識別するモジュールとコード構造を作成しました。 また、元の実装で、パフォーマンスを低下させる高いメモリ消費につながるいくつかの問題を特定しました。

この記事を楽しんでいただけたでしょうか。さらにコメントや質問がある場合はお知らせください。