حسام درویشیان
حسام درویشیان
خواندن ۹ دقیقه·۲ سال پیش

چطوری با KSP کمتر کد بنویسیم

توی این مقاله می‌خواییم در قالب حل یک مسئله ببینیم چقدر راحت می‌تونیم با استفاده از KSP، کدهای تکراری رو به صورت خودکار تولید کنیم و یکسری از چالش‌ها و راه‌کارها رو با هم ببرسی کنیم.


قبل از شروع:

- یک: annotation ها یکسری متادیتا هستن که هیچ تاثیر بر روی فرایند اجرای کد (runtime) ندارن.
- دو: annotation processing یک فرایند کامپایل تایم هستش و تاثیری بر سرعت اجرای برنامه نداره! ولی باعث افزایش زمان کامپایل می‌شه.
- سه: ما این رو روی اندروید می‌نویسیم ولی به صورت کلی اگه با کاتلین برنامه می‌نویسین، پلتفرم فرقی نداره.

فرض کنید data class زیر رو داریم.

https://gist.github.com/Darvishiyan/a87240ca6d9e322000a3bb12ddbae8d0

می‌خواییم به صورت خودکار بر اساس الگوی builder، یک کلاس builder مثل کلاس زیر برای کلاسمون ایجاد کنیم.

https://gist.github.com/Darvishiyan/d330d73c7ec11ec9ed1664de5fa3d61f

اگه به کد دقت کنید خطای زمان کامپایل داره، برای اینکه پراپرتی های data classمون غیرقابل تغییر هستن. برای رفع این مشکل ما باید به صورت خودکار یک کلاس با اشیای تغییر پذیر ایجاد ‌کنیم. مثل کلاس زیر:

https://gist.github.com/Darvishiyan/92ca6d98be5cd0af68f25cc55dfff914

و انتظار داریم که کد کلاس builderمون هم به کد زیر تغییر کنه.

https://gist.github.com/Darvishiyan/0e8e0d5ac5303278a30e33fb539f7301
ما توی کاتلین راهکار هایی داریم که نیازی به پیاده‌سازی الگوی builder نداریم. هدف این مثال، آموزش راحتتر KSP و ارائه راهکاری هایی برای بعضی شرایط خاص هستش.

پیاده‌سازی:

برای این کار سه تا ماژول لازم داریم.

  • ماژول annotations: برای تعریف annotationهامون هستش
  • ماژول processor: کدهای ما رو تولید می‌کنه.
  • ماژول app: برای استفاده و تست annotationمون

ماژول annotations:

برای اینکار باید دوتا annotation class زیر رو بسازیم.

https://gist.github.com/Darvishiyan/2e3f8e82a0075701719b9b7841de0f42
ساختار ماژول annotations
ساختار ماژول annotations

کلاس AutoBuilder برای این هستش که data class هایی مورد نظر رو مشخص کنیم. و کلاس BuilderProperty برای مشخص کردن پراپرتی هایی که می‌خواییم متد جدا گانه داشته باشند. و پارامتر flexible هم مشخص می‌کنه که آیا ما می‌خواییم کلاسی با اشیای تغییر پذیر ایجاد کنیم یا نه.

بعد از نوشتن این annotation ها، کلاس Person به شکل زیر تغییر می‌کنه.

https://gist.github.com/Darvishiyan/93364cb9f4ff20468649413d68f5d41a

ماژول 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 &quotcom.google.devtools.ksp:symbol-processing-api:1.7.0-1.0.6&quot

پروژه رو sync و build می‌کنیم و یک کلاس درست می‌کنیم و اینترفیس SymbolProcessorProvider رو پیاده‌سازی می‌کنیم.

https://gist.github.com/Darvishiyan/18e15073e00d9f430243ecc91f2ed46c

حالا برای معرفی Providerمون باید آدرس زیر رو بسازیم.

processor/src/main/resources/META-INF/services

یک فایل با نام زیر بسازیم.

com.google.devtools.ksp.processing.SymbolProcessorProvider

و توی اون اسم Providerمون رو به همراه package nameش وارد می‌کنیم.

your.domain.processor.AutoBuilderProcessorProvider

همونجوری که دیدید ما توی کلاس AutoBuilderProcessorProvider به یک شی از نوع SymbolProcessor نیاز داشتیم، برای این کار یک کلاس می‌سازیم و اینترفیس SymbolProcessor رو پیاده‌سازی می‌کنیم.

https://gist.github.com/Darvishiyan/f91eb4e54c1d7cf76fc64246398a0cc4

همچنین TODO کلاس AutoBuilderProcessorProvider رو هم پیاده‌سازی می‌کنیم.

https://gist.github.com/Darvishiyan/1efa65bc389437bde06228ec9a747db3

در این مرحله باید متد processمون رو پیاده‌سازی کنیم.

در ابتدا باید اشیایی که انوتیشن ما رو دارن رو پیدا کنیم.

val symbols: Sequence<KSClassDeclaration> = resolver .getSymbolsWithAnnotation(AutoBuilder::class.java.name) .filterIsInstance<KSClassDeclaration>()

حالا باید بررسی کینم که آیا شی ای انوتیشن ما رو داره یا نه، به خاطر اینکه که نوع شی ما Sequence هستش ما با استفاده از متد hasNext توی اینترفیس Iterator این کار رو انجام می‌دیم.

if (symbols.iterator().hasNext().not()) return emptyList()

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

https://gist.github.com/Darvishiyan/d71834877ca617f8fafc387d74f56622
به عنوان یک توضیح کوچیک متد getAnnotation بر اساس نام، انوتیشن ما رو برمیگردونه و متد getParameterValue مقدار پارامتر مورد نظر ما رو بر می‌گردونه، متد containsIgnoreCase بررسی می‌کنه که شی ای که انوتیشن ما رو داره یک Modifier خاص رو داره یا نه و متد hasAnnotation بررسی می‌کنه که یک شی انوتیشن ما رو داره یا نه. فقط توجه کنید که باید نام پارامتر رو به صورت رشته ارسال کنیم.

برای پردازش اشیایی که انوتیشن ما رو دارن فقط کافیه با یک forEach ساده، شی ای که بالاتر درست کرده بودیم رو پیمایش کنیم.

