دانستنی‌ها

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

این مطلب را آقای پارسا نوری تهیه کرده‌ و برای انجمن جاواکاپ ارسال کرده‌اند.

برای پروژه‌ی درسِ برنامه‌نویسی پیشرفته داشتم روی مدل پروژهام فکر میکردم که به فکرم رسید چه جالب میشد اگه به جای اینکه من یک فرمول برای محاسبه قسمت را به صورت hard code وارد کنم، کاربر بتواند در زمان اجرا این فرمول را تعیین کند. راهی که برای پیاده‌سازی این موضوع به ذهنم رسید، ورودی گرفتن یک تکه کد از کاربر و ساخت کلاسی از روی آن  و کامپایل کردنِ کلاس در زمان اجرا بود. من بعد از اتمام پروژه تصمیم گرفتم بیشتر در مورد Runtime Compilation  درجاوا مطالعه کنم و اطلاعات کسب شده را با دوست‌دارانِ جاوا به اشتراک بگذارم.

ما میخواهیم JavaCompiler که واسطی در پکیج javax.tools هست را بررسی کنیم. این واسط برای این طراحی شده تا ما بتوانیم از کامپایلرِ سیستم به عنوان یک شی استفاده کنیم. با استفاده از متدهایی روی این شی می‌توانیم اعمالی همچون کامپایل کردن یک فایل را در زمان اجرا انجام دهیم.

ابتدا با استفاده از کلاس ToolProvider، کامپایلر موجود روی سیستم را به دست می‌آوریم.

 

فرایند کامپایل

کامپایلر برای کامپایل به واحدهایی (Compilation Unit) برای کامپایل کردن نیاز دارد. سپس این واحد ها را کامپایل کرده و با استفاده از FileManager در محلی ذخیره میکند. شاید ما نیاز داشته باشیم برای Compile کدمان یک سری پارامتر هم ورودی بدهیم. کامپایلر برای تعریف یک وظیفه‌ی کامپایل (Compilation Task) باید بتواند آپشن‌ها را نیز بگیرد. در نهایت، کدی که برای کامپایلر ارسال می‌کنیم، ممکن است کامپایل ارور داشته باشد، برای همین Compilation Task باید بداند این خطا‌ها را  به کجا روانه کند.

جاوا برای کارهایی از قبیل کامپایل کردن در زمان اجرا، واسطی را برای نمایش فایل به نام FileObject در نظر گرفته. FileObject تنها نمایانگر فایل نیست بلکه در اینجا منظور از فایل هرگونه منبعی از داده است. حال این منبع میتواند یک مقدار Cache در RAM  سیستم باشد یا داده‌ای در Database یا یک فایل معمولی در دیسک. واسط FileObject یک زیرواسط به نام JavaFileObject دارد که به طور خاص نمایانگر سورس‌کدِ کلاس‌ها و خود فایل کلاس‌ها میباشد. ما برای کامپایل کردن در زمان اجرا بایستی شیئی از جنس Iterable که روی فرزندان واسط JavaFileObject   پیمایش می‌کند را به کامپایلر مورد بحث تحویل دهیم.

با توجه به این توضیحات شاید امضای متد جاوا برای گرفتن یک Compilation Task از کامپایلر که به صورت زیر است تا حدودی برایتان ملموس تر شود:

 

در این متد out یک Writer است که برای خروجی‌های اضافیِ کامپایلر کاربرد دارد. FileManager مدیر فایلیست که وظیفه گرفتن خروجی کامپایل را دارد و DiagnosticListener به ایراد های کد که در زمان کامپایل مشخص می‌شوند مثل ارور‌های سینتکسی مربوط است. Options ویژگی‌های کامپایل شدن کد ماست و compilationUnits واحد هایی است که قرار است کامپایل شوند. خروجی نیز کلاسی در کلاس JavaCompiler به نام CompilationTask است.

 

 StandardJavaFileManager چیست ؟

StandardJavaFileManager واسط مدیر فایلی بر اساس java.io.File است که می‌توان شیئی از آن را از JavaCompiler با استفاده ازمتد getStandardFileManager گرفت و استفاده کرد. StandardJavaFileManager نیز مثل سایر کلاسها و واسطهایی که با IO کار دارند زیرواسط Closeable است و بعد از این که کارمان با آن تمام شد باید آن را ببندیم. (Close کنیم).

