استفاده از JavaCompiler و کامپایل کردن کد در زمان اجرا

این مطلب را آقای پارسا نوری تهیه کرده و برای انجمن جاواکاپ ارسال کردهاند.
برای پروژهی درس برنامهنویسی پیشرفته داشتم روی مدل پروژهام فکر میکردم که به فکرم رسید چه جالب میشد اگه به جای اینکه من یک فرمول برای محاسبه یک قسمت را به صورت hard code وارد کنم، کاربر بتواند در زمان اجرا این فرمول را تعیین کند. راهی که برای پیادهسازی این موضوع به ذهنم رسید، ورودی گرفتن یک تکه کد از کاربر و ساخت کلاسی از روی آن و کامپایل کردن کلاس در زمان اجرا بود. من بعد از اتمام پروژه تصمیم گرفتم بیشتر در مورد Runtime Compilation در جاوا مطالعه کنم و اطلاعات کسبشده را با دوستدارانِ جاوا به اشتراک بگذارم.
ما میخواهیم JavaCompiler که واسطی در پکیج javax.tools هست را بررسی کنیم. این واسط برای این طراحی شده تا ما بتوانیم از کامپایلرِ سیستم به عنوان یک شی استفاده کنیم. با استفاده از متدهایی روی این شی میتوانیم اعمالی همچون کامپایل کردن یک فایل را در زمان اجرا انجام دهیم.
ابتدا با استفاده از کلاس ToolProvider، کامپایلر موجود روی سیستم را به دست میآوریم.
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
فرایند کامپایل
کامپایلر برای کامپایل به واحدهایی (Compilation Unit) برای کامپایل کردن نیاز دارد. سپس این واحدها را کامپایل کرده و با استفاده از FileManager در محلی ذخیره میکند. شاید ما نیاز داشته باشیم برای Compile کدمان یک سری پارامتر هم ورودی بدهیم. کامپایلر برای تعریف یک وظیفهی کامپایل (Compilation Task) باید بتواند پارامترها را نیز بگیرد. در نهایت، کدی که برای کامپایلر ارسال میکنیم، ممکن است خطای کامپایل داشته باشد، برای همین Compilation Task باید بداند این خطاها را به کجا روانه کند.
جاوا برای کارهایی از قبیل کامپایل کردن در زمان اجرا، واسطی را برای نمایش فایل به نام FileObject در نظر گرفته. FileObject تنها نمایانگر فایل نیست بلکه در اینجا منظور از فایل هرگونه منبعی از داده است. حال این منبع میتواند یک مقدار Cache در RAM سیستم باشد یا دادهای در Database یا یک فایل معمولی در دیسک. واسط FileObject یک زیرواسط به نام JavaFileObject دارد که به طور خاص نمایانگر سورسکد کلاسها و خود فایل کلاسها میباشد. ما برای کامپایل کردن در زمان اجرا بایستی شیای از جنس Iterable که روی فرزندان واسط JavaFileObject پیمایش میکند را به کامپایلر مورد بحث تحویل دهیم.
با توجه به این توضیحات، شاید امضای متد جاوا برای گرفتن یک Compilation Task از کامپایلر که به صورت زیر است تا حدودی برایتان ملموستر شود:
JavaCompiler.CompilationTask getTask( Writer out, JavaFileManager fileManager, DiagnosticListener<? super JavaFileObject> diagnosticListener, Iterable<String> options, Iterable<String> classes, Iterable<? extends JavaFileObject> compilationUnits )
در این متد، out یک Writer است که برای خروجیهای اضافیِ کامپایلر کاربرد دارد. FileManager مدیر فایلیست که وظیفه گرفتن خروجی کامپایل را دارد و DiagnosticListener به ایرادهای کد که در زمان کامپایل مشخص میشوند مثل خطاهای سینتکسی مربوط است. Options ویژگیهای کامپایل شدن کد ماست و compilationUnits واحدهایی است که قرار است کامپایل شوند. خروجی نیز کلاسی در کلاس JavaCompiler به نام CompilationTask است.
StandardJavaFileManager چیست ؟
StandardJavaFileManager واسطی بر اساس java.io است که میتوان شیای از آن را ازJavaCompiler با استفاده از متد getStandardFileManager گرفت و استفاده کرد. StandardJavaFileManager نیز مثل سایر کلاسها و واسطهایی که با IO کار دارند زیرواسط Closeable است و بعد از این که کارمان با آن تمام شد باید آن را ببندیم (Close کنیم).
در برنامه، بعد از اینکه فرایند کامپایل تمام میشود، برای استفاده از کلاسهای کامپایلشده بایستی کلاسهای کامپایلشده در حافظه لود شوند. این کار با استفاده از ClassLoader اتفاق میافتد.
با توجه به توضیحات بالا شاید بد نباشد نگاهی به تکه کد زیر بیندازیم و کارکرد آن را توضیح بفهمیم:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); //1 StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null); //2 String sourceFilePathWithoutExtension = name.replace('.', ‘/‘); //3 File sourceFile = new File(sourceFilePathWithoutExtension + “.java”); //4 sourceFile.getParentFile().mkdirs(); //5 Files.writeString(sourceFile.toPath(), code); //6 Iterable<? extends JavaFileObject> compilationUnit = fileManager.getJavaFileObjects(sourceFile); //7 compiler.getTask(null, fileManager, null, null, null, compilationUnit).call(); //8 URLClassLoader classLoader = URLClassLoader.newInstance( new URL[]{new URL("file:"+System.getProperty("user.dir") + ‘/‘)}); //9 Class<?> myClass = classLoader.loadClass(name); //10
در اینجا ما فرض کردیم که دو رشته code و name که به ترتیب متن کلاس و نام کلاس مدنظر برای کامپایل هستند به ما داده شده و ما باید رشته code را کامپایل کنیم. در اینجا ما تصمیم گرفتیم که code را بر روی یک فایل در حافظه جانبی ریخته و سپس آن فایل را کامپایل کنیم.
توضیح خط به خط:
- ابتدا کامپایلر را دریافت نموده و رفرنس به آن را در یک JavaCompiler ذخیره میکنیم.
- سپس مدیر فایل جاوا (FileManager) را گرفته و نگه میداریم.
- ممکن است کاربر نام کامل کلاس مد نظر را وارد کند. مثلا com.parsa.HelloWorld. برای همین ما که میخواهیم آن را در یک فایل بریزیم باید آن را به شکل مسیر در آوریم.
- فایلی جدید تعریف کرده که به فایلِ سورس مد نظر که در آینده با فرمت java ساخته خواهد شد اشاره دارد.
- پوشههای بالای فایل مورد نظر را ایجاد میکنیم.
- رشته code را درون فایل در مسیر مورد نظر مینویسیم.
- StandardJavaFileManager متدی دارد که فایلهای ورودی به آن از نوع Variable Arguments را به یک شی Iterable که حاوی اشیایی است که فرزند JavaFileObject هستند خروجی میدهد. این متد را فراخوانی کرده و خروجی را در یک Iterable <? extends JavaFileObject> میریزیم.
- این خط در اصل میتوانست دو خط باشد ولی در یک خط نوشته شده. ابتدا وظیفه (Task) و یا دقیقتر یک CompilationTask با استفاده از متد getTask به دست آورده شده و آن وظیفه توسط call صدا زده میشود تا فرایند compile انجام شود.
- URLClassLoader کلاسی است فرزند SecureClassLoader و ClassLoader که با آن میتوان یک کلاس را از یک URL فراخوانی کرد. شیای از این کلاس (که در پوشه فعلی به دنبال فایل class. کامپایلشده میگردد) را ایجاد میکنیم.
- classLoader را فراخوانی کرده تا کلاس لود شود.
- تمام
SimpleJavaFileObject چیست؟
SimpleJavaFileObject کلاسی است که اکثر متدهای JavaFileObject را پیادهسازی کرده است. از این کلاس میتوان برای ورودی دادن به JavaCompiler استفاده کرد.
فرض کنیم میخواستیم فایلی که قرار است کامپایل شود را در جایی از حافظه جانبی ذخیره نکنیم. چرا که مثلا نوشتن اطلاعات بر روی حافظه جانی میتواند زمانبر باشد. در آن صورت بایستی خودمان زیرکلاسی از SimpleJavaFileObject را درست کنیم و Iterableای از آن را به متد getTask بدهیم. بدیهی است زیرکلاس ما از SimpleJavaFileObject باید حاوی String مد نظر باشد. اما این کلاس چگونه قرار است این String را به کامپایلر بدهد؟ در اصل ما باید در نهایت به یک فرم این اطلاعات را به کامپایلر بدهیم. جاوا کامپایلر برای گرفتن این اطلاعات متد getCharContent را فراخوانی میکند که بایستی یک CharSequence خروجی دهد تا با آن به کارهای خود بپردازد.
حال بیایید خودمان را جای کامپایلر بگذاریم و فرض کنیم که اطلاعات را گرفتیم و کامپایل هم کردیم. حال آنها را چگونه تحویل دهیم؟ پاسخ موجود در JDK به این صورت است که کامپایلر متد getJavaFileForOutput از شیای که به عنوان فایل به آن پاس دادیم را صدا میزند که وظیفه آن پاس دادن JavaFileObjectای است که قرار است کامپایلر خروجی را در آن بریزد. برای این کار بایستی JavaFileManager خودمان را تعریف کنیم که با توجه به حجم زیاد متدهایی که باید implement شوند، کاری طولانی و طاقتفرساست و همیشه نیازی به بازتعریف برخی رفتارهای رایج در آن نیست. پس ForwardingJavaFileManager در جاوا به ما کمک میکند تا تنها به آنچه نیاز هست بپردازیم. پس زیرکلاسی از آن برای کار خود ساخته و getJavaFileForOutput خاص خود را در آن پیاده میکنیم. اما باز هم همه چیز تمام و کمال نیست و کماکان این سوال باقی است که چگونه قرار است کامپایلر اطلاعات مورد بحث را در JavaFileObject برای خروجی بریزد. پاسخ همچون دو پاسخ قبلی با پیادهسازی متدی از پیش تعریفشده است که این بار openOutputStream نام دارد و یک ByteArrayOutputStream را به کامپایلر میدهد تا خروجی را به شکل جویباری از byteها در آن بریزد.
حال چگونه ByteArrayOutputStreamهای موجود در JavaFileObjectهای خودمان را به عنوان کلاس در حافظه لود کنیم؟ کافی است به متد defineClass در کلاس انتزاعی ClassLoader دسترسی پیدا کنیم. این متد protected است. پس برای دستیابی به آن بایستی زیرکلاسی از آن داشته باشیم. همچنین این تابع برای ورودی اصلی کلاس کامپایلشده یک آرایه از بایتها به عنوان ورودی میگیرد. از طرفی برای ساخت یک زیرکلاس از ClassLoader بایستی findClass را پیادهسازی کنیم که وظیفه پیدا کردن کلاس و خروجی دادن آن را دارد.
پیادهسازی تمام اینها را میتوانید در لینک موجود در انتهای این مطلب ببینید (در فایل CompileToCustomJavaFile).
Annotation Processor چیست؟
وارد شدن به جزئیات Annotation Processor خود مطلب مفصلی است و ذکر آن در این مقاله ممکن است حوصله خواننده را سر بیاورد. اما گریز و نیمنگاهی به آن در اینجا میتواند مفید باشد. Annotationها به نوعی نحوه مکالمه ما با کامپایلر هستند و نحوه کامپایل شدن کد ما را نشان میدهند.
Annotation Processing شامل تعدادی دور میباشد که در هر دور ممکن است فایلهایی درست شود که به دورهای بعدی داده شود تا اینکه دیگر فایلی درست شود و دوری باقی نماند. این کار در برخی مواقع برای رعایت اصل عدم تکرار در برنامهنویسی ممکن است کاربرد داشته باشد.
Annotation Processing توسط Annotation Processorها انجام میشود. واسط نمایانگر Annotation Processorها Processor هست که شبیه واسطهای دیگری که دیدیم، متدهای زیادی دارد و پیادهسازی همهشان نیاز نیست. پس کلاس AbstractProcessor برایمان پیادهسازی شده است. کامپایلر برای انجام Annotation Processing متد process از Processor با پاس دادن RoundEnvironment به عنوان نمایانگر المانهای موجود در این دور و شیای دیگر از مجموعهای از Annotationها برای process کردن صدا میزند.
برای تمرینی خوب، میتوان تعداد متدهای موجود در کد را با Annotation Processing به دست آورد (نمونه در کد انتهای مطلب در فایل CompileAndCountMethods.java)
DiagnosticListener چیست؟
DiagnosticListener کلاسی برای جمعآوری اطلاعات تشخیصی (Diagnostice) است. با پاس دادن شیای از آن به کامپایلر میتوان اقدام به حل مشکلات کلی که قرار است کامپایل شود (با پیادهسازی متدهای این واسط) نمود.
برای مطالعه کدهای مربوط به این مطلب وارد این لینک شوید.
منبع مطالعات: داکیومنتهای وبسایت اوراکل
.
.
.
با ما همراه باشید
آدرس کانال تلگرام: JavaCupIR@
آدرس اکانت توییتر: JavaCupIR@
آدرس صفحه اینستاگرام: javacup.ir
آدرس گروه لینکدین: Iranian Java Developers
ممنون