symbols.forEach { symbol -> // … }

در ابتدا باید بررسی کنیم که آیا شی ما data class هستش یا نه.

if (symbol.modifiers.containsIgnoreCase(&quotdata&quot).not()) { logger.error(&quotYou should write this function on a data class&quot, symbol) return emptyList() }

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

val flexible = symbol.annotations.getAnnotation(AutoBuilder::class.java.simpleName) .arguments.getParameterValue<Boolean>(&quotflexible&quot)

کد بالا یک اشکال داره و اون استفاده از رشته هستش، این کار احتمال اشتباه رو بالا می‌بره. برای این ما باید کد AutoBuilder رو به کد زیر تغییر بدیم.

https://gist.github.com/Darvishiyan/242936f9da2f5e27dc4979d0e61c9e0d

و کدی که مشکل داشت رو هم به کد زیر تغییر می‌دیم.

val flexible = symbol.annotations.getAnnotation(AutoBuilder::class.java.simpleName) .arguments.getParameterValue<Boolean>(AutoBuilder.flexible)
حتما یادتون باشه لیست انوتیشن‌هایی که پردازش کردین رو return کنید تا دوباره پردازش نشن.

خوب در این مرحله باید اشیایی که انوتیشن مورد نظر ما رو دارن رو پردازش کنیم برای این کار لازمه که اینترفیس KSVisitor رو پیاده‌سازی کنیم، این اینترفیس دوتا ورودی رو به صورت جنریک می‌گیره.

  • ورود اول D: شی‌ای هست که به صورت ورودی ارسال می‌شه یا در مراحل پراسس ساخته ‌می‌شه.
  • ورودی دوم R: شی‌ای هستش انتظار داریم که کلاسمون برای ما برگردونه.

این اینترفیس یک سری پیاده سازی‌ها داره، چون برای مثال ما شی ورودی و خروجی Unit هستش از KSVisitorVoid استفاده می‌کنیم.

در رابطه با این شی هم بگم که کارش به این صورت هستش که متد متناظر با شی شما رو صدا میزنه یعنی که انوتیشن رو روی class نوشته باشیم متد visitClassDeclaration رو صدا می‌زنه و اگه اون رو روی property نوشته ب اشیم متد visitPropertyDeclaration رو صدا می‌زنه.
در اینجا ما فقط Visitor کلاس builder رو پیاده‌سازی می‌کنیم، Visitor کلاس تغییر پذیرمون رو توی سورس کد میزارم، اگه هر سوالی داشتید بپرسید خوشحال میشم اگه بلد باشم جواب بدم یا متن رو بروز می‌کنم.

یک کلاس به اسم AutoBuilderVisitor می‌سازیم و کلاس KSVisitorVoid رو توسعه می‌دیم، طبق توضیحی که دادم، ما باید متد visitClassDeclaration رو بازنویسی کنیم.

https://gist.github.com/Darvishiyan/2b8dd49d955f3bd155fbd27063b79736

حالا باید فایلمون رو بسازیم.

val file: OutputStream = codeGenerator.createNewFile( dependencies = Dependencies(false), packageName = [Package Name], fileName = [File Name] )

برای جلوگیری از دوباره پردازش شدن و دوباره ساخته شدن فایل‌هایی که لازم نیستن از تکنیک ‍Incremental processing استفاده می‌کنیم که توی این مقاله قصد نداریم بهش بپردازیم. فقط همین قدر می‌گم که هدف پارامتر dependencies همین هستش. برای اطلاعات بیشتر می‌تونید به لینک زیر سر بزنید.

https://kotlinlang.org/docs/ksp-incremental.html

نوبت به نوشتن توی فایلمون می‌رسه. برای راحت تر شدن و خواناتر شدن یک operator function می‌نویسیم.

https://gist.github.com/Darvishiyan/7326ead9f2fedaeba85f9600222d4956
قبل از اینکه ادامه بدیم باید بگم که حتما یادتون باشه که در انتهای کار OutputStream رو close کنید.
https://gist.github.com/Darvishiyan/8d9157db2ed5a43f9ad089a1e2d3cb52

همون طور که می‌بینید نوشتن توی فایلمون بسیار راحت هستش و ما هر چیزی که می‌خواییم در انتها توی فایلمون باشه رو لازمه اینجا بنویسیم.

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

۱- برای تولید کد متد سازنده:

class PersonBuilder(name: kotlin.String)
https://gist.github.com/Darvishiyan/69ca8d32df5e74d95664f14812dfd9a9

۲- برای تولید کد شی‌ای که قرار هستش داده‌ها رو نگه داره:

private val mutablePerson: MutablePerson = MutablePerson( name = name, age = null, email = null, contact = null, )
https://gist.github.com/Darvishiyan/6571f008da80259305c7c248dcb8a168

۳- برای تولید کد متدهای الگوی builder:

fun age(age: kotlin.Int): PersonBuilder { mutablePerson.age = age return this }
https://gist.github.com/Darvishiyan/57c23c6b1d405c4bec276cbe40dbc147

توجه داشته باشید که بررسی تمام typeها و import کردن اونها بالای کلاسمون کار خیلی دردسر داری هستش. چون ما فایل رو داریم خط به خط می‌نویسم. برای حل این مشکل بجای نام هر type، نامش رو همراه با پکیجش ‌می‌نویسیم. یعنی بجای اینکه از پراپرتی declaration شی KSType پراپرتی simpleName رو بگیریم، پراپرتی qualifiedName رو می‌گیریم.

این کار باعث میشه که ما بجای

List<Boolean>

بنویسیم

kotlin.collections.List<kotlin.Boolean>

که فرقی نداره، فقط مشکل import کردن رو حل کردیم.

نکته بعدی که باید توجه داشته باشید این هستش که پیدا کردن type های جنریک به این راحتی نیست و ما باید به صورت بازگشتی typeها رو بررسی کنیم و مقادیر جنریک اونها رو پیدا کنیم.

https://gist.github.com/Darvishiyan/dc27777726ea26ab2380fb1b8cef892d
https://gist.github.com/Darvishiyan/2d3021a8483fdc42536b47ec324945d2

همونجوری که می‌بینید در خط ۱۵ متد visitTypeArgument، متد visitTypeArguments رو صدا می‌کنیم و در خط ۶ این متد، متد visitTypeArgument رو صدا می‌کنیم و این روند اینقدر ادامه داره تا تام typeهای جنریک رو پردازش کنیم.

در انتها فایل زیر به صورت خودکار تولید می‌شه.

https://gist.github.com/Darvishiyan/8fb550dccd85936c5415d5676716fdf1
ساختار ماژول processor
ساختار ماژول processor

حالا نوبت به پیاده‌سازی ماژول app میرسه:

برای اینکه از این ماژول ها توی برنامتون استفاده کنید باید پلاگین زیر رو به ماژول اصلی برنامه اضافه کنیم.

id 'com.google.devtools.ksp'

و همینطور باید وابستگی های زیر رو هم به ماژول اصلی اضافه کنیم.

implementation project(path: ':annotations') ksp project(path: ':processor')

خوب حالا پروژه رو sync و build کنید.

یک کلاس شبیه کلاس زیر می‌سازیم.

https://gist.github.com/Darvishiyan/93364cb9f4ff20468649413d68f5d41a
ساختار کل پروژه
ساختار کل پروژه

پروژه رو یکبار دیگه 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(&quot$buildDir/generated/ksp/$name/kotlin&quot) } } }

