برنامه نویسی

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

آیا تا به حال فکر کرده اید که پلتفرم هایی مانند 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

نتیجه

ایجاد یک پلتفرم حل مسئله می تواند یک کار چالش برانگیز باشد، اما استفاده از کانتینرها می تواند این فرآیند را بسیار قابل مدیریت کند. با مزایای فراوان کانتینرها، مانند جداسازی، قابل حمل بودن، سازگاری، مقیاس پذیری و مقرون به صرفه بودن، به راحتی می توان فهمید که چرا آنها یک انتخاب عالی برای ساختن یک پلت فرم قدرتمند حل مشکل هستند. بنابراین، چه علاقه‌مند به کدنویسی هستید که به دنبال تقویت مهارت‌های خود هستید یا کسب‌وکاری که به دنبال بهبود عملکرد خود هستید، از امتحان کردن کانتینرها دریغ نکنید، خوشحال خواهید شد که این کار را انجام دادید! و به یاد داشته باشید، همانطور که نقل قول معروف می گوید: “ظروف برای کد شما مانند لگو هستند”، بنابراین از ساختن پلت فرم حل مسئله خود لذت ببرید، امکانات بی پایان است!

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا