欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

纯JAVA实现Online Judge--2.代码编译与类加载

程序员文章站 2022-03-09 16:06:08
...

前言

  作为一个Online Judge系统,最重要的一件事情,就是对用户提交给系统的代码进行编译,然后再将其加载进JVM中,然后才能通过反射的方式,调用入口方法,以此达到运行用户代码的目的。

  首先需要的说明是,我下面的贴出的代码,由于篇幅等缘故,我只会贴出跟我讲的内容有关的代码,因此并不会贴出所有的代码,代码会因此有些不连贯,也无法直接复制进行使用。整个系统完整的代码,会在这个系列最后的博文中给出GitHub的地址。

代码约束

  在开始讲我们实现之前,首先我说明几点,用户提交的代码必须要遵守的几点原则,至于为什么要有这样的规定呢,将会在后面慢慢给出答案:

  1. 有且只有一个外部类,并且是public并以Main命名,其余出现的类都必须是内部类
  2. 从系统标准输入流(System.In)中读取数据
  3. 将结果通过系统标准输出流(System.out)进行输出
  4. 必须拥有main方法并以此为程序入口


下图就是一个合格的代码规范示例:

纯JAVA实现Online Judge--2.代码编译与类加载

代码编译

  代码的编译在系统的web端进行的,大致流程就是从网络中接收到用户提交的代码后(其实就是一堆字符串),先用正则进行一些简单的判断,接着将其保存为一个文件,再然后利用JAVA提供的编译API(可以参考:我另外博文写的一个简单工具类)。
  其中,在将其保存为文件之前,我们会对其主类名Main进行修改,格式:u<用户ID>_<当前时间毫秒值>Main)如:u1_1494297585231Main,之所以这么做,主要有两点。一:避免两个文件名字一样造成冲突。二:编译成class后通过类加载进行加载时,对于同名的class是不允许重复加载的,当然有其他手段达到这个目的,但是这里为了方便起见,直接就让每个class都不同名了。
  这里也就解释了,为什么我上面规范中要求,用户提交的代码主类名必须是Main了,因为这里我要通过正则的方式,对其类名进行替换。又由于系统限制了用户每隔5秒才能提交一次代码,因此上述的命名规范确保了不会出现同名的文件名和编译出来后也不会有同名的class文件名、类名。


相关代码如下:
private Pattern packagePattern = Pattern.compile("^[ ]*package.*;");
	private Pattern classNamePattern = Pattern
			.compile("public[ ]*class[ ]*Main[ ]*\\{");
	private Pattern mainMethodPattern = Pattern
			.compile("public[ ]*static[ ]*void[ ]*main");

public void submitAnswer(ProblemAnswerDTO dto) {
		checkCodeStandard(dto.getCode());

		User user = dto.getUser();
		// 组拼java文件名,并修改里面的主类名
		// u用户ID:时间毫秒值
		String javaFileName = "u" + dto.getUser().getUserId() + "_"
				+ System.currentTimeMillis() + "Main";
		// 替换主类名为文件名
		String code = dto.getCode().replace("Main", javaFileName);

		// 创建当天的代码提交文件夹
		String today = DateUtil.getYYYYMMddToday();
		File dir = new File(user.getSourceFileRootPath() + File.separator
				+ today);
		if (!dir.exists()) {
			if (!dir.mkdirs()) {
				Log4JUtil.logError(new RuntimeException("创建文件夹失败,无法保存用户代码"));
			}
		}

		FileOutputStream outputStream = null;
		String javaFilePath = dir.getAbsolutePath() + File.separator
				+ javaFileName + WebConstant.DEFAULT_CODE_FILE_SUFFIX;
		try {
			outputStream = new FileOutputStream(javaFilePath);
			outputStream.write(code.getBytes());

			SubmitRecord record = new SubmitRecord();
			record.setIsAccepted(false);
			record.setCodeLanguage(dto.getCodeLanguage());
			record.setCodeFilePath(javaFilePath);
			record.setDetails("编译运行中");
			record.setScore(new Double(0));
			record.setSubmitProblemId(dto.getSubmitProblemId());
			record.setSubmitTime(new Date());
			record.setSubmitUserId(user.getUserId());
			record.setSubmitRecordTableName(user.getSubmitRecordTableName());
			submitRecordDao.add(record);

			// 发布判题请求
			sendAnswerToJudge(dto, javaFilePath, record);
		} catch (Exception e) {
			e.printStackTrace();
			throw new RuntimeException("创建文件失败,无法保存用户代码");
		} finally {
			try {
				outputStream.close();
			} catch (IOException e) {

			}
		}
	}

	/**
	 * 
	 * @param code
	 * @return true表示代码没有问题,false表示代码有问题
	 */
	private void checkCodeStandard(String code) {
		Matcher matcher = packagePattern.matcher(code);
		if (matcher.find()) {
			throw new ServiceLogicException("不能拥有package语句");
		}

		matcher = classNamePattern.matcher(code);
		if (!matcher.find()) {
			throw new ServiceLogicException("主类名必须是Main");
		}

		matcher = mainMethodPattern.matcher(code);
		if (!matcher.find()) {
			throw new ServiceLogicException("没有静态的main方法入口");
		}
	}