در برنامه، بعد از اینکه فرایند کامپایل تمام میشود برای استفاده از کلاس های کامپایل شده بایستی کلاس های کامپایل شده در حافظه لود شوند. این کار با استفاده از ClassLoader اتفاق میافتد.

با توجه به توضیحات بالا شاید بد نباشد نگاهی به تکه کد زیر بیندازیم و کارکرد آن را توضیح دهیم.

 

در اینجا ما فرض کردیم که دو رشته code و name که به ترتیب متن کلاس و نام کلاس مد نظر برای کامپایل هستند به ما داده شده و ما باید رشته code را کامپایل کنیم. در اینجا ما تصمیم گرفتیم که code را بر روی یک فایل در حافظه جانبی ریخته و سپس آن فایل را کامپایل کنیم.

توضیح خط به خط :

۱. ابتدا کامپایلر جاوا را دریافت نموده و رفرنس به آن را در یک JavaCompiler ذخیره می‌کنیم.

۲. سپس مدیر فایل جاوا را گرفته و نگه می‌داریم.

۳. ممکن است کاربر نام کامل کلاس مد نظر را وارد کند مثلا com.parsa.HelloWorld برای همین ما که میخواهیم آن را در یک فایل بریزیم باید آن را به شکلِ مسیر درآوریم.

۴. فایلی جدید تعریف کرده که به فایلِ سورسِ مدِ نظر که در آینده با فرمت java ساخته خواهدشد اشاره دارد.

۵. پوشه های بالای فایل مورد نظر را ایجاد می‌کنیم.

۶. رشته code را درون فایل در مسیر مورد نظر می‌نویسیم.

۷. StandardJavaFileManager متدی دارد که فایل های ورودی به آن از نوع Variable Arguments را به یک شیئ Iterable که حاوی اشیائی است که فرزند JavaFileObject هستند خروجی می‌دهد. این متد را فراخوانی کرده و خروجی را در یک Iterable<? extend  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 خود مطلب مفصلی است و ذکر آن در اینجا ممکن است حوصله خواننده را سر بیاورد اما گریز و نیم‌نگاهی به آن در اینجا میتواند مفید باشد. Annotaion ها به نوعی نحوه مکالمه ما با کامپایلر هستند و نحوه کامپایل شدن کد ما را نشان می‌دهند.

Annotation Processing شامل تعدادی دور میباشد که در هر دور ممکن است فایل هایی درست شود و به دور های بعدی داده شود تا اینکه دیگر فایلی درست نشود و دوری باقی نماند. این کار در برخی مواقع برای رعایت اصل عدم تکرار خود در برنامه نویسی ممکن است کاربرد داشته باشد.

Annotation Processing توسط Annotation Processor ها انجام میشود. واسط نمایانگر Annotation Proecessor ها Processor هست که شبیه واسط‌های دیگری که دیدیم، متد های زیادی دارد و پیاده سازی همه شان نیاز نیست. پس کلاس AbstractProcessor برایمان پیاده‌سازی شده است. کامپایلر برای انجام Annotation Processing متد process را از Processor با پاس دادن RoundEnvironment به عنوان نمایانگر المان های موجود در این دور و شیئی دیگر از مجموعه ای از Annotation ها برای Process کردن صدا می‌زند.

برای تمرینی خوب، می‌توان تعداد متدهای موجود در کد را با Annotation Processing به دست آورد. (نمونه در کد انتهای مطلب در فایل CompileAndCountMethods,java)

 

DiagnosticListener چیست ؟

Diagnostic Listener کلاسی برای جمع آوری اطلاعات تشخیصی (Diagnostic) است. با پاس دادن شیئی از آن به کامپایلر می‌توان اقدام به حل مشکلات کدی که قرار است کامپایل شود (با پیاده‌سازی متدهای این وسط) نمود.

برای مطالعه کد های مربوط به این مطلب وارد این لینک شوید.

 

منبع مطالعات: داکیومنت‌های وبسایت اوراکل

.

.

.

.

با ما همراه باشید


آدرس کانال تلگرام: JavaCupIR@

آدرس اکانت توییتر: JavaCupIR@

آدرس صفحه اینستاگرام: javacup.ir

آدرس گروه لینکدین: Iranian Java Developers

 

[تعداد: 0   میانگین:  0/5]
برچسب ها
نمایش بیشتر

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

پاسخی بگذارید

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

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