소프트웨어 리엔지니어링: 스파게티에서 깔끔한 디자인으로

게시 됨: 2022-03-11

저희 시스템 좀 보실래요? 소프트웨어를 작성한 사람은 더 이상 주위에 없으며 우리는 많은 문제를 겪고 있습니다. 우리는 그것을 살펴보고 우리를 위해 청소할 누군가가 필요합니다.

상당한 시간 동안 소프트웨어 엔지니어링에 종사한 사람은 이 겉보기에 무해한 요청이 종종 "재해가 도사리고 있는" 프로젝트의 시작이라는 것을 알고 있습니다. 다른 사람의 코드를 상속하는 것은 악몽이 될 수 있습니다. 특히 코드가 잘못 설계되고 문서가 부족한 경우에는 더욱 그렇습니다.

그래서 최근 한 고객으로부터 기존 socket.io 채팅 서버 애플리케이션(Node.js로 작성)을 살펴보고 개선해 달라는 요청을 받았을 때 매우 조심스러웠습니다. 그러나 언덕을 향해 달리기 전에 나는 적어도 코드를 살펴보기로 동의하기로 결정했습니다.

불행히도 코드를 보고 내 우려를 재확인했습니다. 이 채팅 서버는 하나의 큰 JavaScript 파일로 구현되었습니다. 이 단일 모놀리식 파일을 깔끔하게 설계되고 쉽게 유지 관리할 수 있는 소프트웨어로 리엔지니어링하는 것은 실제로 어려운 일이 될 것입니다. 하지만 저는 도전을 즐기므로 동의했습니다.

소프트웨어 리엔지니어링

출발점 - 리엔지니어링 준비

기존 소프트웨어는 1,200줄의 문서화되지 않은 코드가 포함된 단일 파일로 구성되었습니다. 그렇군요. 또한 일부 버그가 포함되어 있고 성능 문제가 있는 것으로 알려져 있습니다.

또한 로그 파일(다른 사람의 코드를 상속할 때 항상 좋은 시작 위치)을 조사한 결과 잠재적인 메모리 누수 문제가 드러났습니다. 어떤 시점에서 프로세스는 1GB 이상의 RAM을 사용하는 것으로 보고되었습니다.

이러한 문제를 감안할 때 비즈니스 로직을 디버그하거나 강화하기 전에 코드를 재구성하고 모듈화해야 한다는 것이 즉시 분명해졌습니다. 이를 위해 해결해야 할 초기 문제 중 일부는 다음과 같습니다.

  • 코드 구조. 코드에 실제 구조가 전혀 없었기 때문에 비즈니스 로직에서 인프라 구성과 구분하기 어려웠습니다. 본질적으로 모듈화나 관심사 분리가 없었습니다.
  • 중복 코드. 코드의 일부(예: 모든 이벤트 핸들러의 오류 처리 코드, 웹 요청 작성 코드 등)가 여러 번 복제되었습니다. 복제된 코드는 결코 좋은 것이 아니므로 코드를 유지 관리하기가 훨씬 더 어렵고 오류가 발생하기 쉽습니다(중복 코드가 한 곳에서는 수정되거나 업데이트되지만 다른 곳에서는 그렇지 않은 경우).
  • 하드코딩된 값. 코드에 하드코딩된 값이 많이 포함되어 있습니다(드물게 좋은 경우). 코드에서 하드코딩된 값을 변경해야 하는 대신 구성 매개변수를 통해 이러한 값을 수정할 수 있으면 유연성이 향상되고 테스트 및 디버깅을 용이하게 하는 데 도움이 될 수 있습니다.
  • 벌채 반출. 로깅 시스템은 매우 기본적이었습니다. 분석하거나 구문 분석하기 어렵고 서투른 하나의 거대한 로그 파일을 생성합니다.

주요 아키텍처 목표

코드 재구성을 시작하는 과정에서 위에서 식별한 특정 문제를 해결하는 것 외에도 모든 소프트웨어 시스템 설계에 공통적이거나 최소한 공통되어야 하는 몇 가지 주요 아키텍처 목표를 해결하기 시작했습니다. . 여기에는 다음이 포함됩니다.

  • 유지보수성. 소프트웨어를 유지 관리해야 할 유일한 사람이 될 것으로 기대하는 소프트웨어를 작성하지 마십시오. 다른 사람이 코드를 얼마나 이해할 수 있는지, 그리고 다른 사람이 수정하거나 디버그하는 것이 얼마나 쉬운지 항상 고려하십시오.
  • 확장성. 지금 구현하고 있는 기능이 필요할 것이라고 가정하지 마십시오. 확장하기 쉬운 방식으로 소프트웨어를 설계하십시오.
  • 모듈성. 각각의 명확한 목적과 기능을 가진 논리적이고 고유한 모듈로 기능을 분리합니다.
  • 확장성. 오늘날의 사용자는 즉각적인(또는 최소한 즉각적인) 응답 시간을 기대하면서 점점 더 참을성이 없습니다. 낮은 성능과 긴 대기 시간으로 인해 가장 유용한 애플리케이션도 시장에서 실패할 수 있습니다. 동시 사용자 수와 대역폭 요구 사항이 증가하면 소프트웨어가 어떻게 작동합니까? 병렬화, 데이터베이스 최적화 및 비동기식 처리와 같은 기술은 증가하는 로드 및 리소스 요구에도 불구하고 시스템이 응답성을 유지하는 기능을 개선하는 데 도움이 될 수 있습니다.

코드 재구성