类加载

  class编译出来之后,我们就要通过定制我们自己的类加载器,去特定的地方,加载用户代码编译出来后的class文件,而我们自己定制的类加载器,在整个沙箱初始化的时候,就已经设置好了。

相关代码如下:

沙箱部分初始化函数:(从下面的代码,我们应该可以看出一开始我们就指定了我们的类加载器,去哪一个路径下去加载class)

/**
	 * 建立沙箱环境
	 * @param sandboxInitData 沙箱初始化信息
	 */
	private void buildEnvironment(SandboxInitData sandboxInitData) {
		sandboxClassLoader = new SandboxClassLoader(
				sandboxInitData.getClassFileRootPath());
		beginStartTime = System.currentTimeMillis();
		// 重定向输出流
		System.setOut(new PrintStream(resultBuffer));
		// 重定向输入流
		System.setIn(systemThreadIn);
	}

  类加载器代码:(加载一个类时,通过传入类名的方式,再加上之前已经设置好的class存放目录的路径,可以组装成一个唯一确定的文件路径,以此就可以读取这个class文件的信息,并将其加载到JVM中)

package cn.superman.sandbox.core.classLoader;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class SandboxClassLoader extends ClassLoader {
	private String classPath = null;

	public SandboxClassLoader(String classPath) {
		super();
		this.classPath = classPath;
	}

	@Override
	protected Class<?> findClass(String name) throws ClassNotFoundException {
		return loadSandboxClass(name);
	}

	public Class<?> loadSandboxClass(String name) throws ClassNotFoundException {
		String classFilePath = classPath + File.separator + name + ".class";
		FileInputStream inputStream = null;
		try {
			File file = new File(classFilePath);
			inputStream = new FileInputStream(file);
			byte[] classByte = new byte[(int) file.length()];
			inputStream.read(classByte);

			return defineClass(name, classByte, 0, classByte.length);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			try {
				inputStream.close();
			} catch (IOException e) {
			}
		}

		return null;
	}
}

定时更换类加载器

  由于用户代码编译出来后的class,再加载进JVM运行并得出结果过后,其实这个class的信息就再也没有价值了。但是,因为类加载器一只保持着对其的引用,因此这个class的信息会一直在内存中,导致GC很难将其回收,久而久之就会导致沙箱端的内存不断上升,导致无法下降。因此,我们可以通过一定的规则,定时更换类加载器的方式,让这个类加载器可以被回收,以及与其相关的类信息都可以被回收,以此达到释放内存的目的。

相关代码如下:

// 每加载超过100个类后,就替换一个新的ClassLoader
	public static final int UPDATE_CLASSLOADER_GAP = 100;


——————————————————————

if (loadClassCount >= UPDATE_CLASSLOADER_GAP) {
				loadClassCount = 0;
				// 重置类加载器,使得原有已经加载进内存的过期的类,可以得以释放
				sandboxClassLoader = new SandboxClassLoader(
						sandboxInitData.getClassFileRootPath());
				System.gc();
			}
			Future<List<ProblemResultItem>> processProblem = processProblem(request
					.getData());
			returnJudgedProblemResult(request.getSignalId(), processProblem);
			loadClassCount++;

预告

这次的内容大概就到这里,由于个人水平原因,难免会有错漏,欢迎大家与我交流,共同进步。下一篇的博文,我将会继续介绍跟沙箱端有关的内容。谢谢。