در library module:

libraryVariants.all { variant -> kotlin.sourceSets { def name = variant.name getByName(name) { kotlin.srcDir(&quot$buildDir/generated/ksp/$name/kotlin&quot) } } }

می‌خواییم کانفیگ داشته باشیم.

برای این کار باید کافیگ‌هامون رو توی بلاک اندروید ماژول اصلی بنویسیم و از با شی option بهشون دسترسی داریم.

android { ... ksp { arg(&quotmyConfig1&quot, &quottrue&quot) arg(&quotmyConfig2&quot, &quotmyText&quot) arg(&quotmyConfig3&quot, &quot1&quot) } }

من کد زیر رو برای استفاده از کانفیگ استفاده می‌کنم.

https://gist.github.com/Darvishiyan/4df1b97755e5372e6824ddce6e52f1e5

می‌خوام پارامتر رو پردازش کنم.

وقتی می‌خواییم یک پرامتر رو پردازش کنیم تابع visitValueParameter از اینترفیس KSVisitor ما صدا زده می‌شه. در ورودی یکی شی داریم از نوع KSValueParameter.

-- پراپرتی isVararg به ما میگه که این پرامتر از نوع vararg هستش یا نه
-- پراپرتی hasDefault بهمون می‌گه که مقدار پیشفرض داره یا نه ولی مقدار رو نمی‌تونیم از جایی بدست بیاریم یا من پیدا نکردم ??‍♂️
-- متد resolve در پراپرتی type اطلاعت خوبی می‌ده، مقدار بازگشتی این متد از نوع KSType هستش که میشه متوجه بشیم که
------ اون پارامتر nullable هستش یا نه
------ با استفاده از پراپرتی qualifiedName توی پراپرتی declaration می‌تونیم نوع پارامتر رو بدست بیاریم

توجه کنید که متد resolve بسیار پر هزینه هستش و سعی کنید کمتر ازش استفاده کنید.

دوتا از چالش‌ها هم پردازش type های جنریک و import کردن type ها بود که توی مثال توضیح دادم.

محدودیت ها:

من تا اینجا با محدودیتی بجز اینکه به مقدار پیشفرض پارامترها دسترسی نداشتم روبرو نشدم. برای حل این مشکل هم می‌تونید یک انوتیشن دیگه تعریف کنید و مقدار پیشفرض رو با انوتیشن برای هر پارامتر مشخص کنید.

https://github.com/google/ksp/issues/268

نمونه:

توی ریپوهای زیر می‌تونید به سورس کد دسترسی داشته باشید، یه چندتا ریپوی دیگه هم اگه شد اضافه می‌کنم تا دید بهتری بهتون بده.

استار بزنید که ببینم چند نفر به این پروژه سر میزنن ?
https://github.com/Darvishiyan/KSP-Builder-Sample
https://github.com/Darvishiyan/KSP-DiffCallback-Sample
اگه سوالی داشتید که چالشی بود بپرسید اگه بلد بودم، این مقاله رو بروز می‌کنم و جوابش رو می‌دم یا اگه با محدودتی رو برو شدید اون رو هم بگید اینجا بنویسم که قبل از شروع دیگران بدونن چه کار هایی می‌تونن بکنن و چه کار هایی نمی‌تونن.

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

https://kotlinlang.org/docs/ksp-overview.html
https://github.com/google/ksp
https://proandroiddev.com/so-how-do-i-write-a-kotlin-symbol-processor-ksp-b9606e9e3818
kotlinkspandroidannotation processing
Android Engineer at Adevinta
شاید از این پست‌ها خوشتان بیاید