프로젝트/HongsamIDE

[HongsamIDE] 컴파일러 및 실행 환경 개발

이덩우 2023. 10. 18. 14:50

- 요구사항 분석

컴파일 환경 구축

  • 클라이언트 측에서 코드 에디터 라이브러리를 통해 작성한 코드를 문자열 형태로 받아온다.
  • 해당 문자열을 자바 파일로 변환한다.
  • Java Compiler API를 사용해 해당 파일을 컴파일한다.

 

실행 환경 구축

  • 컴파일에 성공할 경우, 해당 파일을 실행한다.
  • 실행 결과를 사용자에게 반환한다.

- 개발 및 배포 환경

  • Java : 11
  • SpringBoot : 2.7.XX
  • Build : Gradle
  • 배포 : AWS Lambda

 


- 컴파일러 

File javaFile = new File("/tmp/" + questionId + ".java"); // 만들어놓은 .java 파일 불러오는 부분
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); // 컴파일할 수 있는 컴파일러 모듈 생성
File outputDirectory = new File("/tmp"); // .class 파일을 생성할 위치

Iterable<String> options = Arrays.asList("--release", "11"); // 컴파일러 자바 버전 설정
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
fileManager.setLocation(StandardLocation.CLASS_OUTPUT, Arrays.asList(outputDirectory));
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromFiles(Arrays.asList(javaFile));
DiagnosticCollector<JavaFileObject> diag = new DiagnosticCollector<>(); // 컴파일 에러 정보를 담을 장소

// 에러를 담을 DignosticCollector를 포함한 컴파일 정보를 입력해 실제 Task를 생성
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diag, options, null, compilationUnits);

// 컴파일 시도
boolean success = task.call();
  • 컴파일할 자바 파일을 불러온다.
  • Java Compiler API를 통해 컴파일 모듈을 생성한다.
  • 컴파일 결과(실행 파일)을 저장할 디렉토리를 지정해준다.
  • 이 때, 상대경로 tmp로 디렉토리를 지정한 이유는 AWS Lambda 환경에 배포할 예정이기 때문이다.
    해당 파일을 임시로 보관하기 위한 스토리지가 필요했는데, AWS Lambda에서는 tmp라는 이름의 디렉토리에 512MB의 임시 스토리지 공간을 제공해준다.

 

  • 이제 컴파일 옵션을 설정하고 자바 파일을 컴파일 한다.
  • 컴파일 테스크를 설정할 때 다양한 옵션이 있었는데, 그 중 가장 핵심 부분은 DiagnosticCollector였다.
  • 컴파일 에러로 실패할 수 있기 때문에, 코딩 테스트 플랫폼이라는 특성 상 왜 컴파일 에러가 발생했는지 사용자에게 정보를 전달해줘야했다.
  • DiagnosticCollector를 생성하고 Task 옵션에 넣어주면 컴파일 실패 시 에러 정보를 Iterator로 꺼내볼 수 있었다.

 


- 실행(Execute) 

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
PrintStream printStream = new PrintStream(outputStream);
PrintStream originalOut = System.out; // 원래의 표준 출력 보관
System.setOut(printStream); // 표준 출력을 ByteArrayOutputStream으로 리다이렉션
  • 자바 리플렉션을 사용해 사용자의 표준 출력을 캡쳐해야한다.
  • 따라서 기존의 표준 출력을 보관하고, 리플렉션으로 실행한 메소드의 출력을 담는 새로운 OutputStream을 설정해줬다.
  • 마지막에 해당 출력결과를 기존 표준 출력으로 가져올 것이다.

 

// 컴파일된 .class 파일이 있는 디렉토리를 클래스 로더에 추가
URLClassLoader classLoader = URLClassLoader.newInstance(new URL[]{new File("/tmp").toURI().toURL()});
// 클래스 로드
Class<?> loadedClass = Class.forName(questionId, true, classLoader);
// 메소드 호출
Method mainMethod = loadedClass.getMethod("main", String[].class);
InputStream originalIn = System.in;
getS3File(questionId, "input");
getS3File(questionId, "answer");
System.setIn(new FileInputStream("/tmp/input.txt"));
try {
     mainMethod.invoke(null, (Object) new String[]{});
} catch (Exception e) {
      return e.getCause().toString();
}
  • 실행 클래스 파일을 불러오고 Main 메소드를 실행하는 모습이다.
  • 이 때 AWS S3에 저장되어있는 히든 케이스를 포함한 입력 데이터를 가져와 표준 입력으로 넣어준다.
  • 런타임 예외 발생 시 해당 예외 정보를 반환한다.

 

classLoader.close();
System.setIn(originalIn);
System.setOut(originalOut);
FileOutputStream resultFile = new FileOutputStream("/tmp/output.txt");
resultFile.write(outputStream.toByteArray());
resultFile.close();
outputStream.reset();

File classFile = new File("/tmp", questionId + ".class");
if (classFile.exists()) {
     classFile.delete();
}

if(compareFiles("/tmp/output.txt", "/tmp/answer.txt")) {
     return "정답입니다.";
} else {
     return "틀렸습니다.";
}
  • 실행이 끝났으므로 표준 출력을 가져와 사용자에게 전달하면 된다.
  • 다시 표준 출력을 기존 Origin으로 변경하고, 사용자 코드의 출력 결과를 저장할 파일을 생성한다.
  • 해당 파일에 리플렉션에서 발생한 출력 결과를 작성한다.
  • 이후 AWS S3에 저장되어있는 정답 파일과 비교해 결과를 반환한다.

- 응답 종류

if(compareFiles("/tmp/output.txt", "/tmp/answer.txt")) {
    return "정답입니다.";
} else {
    return "틀렸습니다.";
}
  • 정상 응답 : 컴파일 성공 후 런타임 예외없이 실행 된 경우로, 사용자의 표준 출력값이 서버 측에서 보관하고 있는 정답 파일과 동일한지 판단한다. 정답 유무에 따라 “정답입니다.” or “오답입니다.” 를 반환

 

else {   //컴파일 에러 발생
     DiagnosticCollector<JavaFileObject> diagnostics = diag;
     for (Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
          sb.append("Error on line " + diagnostic.getLineNumber() + ": " + diagnostic.getMessage(Locale.ENGLISH) + "\n");
     }
     return sb.toString();
}
  • 컴파일 에러 : Java Compiler API를 사용해 자바 파일을 컴파일하는 과정에서 에러가 생긴다면 해당 에러정보를 DiagnosticCollector에 담는다. 이후 에러 정보가 있을 시 에러 라인과 에러 내용을 반환한다.

 

try {
    mainMethod.invoke(null, (Object) new String[]{});
} catch (Exception e) {
    return e.getCause().toString();
}
  • 런타임 예외 : 컴파일 성공 후 Java Reflection을 사용해 main 메소드를 invoke하는 단계를 try-catch로 감싸, 런타임 예외가 발생한다면 해당 예외 정보를 반환한다.

정상 응답 : 정답처리
정상 응답 : 오답처리
컴파일 에러 발생
런타임 예외 발생

 

 


- 시연 동영상