چگونه با استفاده از جاوا و داکر یک کامپایلر کد آنلاین توسعه دهیم.

آیا تا به حال فکر کرده اید که پلتفرم هایی مانند Codeforces و LeetCode چگونه کار می کنند؟ چگونه آنها کد چندین کاربر را در برابر موارد آزمایشی کامپایل و اجرا می کنند؟ چگونه کارایی الگوریتم ها را تعیین می کنند؟
در این مقاله، به فرآیند ساخت یک پلتفرم حل مسئله بسیار موثر خواهیم پرداخت.
کد منبع این مقاله را می توان در منبع منبع Github من یافت
مشخصات
عملکردی
پلت فرم ما باید این باشد:
- قادر به پشتیبانی از چندین زبان برنامه نویسی
- قادر به اجرای کد کاربر در برابر چندین مورد تست
- امکان بازگشت یک حکم صحیح پس از اجرا، لیست احکام (پذیرفته شده، جواب اشتباه، از محدودیت زمانی فراتر رفت، از حد مجاز حافظه فراتر رفت، خطای زمان اجرا، خطای تالیف).
- اگر حکم یکی از این موارد باشد، می تواند یک خطای دقیق را به کاربر بازگرداند (از محدودیت زمانی فراتر رفت، خطای تالیف، خطای زمان اجرا، از حد مجاز حافظه فراتر رفت).
- قادر به برگرداندن مدت زمان تدوین.
- قادر به برگرداندن مدت زمان اجرا برای هر مورد آزمایشی است.
غیر کاربردی
پلت فرم ما باید:
- قادر به اجرای چندین درخواست به طور همزمان
- محیط های اجرایی مجزا (کد کاربر مخرب نباید به میزبان ماشین دسترسی داشته باشد)
- در صورت تجاوز از محدودیت زمانی، نباید اجازه داد کد اجرا شود.
- برای هر درخواست، کد کاربر باید یک بار کامپایل شود و چندین بار در برابر موارد آزمایشی اجرا شود.
- کاربر نباید به سیستم فایل میزبان دسترسی داشته باشد.
رابط
نمونه ای از ورودی:
{
"testCases": {
"test1": {
"input": "<YOUR_INPUT>",
"expectedOutput": "<YOUR_EXPECTED_OUTPUT>"
},
"test2": {
"input": "<YOUR_INPUT>",
"expectedOutput": "<YOUR_EXPECTED_OUTPUT>"
},
...
},
"sourceCode": "<YOUR_SOURCE_CODE>",
"language": "JAVA",
"timeLimit": 15,
"memoryLimit": 500
}
نمونه هایی از خروجی ها:
{
"verdict": "Accepted",
"statusCode": 100,
"error": "",
"testCasesResult": {
"test1": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "0 1 2 3 4 5 6 7 8 9",
"error": "",
"expectedOutput": "0 1 2 3 4 5 6 7 8 9",
"executionDuration": 175
},
"test2": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "9 8 7 1",
"error": "" ,
"expectedOutput": "9 8 7 1",
"executionDuration": 273
},
...
},
"compilationDuration": 328,
"averageExecutionDuration": 183,
"timeLimit": 1500,
"memoryLimit": 500,
"language": "JAVA",
"dateTime": "2022-01-28T23:32:02.843465"
}
{
"verdict": "Runtime Error",
"statusCode": 600,
"error": "panic: runtime error: integer divide by zero\n\ngoroutine 1 [running]:\nmain.main()\n\t/app/main.go:11 +0x9b\n",
"testCasesResult": {
"test1": {
"verdict": "Accepted",
"verdictStatusCode": 100,
"output": "0 1 2 3 4 5 6 7 8 9",
"error": "",
"expectedOutput": "0 1 2 3 4 5 6 7 8 9",
"executionDuration": 175
},
"test2": {
"verdict": "Runtime Error",
"verdictStatusCode": 600,
"output": "",
"error": "panic: runtime error: integer divide by zero\n\ngoroutine 1 [running]:\nmain.main()\n\t/app/main.go:11 +0x9b\n" ,
"expectedOutput": "9 8 7 1",
"executionDuration": 0
}
},
"compilationDuration": 328,
"averageExecutionDuration": 175,
"timeLimit": 1500,
"memoryLimit": 500,
"language": "GO",
"dateTime": "2022-01-28T23:32:02.843465"
}
پیاده سازی
محیط های مجزا از اعدام ها
برای جداسازی محیط ها برای اجرا می توانیم از کانتینرها استفاده کنیم. مفهوم این است که کد منبع ارائه شده توسط کاربر را بگیرید و یک تصویر Docker ایجاد کنید که شامل اطلاعات مربوط به اجرا (محدودیت زمانی، محدودیت حافظه، کد منبع، موارد آزمایشی و غیره) و اجرای این کانتینر در برابر چندین مورد آزمایشی است. بسته به کد خروجی کانتینر، می توانیم نتیجه اجرا را تعیین کنیم (پذیرفته شده، جواب اشتباه، از محدودیت زمانی فراتر رفت، از حد مجاز حافظه فراتر رفت، خطای زمان اجرا، خطای تالیف).
برخی از مزایای استفاده از ظروف
-
انزوا: کانتینرها راهی برای جداسازی برنامه ها از یکدیگر و همچنین از سیستم میزبان ارائه می دهند. این می تواند به جلوگیری از درگیری و بهبود امنیت کمک کند.
-
قابل حمل بودن: کانتینرها همه وابستگی های مورد نیاز برای اجرای یک برنامه را بسته بندی می کنند و جابجایی برنامه را بین محیط های مختلف آسان می کند.
-
ثبات: از آنجایی که کانتینرها همه وابستگی های مورد نیاز برای اجرای یک برنامه را بسته بندی می کنند، می تواند به اطمینان از اینکه برنامه به طور مداوم در محیط های مختلف رفتار می کند کمک کند.
-
مقیاس پذیری: کانتینرها را می توان به راحتی بزرگ یا کوچک کرد تا تقاضای در حال تغییر را برآورده کند، که مدیریت منابع را آسان می کند و اطمینان حاصل می کند که برنامه ها همیشه با عملکرد مطلوب اجرا می شوند.
-
مقرون به صرفه بودن: استفاده از کانتینرها می تواند به کاهش هزینه اجرا و مدیریت برنامه ها کمک کند، زیرا آنها سبک هستند و به منابع کمتری نسبت به ماشین های مجازی سنتی نیاز دارند.
-
انعطاف پذیری: کانتینرها را می توان در محیط های مختلفی از جمله در محل، در فضای ابری یا در یک محیط ترکیبی مستقر کرد که آنها را بسیار انعطاف پذیر می کند.
همانطور که در تصویر بالا ذکر شد، ما به دو نوع ظرف نیاز داریم. ظروف گردآوری و ظروف اعدام. هر درخواست یک تصویر از این نوع کانتینرها ایجاد می کند، سپس یک نمونه کانتینر از تصویر کانتینر کامپایل و چندین نمونه (یک مورد برای هر مورد آزمایشی) از تصویر کانتینر اجرا ایجاد می کند.
ظروف کامپایلاتون
این نوع کانتینرها برای کامپایل کد منبع به دودویی استفاده می شوند. این ظروف بسیار خاص هستند زیرا حجم را با سرویس اصلی به اشتراک می گذارند.
مثال:
FROM openjdk:11.0.6-jdk-slim
WORKDIR /app
ENTRYPOINT ["/bin/sh", "-c", "javac -d $EXECUTION_PATH $EXECUTION_PATH/$SOURCE_CODE_FILE_NAME && rm $EXECUTION_PATH/$SOURCE_CODE_FILE_NAME"]
ظروف اعدام
این نوع کانتینرها حاوی تمام اطلاعات مربوط به اجرا هستند و این کانتینر برای هر تست اجرا می شود و ایزوله است (حجم را با هیچ برنامه یا کانتینری به اشتراک نگذارید).
مثال:
FROM openjdk:11.0.6-jre-slim
WORKDIR /app
USER root
RUN groupadd -r user -g 111 && \
useradd -u 111 -r -g user -s /sbin/nologin -c "Docker image user" user
ADD . .
RUN chmod a+x ./entrypoint-*.sh
USER user
ENTRYPOINT ["/bin/sh", "-c", "./entrypoint-$TEST_CASE_ID.sh"]
همانطور که در Dockerfile کانتینر ذکر شد نقطه ورود فایل و دارای پیشوند است TEST_CASE_ID، توسط برنامه برای هر مورد آزمایشی با استفاده از یک الگو تولید می شود.
#!/usr/bin/env bash
ulimit -s [(${compiler.memoryLimit})]
timeout -s SIGTERM [(${compiler.timeLimit})] [(${compiler.executionCommand})]
exit $?
این الگو شامل محدودیت زمانی و محدودیت حافظه مجاز برای هر مورد آزمایشی است.
خط مشی امنیتی
به دلایل امنیتی و برای جلوگیری از دسترسی کاربر به سیستم fil می توانیم استفاده کنیم سیاست های امنیتی.
برای جاوا، ما سیاست های امنیتی داریم که برای کنترل دسترسی به منابع سیستم، مانند فایل ها و اتصالات شبکه، برای برنامه های جاوا استفاده می شود. مدیر امنیت جاوا مسئول اجرای این سیاست ها است. مدیر امنیتی را می توان به گونه ای پیکربندی کرد که بر اساس مبدأ کد، مانند مکان آن در سیستم فایل یا امضای دیجیتال، به کد خاصی مجوز داده یا رد کند.
grant {
permission java.io.FilePermission "/tmp/test.txt", "read,write";
permission java.net.SocketPermission "www.example.com:80", "connect";
};
خط مشی بالا را می توان به عنوان یک آرگومان خط فرمان در هنگام راه اندازی JVM تنظیم کرد، مانند این:
java -Djava.security.policy=mypolicy.policy MyApp
درخواست کاربر
ورودی کاربر به شکل زیر خواهد بود:
@Getter
@NoArgsConstructor
@EqualsAndHashCode
@AllArgsConstructor
public class Request {
/**
* The Source code.
*/
@ApiModelProperty(notes = "The sourcecode")
@NonNull
@JsonProperty("sourcecode")
protected String sourcecode;
/**
* The Language.
*/
@ApiModelProperty(notes = "The programming language")
@NonNull
@JsonProperty("language")
protected Language language;
/**
* The Time limit.
*/
@ApiModelProperty(notes = "The time limit in sec")
@NonNull
@JsonProperty("timeLimit")
protected int timeLimit;
/**
* The Memory limit.
*/
@ApiModelProperty(notes = "The memory limit")
@NonNull
@JsonProperty("memoryLimit")
protected int memoryLimit;
/**
* The Test cases.
*/
@ApiModelProperty(notes = "The test cases")
@NonNull
@JsonProperty("testCases")
protected LinkedHashMap<String, TestCase> testCases; // Note: test cases should be given in order
}
برای هر مورد آزمایشی، ورودی و خروجی مورد انتظار خواهیم داشت:
@Getter
@AllArgsConstructor
@EqualsAndHashCode
public class TestCase {
@ApiModelProperty(notes = "The input, can be null")
@JsonProperty("input")
private String input;
@ApiModelProperty(notes = "The expected output, can not be null")
@NonNull
@JsonProperty("expectedOutput")
private String expectedOutput;
}
استراتژی تدوین
در اینجا یک قطعه کد در مورد نحوه انجام کامپایل برای زبان های کامپایل شده آورده شده است. میتوانیم از الگوی استراتژی برای انتخاب الگوریتم برای زبانهای کامپایل یا تفسیر شده استفاده کنیم.
@Override
public CompilationResponse compile(Execution execution) {
// repository name must be lowercase
String compilationImageName = IMAGE_PREFIX_NAME + execution.getLanguage().toString().toLowerCase();
// If the app is running inside a container, we should share the same volume with the compilation container.
final String volume = compilationContainerVolume.isEmpty()
? System.getProperty("user.dir")
: compilationContainerVolume;
String sourceCodeFileName = execution.getSourceCodeFile().getOriginalFilename();
String containerName = COMPILATION_CONTAINER_NAME_PREFIX + execution.getImageName();
var processOutput = new AtomicReference<ProcessOutput>();
compilationTimer.record(() -> {
processOutput.set(
compile(volume, compilationImageName, containerName, execution.getPath(), sourceCodeFileName)
);
});
ProcessOutput compilationOutput = processOutput.get();
int compilationDuration = compilationOutput.getExecutionDuration();
ContainerInfo containerInfo = containerService.inspect(containerName);
ContainerHelper.logContainerInfo(containerName, containerInfo);
Verdict verdict = getVerdict(compilationOutput);
compilationDuration = ContainerHelper.getExecutionDuration(
containerInfo == null ? null : containerInfo.getStartTime(),
containerInfo == null ? null : containerInfo.getEndTime(),
compilationDuration);
ContainerHelper.deleteContainer(containerName, containerService, threadPool);
return CompilationResponse
.builder()
.verdict(verdict)
.error(compilationOutput.getStdErr())
.compilationDuration(compilationDuration)
.build();
}
استراتژی اجرا
در اینجا یک قطعه کد در مورد نحوه انجام یک اجرا آمده است.
public ExecutionResponse run(Execution execution, boolean deleteImageAfterExecution) {
buildContainerImage(execution);
var testCasesResult = new LinkedHashMap<String, TestCaseResult>();
Verdict verdict = null;
String err = "";
for (ConvertedTestCase testCase : execution.getTestCases()) {
TestCaseResult testCaseResult = executeTestCase(execution, testCase);
testCasesResult.put(testCase.getTestCaseId(), testCaseResult);
verdict = testCaseResult.getVerdict();
log.info("Status response for the test case {} is {}", testCase.getTestCaseId(), verdict.getStatusResponse());
// Update metrics
verdictsCounters.get(verdict.getStatusResponse()).increment();
if (verdict != Verdict.ACCEPTED) {
// Don't continue if the current test case failed
log.info("Test case id: {} failed, abort executions", testCase.getTestCaseId());
err = testCaseResult.getError();
break;
}
}
// Delete container image asynchronously
if (deleteImageAfterExecution) {
ContainerHelper.deleteImage(execution.getImageName(), containerService, threadPool);
}
return ExecutionResponse
.builder()
.verdict(verdict)
.testCasesResult(testCasesResult)
.error(err)
.build();
}
private TestCaseResult executeTestCase(Execution execution,
ConvertedTestCase testCase) {
log.info("Start running test case id = {}", testCase.getTestCaseId());
String expectedOutput = testCase.getExpectedOutput();
// Free memory space
testCase.freeMemorySpace();
var result = new AtomicReference<TestCaseResult>();
executionTimer.record(() -> {
// Run the execution container
result.set(runContainer(execution, testCase.getTestCaseId(), expectedOutput));
});
TestCaseResult testCaseResult = result.get();
return testCaseResult;
}
هر زبان برنامه نویسی پارامترهای اجرایی و پیکربندی خاص خود را دارد. برای ایجاد این چکیده میتوان از اصل وارونگی وابستگی با ایجاد استفاده کرد اجرا کلاس ها با استفاده از الگوی کارخانه ای انتزاعی
کارخانه چکیده
الگوی Abstract Factory یک الگوی طراحی است که راهی برای ایجاد خانواده هایی از اشیاء مرتبط یا وابسته بدون مشخص کردن کلاس های مشخص آنها فراهم می کند. برای ایجاد اشیایی استفاده می شود که متعلق به یک خانواده واحد هستند، اما قرار نیست با هم استفاده شوند.
@FunctionalInterface
public interface AbstractExecutionFactory {
/**
* Create execution.
*
* @param sourceCode the source code
* @param testCases the test cases
* @param timeLimit the time limit
* @param memoryLimit the memory limit
* @return the execution
*/
Execution createExecution(MultipartFile sourceCode,
List<ConvertedTestCase> testCases,
int timeLimit,
int memoryLimit);
}
public abstract class ExecutionFactory {
private static Map<Language, ExecutionType> registeredExecutionTypes = new EnumMap<>(Language.class);
private static Map<Language, AbstractExecutionFactory> registeredFactories = new EnumMap<>(Language.class);
private ExecutionFactory() {}
/**
* Register.
*
* @param language the language
* @param factory the factory
*/
public static void registerExecution(Language language, AbstractExecutionFactory factory) {
registeredFactories.putIfAbsent(language, factory);
}
/**
* Register execution type.
*
* @param language the language
* @param executionType the execution type
*/
public static void registerExecutionType(Language language, ExecutionType executionType) {
registeredExecutionTypes.putIfAbsent(language, executionType);
}
/**
* Gets execution type.
*
* @param language the language
* @return the execution type
*/
public static ExecutionType getExecutionType(Language language) {
return registeredExecutionTypes.get(language);
}
/**
* Gets registered factories.
*
* @return the registered factories
*/
public static Set<Language> getRegisteredFactories() {
return registeredFactories
.keySet()
.stream()
.collect(Collectors.toSet());
}
/**
* Gets execution.
*
* @param sourceCode the source code
* @param testCases the test cases
* @param timeLimit the time limit
* @param memoryLimit the memory limit
* @param language the language
* @return the execution
*/
public static Execution createExecution(MultipartFile sourceCode,
List<ConvertedTestCase> testCases,
int timeLimit,
int memoryLimit,
Language language) {
AbstractExecutionFactory factory = registeredFactories.get(language);
if (factory == null) {
throw new FactoryNotFoundException("No ExecutionFactory registered for the language " + language);
}
return factory.createExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit);
}
}
همه زبان ها را می توان در یک کلاس پیکربندی ثبت کرد.
private void configureLanguages() {
// Register factories
register(Language.JAVA,
(sourceCode, testCases, timeLimit, memoryLimit) -> new JavaExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit,
ExecutionFactory.getExecutionType(Language.JAVA)));
register(Language.PYTHON,
(sourceCode, testCases, timeLimit, memoryLimit) -> new PythonExecution(
sourceCode,
testCases,
timeLimit,
memoryLimit,
ExecutionFactory.getExecutionType(Language.PYTHON)));
...
برای اطلاعات بیشتر در مورد اجرا کلاس و نحوه ایجاد محیط اجرا به کلاس Execution مراجعه کنید
نحوه محاسبه کامپایل و مدت زمان اجرا
خوب، ما میتوانیم از دستور بازرسی docker استفاده کنیم تا تمام جزئیات مربوط به کانتینر (تاریخ ایجاد، تاریخ شروع اجرا، وضعیت، تاریخ پایان اجرا، وضعیت خروج…) را بدست آوریم.
Docker Inspect
می توانید با تعیین شناسه کانتینر یا تصویر یا نام ظرف یا تصویر به عنوان آرگومان از دستور بازرسی docker استفاده کنید.
به عنوان مثال، برای بررسی یک کانتینر به نام “my_container”، دستور زیر را اجرا کنید:
docker inspect my_container
همچنین می توانید از گزینه –format برای نمایش فقط فیلدهای خاص یا فرمت کردن خروجی به روشی خاص استفاده کنید.
docker inspect --format='{{json .Config}}' my_container
برای جزئیات بیشتر به کد منبع کامل برنامه مراجعه کنید.
سایر موارد موجود در پایگاه کد
- نمودار هلم برای استقرار سرویس در نمودارهای هلم K8s
- تهیه زیرساخت در Azure با استفاده از قالب ARM
- اجرای محلی با استفاده از docker-compose از جمله ارتباط بین RabbitMq و ApacheKafka docker-compose
نتیجه
ایجاد یک پلتفرم حل مسئله می تواند یک کار چالش برانگیز باشد، اما استفاده از کانتینرها می تواند این فرآیند را بسیار قابل مدیریت کند. با مزایای فراوان کانتینرها، مانند جداسازی، قابل حمل بودن، سازگاری، مقیاس پذیری و مقرون به صرفه بودن، به راحتی می توان فهمید که چرا آنها یک انتخاب عالی برای ساختن یک پلت فرم قدرتمند حل مشکل هستند. بنابراین، چه علاقهمند به کدنویسی هستید که به دنبال تقویت مهارتهای خود هستید یا کسبوکاری که به دنبال بهبود عملکرد خود هستید، از امتحان کردن کانتینرها دریغ نکنید، خوشحال خواهید شد که این کار را انجام دادید! و به یاد داشته باشید، همانطور که نقل قول معروف می گوید: “ظروف برای کد شما مانند لگو هستند”، بنابراین از ساختن پلت فرم حل مسئله خود لذت ببرید، امکانات بی پایان است!