우리의 목표는 단일 모놀리식 mongo 소스 코드 파일에서 깔끔하게 설계된 구성 요소의 모듈화된 집합으로 이동하는 것입니다. 결과 코드는 유지 관리, 향상 및 디버그하기가 훨씬 쉬워야 합니다.

이 응용 프로그램의 경우 코드를 다음과 같은 고유한 아키텍처 구성 요소로 구성하기로 결정했습니다.

  • app.js - 이것은 진입점이며 여기에서 코드가 실행됩니다.
  • config - 구성 설정이 있는 곳입니다.
  • ioW - 모든 IO(및 비즈니스) 로직을 포함하는 "IO 래퍼"
  • logging - 모든 로깅 관련 코드(디렉토리 구조에는 모든 로그 파일이 포함될 새 logs 폴더도 포함됩니다.)
  • package.json - Node.js에 대한 패키지 종속성 목록
  • node_modules - Node.js에 필요한 모든 모듈

이 특정 접근 방식에는 마법 같은 것이 없습니다. 코드를 재구성하는 방법에는 여러 가지가 있을 수 있습니다. 저는 개인적으로 이 조직이 너무 복잡하지 않고 충분히 깨끗하고 잘 정리되어 있다고 느꼈습니다.

결과 디렉토리 및 파일 구성은 아래와 같습니다.

재구성된 코드

벌채 반출

로깅 패키지는 오늘날 대부분의 개발 환경 및 언어에 맞게 개발되었으므로 "자신만의" 로깅 기능이 필요한 경우는 드뭅니다.

Node.js로 작업하고 있기 때문에 기본적으로 Node.js와 함께 사용하기 위한 log4js 라이브러리 버전인 log4js-node를 선택했습니다. 이 라이브러리에는 여러 수준의 메시지(경고, 오류 등)를 기록하는 기능과 같은 몇 가지 멋진 기능이 있으며, 예를 들어 매일 분할할 수 있는 롤링 파일을 가질 수 있으므로 다음을 수행할 필요가 없습니다. 여는 데 많은 시간이 걸리고 분석 및 구문 분석이 어려운 대용량 파일을 처리합니다.

우리의 목적을 위해 몇 가지 특정 추가 원하는 기능을 추가하기 위해 log4js-node 주위에 작은 래퍼를 만들었습니다. log4js-node 주위에 래퍼를 생성하기로 선택했으며 코드 전체에서 사용할 것입니다. 이렇게 하면 이러한 확장된 로깅 기능의 구현이 단일 위치에서 현지화되어 로깅을 호출할 때 코드 전체에서 중복성과 불필요한 복잡성을 피할 수 있습니다.

우리는 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 폴더에 비즈니스 및 socket.io 관련 코드가 포함될 것이라고 말했습니다. 특히 다음 파일이 포함됩니다(해당 소스 코드를 보려면 나열된 파일 이름 중 하나를 클릭할 수 있음).

  • index.js – socket.io 초기화 및 연결, 이벤트 구독 및 이벤트에 대한 중앙 집중식 오류 처리기를 처리합니다.
  • eventManager.js – 모든 비즈니스 관련 로직(이벤트 핸들러)을 호스팅합니다.
  • webHelper.js – 웹 요청을 수행하기 위한 도우미 메서드입니다.
  • linkedList.js – 연결 목록 유틸리티 클래스

웹 요청을 하는 코드를 리팩토링하여 별도의 파일로 옮겼고, 비즈니스 로직을 수정하지 않고 그대로 유지했습니다.

한 가지 중요한 참고 사항: 이 단계에서 eventManager.js 에는 여전히 별도의 모듈로 추출해야 하는 일부 도우미 함수가 포함되어 있습니다. 그러나 이 첫 번째 단계에서 우리의 목표는 비즈니스 로직에 대한 영향을 최소화하면서 코드를 재구성하는 것이고 이러한 도우미 기능은 비즈니스 로직에 너무 복잡하게 연결되어 있었기 때문에 우리는 이것을 조직 개선을 위한 후속 단계로 연기하기로 결정했습니다. 암호.

Node.js는 정의상 비동기식이므로 코드를 탐색하고 디버그하기 특히 어렵게 만드는 "콜백 지옥"이라는 쥐의 둥지를 자주 접하게 됩니다. 이 함정을 피하기 위해 새로운 구현에서 나는 Promise 패턴을 사용했고 특히 매우 훌륭하고 빠른 Promise 라이브러리인 bluebird를 활용하고 있습니다. 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);

이벤트 처리와 관련하여 언급할 가치가 있는 또 다른 사항: 원본 파일에서 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)를 사용해야 합니다.

결론

이 모든 것이 제자리에 있으면서 우리는 원래 우리에게 건네진 코드의 상당한 정리를 달성했습니다. 이것은 코드를 완벽하게 만드는 것이 아니라 지원 및 유지 관리가 더 쉽고 디버깅을 용이하고 단순화하는 깨끗한 아키텍처 기반을 만들기 위해 코드를 재설계하는 것입니다.

앞서 열거한 주요 소프트웨어 설계 원칙(유지보수성, 확장성, 모듈성 및 확장성)을 준수하여 서로 다른 모듈 책임을 명확하고 명확하게 식별하는 모듈과 코드 구조를 만들었습니다. 또한 성능을 저하시키는 높은 메모리 소비로 이어지는 원래 구현의 몇 가지 문제를 식별했습니다.

기사가 도움이 되었기를 바랍니다. 추가 의견이나 질문이 있으면 알려주세요.