توی این مقاله میخواییم در قالب حل یک مسئله ببینیم چقدر راحت میتونیم با استفاده از KSP، کدهای تکراری رو به صورت خودکار تولید کنیم و یکسری از چالشها و راهکارها رو با هم ببرسی کنیم.
- یک: annotation ها یکسری متادیتا هستن که هیچ تاثیر بر روی فرایند اجرای کد (runtime) ندارن.
- دو: annotation processing یک فرایند کامپایل تایم هستش و تاثیری بر سرعت اجرای برنامه نداره! ولی باعث افزایش زمان کامپایل میشه.
- سه: ما این رو روی اندروید مینویسیم ولی به صورت کلی اگه با کاتلین برنامه مینویسین، پلتفرم فرقی نداره.
فرض کنید data class زیر رو داریم.
میخواییم به صورت خودکار بر اساس الگوی builder، یک کلاس builder مثل کلاس زیر برای کلاسمون ایجاد کنیم.
اگه به کد دقت کنید خطای زمان کامپایل داره، برای اینکه پراپرتی های data classمون غیرقابل تغییر هستن. برای رفع این مشکل ما باید به صورت خودکار یک کلاس با اشیای تغییر پذیر ایجاد کنیم. مثل کلاس زیر:
و انتظار داریم که کد کلاس builderمون هم به کد زیر تغییر کنه.
ما توی کاتلین راهکار هایی داریم که نیازی به پیادهسازی الگوی builder نداریم. هدف این مثال، آموزش راحتتر KSP و ارائه راهکاری هایی برای بعضی شرایط خاص هستش.
برای این کار سه تا ماژول لازم داریم.
ماژول annotations:
برای اینکار باید دوتا annotation class زیر رو بسازیم.
کلاس AutoBuilder برای این هستش که data class هایی مورد نظر رو مشخص کنیم. و کلاس BuilderProperty برای مشخص کردن پراپرتی هایی که میخواییم متد جدا گانه داشته باشند. و پارامتر flexible هم مشخص میکنه که آیا ما میخواییم کلاسی با اشیای تغییر پذیر ایجاد کنیم یا نه.
بعد از نوشتن این annotation ها، کلاس Person به شکل زیر تغییر میکنه.
ماژول processor:
اول باید classpath زیر رو به پروژتون اضافه کنید.
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.0'
بعد باید پلاگین زیر رو به فایل gradle اصلی پروژه اضافه کنید.
id 'com.google.devtools.ksp' version '1.7.0-1.0.6' apply false
و بعد باید depenencyهای زیر رو به فایل gradle ماژول processor اضافه کنیم.
implementation project(path: ':annotations') implementation "com.google.devtools.ksp:symbol-processing-api:1.7.0-1.0.6"
پروژه رو sync و build میکنیم و یک کلاس درست میکنیم و اینترفیس SymbolProcessorProvider رو پیادهسازی میکنیم.
حالا برای معرفی Providerمون باید آدرس زیر رو بسازیم.
processor/src/main/resources/META-INF/services
یک فایل با نام زیر بسازیم.
com.google.devtools.ksp.processing.SymbolProcessorProvider
و توی اون اسم Providerمون رو به همراه package nameش وارد میکنیم.
your.domain.processor.AutoBuilderProcessorProvider
همونجوری که دیدید ما توی کلاس AutoBuilderProcessorProvider به یک شی از نوع SymbolProcessor نیاز داشتیم، برای این کار یک کلاس میسازیم و اینترفیس SymbolProcessor رو پیادهسازی میکنیم.
همچنین TODO کلاس AutoBuilderProcessorProvider رو هم پیادهسازی میکنیم.
در این مرحله باید متد processمون رو پیادهسازی کنیم.
در ابتدا باید اشیایی که انوتیشن ما رو دارن رو پیدا کنیم.
val symbols: Sequence<KSClassDeclaration> = resolver .getSymbolsWithAnnotation(AutoBuilder::class.java.name) .filterIsInstance<KSClassDeclaration>()
حالا باید بررسی کینم که آیا شی ای انوتیشن ما رو داره یا نه، به خاطر اینکه که نوع شی ما Sequence هستش ما با استفاده از متد hasNext توی اینترفیس Iterator این کار رو انجام میدیم.
if (symbols.iterator().hasNext().not()) return emptyList()
حالا باید شروع به پردازش اشیایی بکنیم که انوتیشن ما رو دارن. اما قبل از اون من چندتا استنشن فانکشن مفید اینجا میزارم. که جلوتر کمکمون میکنن کد خواناتری داشته باشیم.
به عنوان یک توضیح کوچیک متد getAnnotation بر اساس نام، انوتیشن ما رو برمیگردونه و متد getParameterValue مقدار پارامتر مورد نظر ما رو بر میگردونه، متد containsIgnoreCase بررسی میکنه که شی ای که انوتیشن ما رو داره یک Modifier خاص رو داره یا نه و متد hasAnnotation بررسی میکنه که یک شی انوتیشن ما رو داره یا نه. فقط توجه کنید که باید نام پارامتر رو به صورت رشته ارسال کنیم.
برای پردازش اشیایی که انوتیشن ما رو دارن فقط کافیه با یک forEach ساده، شی ای که بالاتر درست کرده بودیم رو پیمایش کنیم.
symbols.forEach { symbol -> // … }
در ابتدا باید بررسی کنیم که آیا شی ما data class هستش یا نه.
if (symbol.modifiers.containsIgnoreCase("data").not()) { logger.error("You should write this function on a data class", symbol) return emptyList() }
همونجوری که میدونید هر شی میتونه چندتا انوتیشن بگیره، ما برای دسترسی به پارامتری که به انوتیشن فرستادیم باید از کد زیر استفاده کنیم که از دوتا اکستنشن فانکشن بالا استفاده میکنه.
val flexible = symbol.annotations.getAnnotation(AutoBuilder::class.java.simpleName) .arguments.getParameterValue<Boolean>("flexible")
کد بالا یک اشکال داره و اون استفاده از رشته هستش، این کار احتمال اشتباه رو بالا میبره. برای این ما باید کد AutoBuilder رو به کد زیر تغییر بدیم.
و کدی که مشکل داشت رو هم به کد زیر تغییر میدیم.
val flexible = symbol.annotations.getAnnotation(AutoBuilder::class.java.simpleName) .arguments.getParameterValue<Boolean>(AutoBuilder.flexible)
حتما یادتون باشه لیست انوتیشنهایی که پردازش کردین رو return کنید تا دوباره پردازش نشن.
خوب در این مرحله باید اشیایی که انوتیشن مورد نظر ما رو دارن رو پردازش کنیم برای این کار لازمه که اینترفیس KSVisitor رو پیادهسازی کنیم، این اینترفیس دوتا ورودی رو به صورت جنریک میگیره.
این اینترفیس یک سری پیاده سازیها داره، چون برای مثال ما شی ورودی و خروجی Unit هستش از KSVisitorVoid استفاده میکنیم.
در رابطه با این شی هم بگم که کارش به این صورت هستش که متد متناظر با شی شما رو صدا میزنه یعنی که انوتیشن رو روی class نوشته باشیم متد visitClassDeclaration رو صدا میزنه و اگه اون رو روی property نوشته ب اشیم متد visitPropertyDeclaration رو صدا میزنه.
در اینجا ما فقط Visitor کلاس builder رو پیادهسازی میکنیم، Visitor کلاس تغییر پذیرمون رو توی سورس کد میزارم، اگه هر سوالی داشتید بپرسید خوشحال میشم اگه بلد باشم جواب بدم یا متن رو بروز میکنم.
یک کلاس به اسم AutoBuilderVisitor میسازیم و کلاس KSVisitorVoid رو توسعه میدیم، طبق توضیحی که دادم، ما باید متد visitClassDeclaration رو بازنویسی کنیم.
حالا باید فایلمون رو بسازیم.
val file: OutputStream = codeGenerator.createNewFile( dependencies = Dependencies(false), packageName = [Package Name], fileName = [File Name] )
برای جلوگیری از دوباره پردازش شدن و دوباره ساخته شدن فایلهایی که لازم نیستن از تکنیک Incremental processing استفاده میکنیم که توی این مقاله قصد نداریم بهش بپردازیم. فقط همین قدر میگم که هدف پارامتر dependencies همین هستش. برای اطلاعات بیشتر میتونید به لینک زیر سر بزنید.
نوبت به نوشتن توی فایلمون میرسه. برای راحت تر شدن و خواناتر شدن یک operator function مینویسیم.
قبل از اینکه ادامه بدیم باید بگم که حتما یادتون باشه که در انتهای کار OutputStream رو close کنید.
همون طور که میبینید نوشتن توی فایلمون بسیار راحت هستش و ما هر چیزی که میخواییم در انتها توی فایلمون باشه رو لازمه اینجا بنویسیم.
توی کد بالا ما سه مرتبه پراپرتیهامون رو بررسی میکنیم.
۱- برای تولید کد متد سازنده:
class PersonBuilder(name: kotlin.String)
۲- برای تولید کد شیای که قرار هستش دادهها رو نگه داره:
private val mutablePerson: MutablePerson = MutablePerson( name = name, age = null, email = null, contact = null, )
۳- برای تولید کد متدهای الگوی builder:
fun age(age: kotlin.Int): PersonBuilder { mutablePerson.age = age return this }
توجه داشته باشید که بررسی تمام typeها و import کردن اونها بالای کلاسمون کار خیلی دردسر داری هستش. چون ما فایل رو داریم خط به خط مینویسم. برای حل این مشکل بجای نام هر type، نامش رو همراه با پکیجش مینویسیم. یعنی بجای اینکه از پراپرتی declaration شی KSType پراپرتی simpleName رو بگیریم، پراپرتی qualifiedName رو میگیریم.
این کار باعث میشه که ما بجای
List<Boolean>
بنویسیم
kotlin.collections.List<kotlin.Boolean>
که فرقی نداره، فقط مشکل import کردن رو حل کردیم.
نکته بعدی که باید توجه داشته باشید این هستش که پیدا کردن type های جنریک به این راحتی نیست و ما باید به صورت بازگشتی typeها رو بررسی کنیم و مقادیر جنریک اونها رو پیدا کنیم.
همونجوری که میبینید در خط ۱۵ متد visitTypeArgument، متد visitTypeArguments رو صدا میکنیم و در خط ۶ این متد، متد visitTypeArgument رو صدا میکنیم و این روند اینقدر ادامه داره تا تام typeهای جنریک رو پردازش کنیم.
در انتها فایل زیر به صورت خودکار تولید میشه.
حالا نوبت به پیادهسازی ماژول app میرسه:
برای اینکه از این ماژول ها توی برنامتون استفاده کنید باید پلاگین زیر رو به ماژول اصلی برنامه اضافه کنیم.
id 'com.google.devtools.ksp'
و همینطور باید وابستگی های زیر رو هم به ماژول اصلی اضافه کنیم.
implementation project(path: ':annotations') ksp project(path: ':processor')
خوب حالا پروژه رو sync و build کنید.
یک کلاس شبیه کلاس زیر میسازیم.
پروژه رو یکبار دیگه build کنید و به آدرس زیر برید و فایلتون رو ببینید.
~/build/generated/ksp/debug/kotlin/[Package Name]/[File Name].kt
برای این کار باید زبانه Project رو روی حالت Project بگذارید. ?
میخواییم فایل های تولید شده رو توی حالت Android ببینیم، مثل فایل های Hilt.
برای این کار باید کد زیر رو به فایل gradle ماژول که داره از KSP استفاده میکنه اضافه کنیم.
در application module:
applicationVariants.all { variant -> kotlin.sourceSets { def name = variant.name getByName(name) { kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin") } } }
در library module:
libraryVariants.all { variant -> kotlin.sourceSets { def name = variant.name getByName(name) { kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin") } } }
میخواییم کانفیگ داشته باشیم.
برای این کار باید کافیگهامون رو توی بلاک اندروید ماژول اصلی بنویسیم و از با شی option بهشون دسترسی داریم.
android { ... ksp { arg("myConfig1", "true") arg("myConfig2", "myText") arg("myConfig3", "1") } }
من کد زیر رو برای استفاده از کانفیگ استفاده میکنم.
میخوام پارامتر رو پردازش کنم.
وقتی میخواییم یک پرامتر رو پردازش کنیم تابع visitValueParameter از اینترفیس KSVisitor ما صدا زده میشه. در ورودی یکی شی داریم از نوع KSValueParameter.
-- پراپرتی isVararg به ما میگه که این پرامتر از نوع vararg هستش یا نه
-- پراپرتی hasDefault بهمون میگه که مقدار پیشفرض داره یا نه ولی مقدار رو نمیتونیم از جایی بدست بیاریم یا من پیدا نکردم ??♂️
-- متد resolve در پراپرتی type اطلاعت خوبی میده، مقدار بازگشتی این متد از نوع KSType هستش که میشه متوجه بشیم که
------ اون پارامتر nullable هستش یا نه
------ با استفاده از پراپرتی qualifiedName توی پراپرتی declaration میتونیم نوع پارامتر رو بدست بیاریم
توجه کنید که متد resolve بسیار پر هزینه هستش و سعی کنید کمتر ازش استفاده کنید.
دوتا از چالشها هم پردازش type های جنریک و import کردن type ها بود که توی مثال توضیح دادم.
من تا اینجا با محدودیتی بجز اینکه به مقدار پیشفرض پارامترها دسترسی نداشتم روبرو نشدم. برای حل این مشکل هم میتونید یک انوتیشن دیگه تعریف کنید و مقدار پیشفرض رو با انوتیشن برای هر پارامتر مشخص کنید.
توی ریپوهای زیر میتونید به سورس کد دسترسی داشته باشید، یه چندتا ریپوی دیگه هم اگه شد اضافه میکنم تا دید بهتری بهتون بده.
استار بزنید که ببینم چند نفر به این پروژه سر میزنن ?
اگه سوالی داشتید که چالشی بود بپرسید اگه بلد بودم، این مقاله رو بروز میکنم و جوابش رو میدم یا اگه با محدودتی رو برو شدید اون رو هم بگید اینجا بنویسم که قبل از شروع دیگران بدونن چه کار هایی میتونن بکنن و چه کار هایی نمیتونن.
لینکهای مرتبط و منابع: