بین توسعهدهندگان و استفاده کنندگان یک رابط کاربری(API) معمولا تنشهای زیادی وجود دارد. توسعهدهندگان کتابخانهها و بستههای نرمافزاری تلاش میکنند تا آنها را طوری توسعه دهند که در طیف گستردهای از نرمافزارها و چالشها قابل استفاده باشند و بتوانند استفاده کنندگان و کاربران بسیاری را برای محصولات خود جذب کنند.
در سوی دیگر کاربرانی که قصد استفاده از این نرم افزارها را دارند به کدها و بستههایی نیاز دارند که دقیقا بر روی نیاز آنها تمرکز کرده باشد و به موارد دیگر نپردازد. این تنش باعث مشکلات زیادی در اسکوپ سیستم میشود.
به عنوان یک مثال بیایید نگاهی به java.util.Map داشته باشیم. همانطور که در در لیست 8.1 میتوانید ببینید، Map ها تعداد بسیار زیادی رابط دارند که باعث میشوند گستره استفاده وسیعی نیز داشته باشند. مطمئنا این انعطاف و قدرت میتواند بسیار مفید باشد اما همزمان میتواند یک نقص نیز به شمار آید. بگذارید با یک مثال این مسئله را روشن کنیم:
فرض کنید ما یک برنامه داریم که یک Map را میسازد و آن را ارسال میکند. هدف ما این است که هیچ کدام از گیرندگان اجازه و توانایی حذف هیچ عضوی از این Map را نداشته باشد. اما همانطور که در لیست زیر میبینید تابع ()clear این قابلیت را در اختیار گیرندگان قرار میدهد تا بتوانند عضوی را حذف کنند.
یا در مثالی دیگر، ما میخواهیم تنها انواع(Type) خاصی از اشیا بتوانند به Map اضافه شوند، اما هیچ راه مطئمنی برای محدود کردن انواع وجود ندارد و هر کاربری میتواند هر نوعی را به یک Map اضافه کند.
لیست 8.1
- clear() void – Map
- containsKey(Object key) boolean – Map
- containsValue(Object value) boolean – Map
- entrySet() Set – Map
- equals(Object o) boolean – Map
- get(Object key) Object – Map
- getClass() Class<? extends Object> – Object
- hashCode() int – Map
- isEmpty() boolean – Map
- keySet() Set – Map
- notify() void – Object
- notifyAll() void – Object
- put(Object key, Object value) Object – Map
- putAll(Map t) void – Map
- remove(Object key) Object – Map
- size() int – Map
- toString() String – Object
- values() Collection – Map
- wait() void – Object
- wait(long timeout) void – Object
- wait(long timeout, int nanos) void – Object
اگر برنامهی ما به یک Map از سنسورها نیاز داشته باشد، احتمالا شما برای ساختن آن کدی مانند زیر را پیشنهاد میدهید:
Map sensors = new HashMap();
Sensor s = (Sensor)sensors.get(sensorId );
این قطعه کد این کار را انجام میدهد، اما تمیز نیست. همچنین این کد مسئولیتی که برعهده دارد(دریافت یک عضو از Map) را به درستی بیان نمیکند. خوانایی این قطعه کد را میتوان با استفاده از جنریک تا حد بسیار خوبی بهبود بخشید، در کد زیر این مورد را میبینید:
Map<Sensor> sensors = new HashMap<Sensor>();
...
Sensor s = sensors.get(sensorId );
شاید به این فکر کنید که تغییراتی در این ابعاد بعید است اما نسخه 5 جاوا را به یاد آورید که در آن جنریک به جاوا اضافه شد و تغییرات گستردهای را باعث شد. در طی این به روز رسانی سیستمهایی بودند که به دلیل بزرگی نتوانستند از امکانات و مزیتهای جنریک استفاده کنند.
یک راه تمیزتر برای استفاده از جنریک میتواند کد زیر باشد. هیج کاربری که از Sensores استفاده میکند ممکن است اصلا به این که به صورت جنریک نوشته شده باشد اهمیتی ندهد.
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor) sensors.get(id);
}
//snip
}
این سبک نوشتار منجر به پیچیدگی کمتر و درک بهتر میشود، همچنین سو استفاده از آن را نیز سختتر میکند.در کلاس Sensors میتوان منطق تجاری را طراحی و پیاده سازی کرد. درست است که این گونه کد نوشتن مزیتهایی دارد اما ما شما را محدود و مجبور به استفاده همیشگی از چنین کدهایی نمیکنیم.توصیه ما این است که مانند این مثال مرزهای برنامه را همیشه مشخص کنید و آنها را رعایت کنید.اگر در برنامه خود از یک رابط که مرزهای زیادی دارد(مانند Map) استفاده میکنید آن را در کلاس یا خانوادهای از کلاسها که به آن نیاز دارند محصور کنید و از بازگرداندن(return) آن به عنوان خروجی یا پذیرش آن به عنوان یک آرگومان ورودی جدا خود داری کنید.
کدهای Third-party به ما کمک میکنند تا عملکردهای بیشتری را در مدت زمان کمتری در اختیار داشته باشیم. به نظر شما وقتی میخواهیم از پکیجهای خارجی استفاده کنیم باید از کجا شروع کنیم؟ این وظیفهی ما نیست که این کدها را بیازماییم(Testing) اما ممکن است در مواردی نیاز باشد که این کار را انجام دهیم و برای کدهای خارجی هم تست بنویسیم.
فرض کنید که نحوه استفاده از یک کتابخانه برای ما روشن نیست. احتمالا ما مجبور میشویم یک یا دو روز یا حتی بیشتر را صرف خواندن مستندات آن کنیم تا نحوه استفاده آن را یاد بگیریم. سپس به نوشتن کدهای خودمان بپردازیم تا ببینیم آیا عملکردی که فکر میکردیم را از کتابخانه دریافت میکنیم.
این که در این مواقع زمان زیادی را صرف خطایابی کنیم یک چیز کاملا معمول در بین برنامه نویسها است تا متوجه شوند که آیا ایراد از کد آنها است یا از کتابخانهای که استفاده کردهاند. یادگیری کدهای طرف سوم سخت است، یکپارچه سازی آنها سختتر! انجام این دو به صورت همزمان پیچیدگی و سختی را دو چندان میکند. آیا میتوان رویکرد متفاوتی اتخاذ کرد؟ به جای نوشتن و آزمودن کدهای جدید روی کدهای محصول خود، میتوانیم با نوشتن تست برای کدهای طرف سوم، عملکرد و رفتار آنها را راحتتر درک کنیم.
Jim Newkirk
به این نوع تستها learning test
میگوید.
در تستهای یادگیری ما رابطهای کاربری(API) کدهای طرف سوم را در شرایطی مشابه شرایطی که میخواهیم از آنها استفاده کنیم صدا میزنیم تا عملکرد آنها را در تست متوجه شویم.
در واقع ما یک تجربهی کنترل شده داریم تا فهم خود را از APIها و عملکرد آنها را بیازماییم. تستها بر روی آنچه ما از APIها انتظار داریم متمرکز میشوند.
فرض کنید که در یک پروژه ما قصد داریم از بستهی log4j
شرکت آپاچی به عنوان لاگر استفاده کنیم.
ما آن را دانلود میکنیم و مشابه آنچه در داکیومنتهای آن ذکر شده آن را نصب میکنیم. بدون مطالعهی هیچ چیز اضافهای اولین تست کیس خود را مینویسیم، انتظار داریم که در کنسول عبارت "hello" نمایش داده شود.
@Test
public void testLogCreate() {
Logger logger = Logger.getLogger("MyLogger");
logger.info("hello");
}
@Test
public void testLogAddAppender() {
Logger logger = Logger.getLogger("MyLogger");
ConsoleAppender appender = new ConsoleAppender();
logger.addAppender(appender);
logger.info("hello");
}
@Test
public void testLogAddAppender() {
Logger logger = Logger.getLogger("MyLogger");
logger.removeAllAppenders();
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("hello");
}
جالب است که اگر ما آرگومان ConsoleAppender.SystemOut را حذف کنیم هنوز هم عبارت "hello" در خروجی کنسول نشان داده میشود. اما اگر PatternLayout را حذف کنیم یکبار دیگر برنامه از کار میافتد و این یک رفتار عجیب است.
اگر کمی دقیقتر به مستندات نگاه کنیم متوه میشویم که سازندهی پیشفرض ConsoleAppender در حالت unconfigured
است، که قابل استفاده نمیباشد.
این مورد مانند یک باگ یا یک ناسازگاری در log4j است.
بعد از مطالعه و جست و جوی بیشتر در نهایت ما به لیستی مانند لیست 8.1 میرسیم . ما چیزهای زیادی را درمورد log4j یاد گرفتیم و آنها را در قالب مجموعهای تستهای واحد ساده لیست کردیم:
Listing 8.1 -- LogTest.java
public class LogTest {
private Logger logger;
@Before
public void initialize() {
logger = Logger.getLogger("logger");
logger.removeAllAppenders();
Logger.getRootLogger().removeAllAppenders();
}
@Test
public void basicLogger() {
BasicConfigurator.configure();
logger.info("basicLogger");
}
@Test
public void addAppenderWithStream() {
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("addAppenderWithStream");
}
@Test
public void addAppenderWithoutStream() {
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n")));
logger.info("addAppenderWithoutStream");
}
}
تستهای یادگیری در نهایت هیچ هزینهای ندارند. به هر حال باید API را یاد میگرفتیم و نوشتن آن تستها راهی آسان و مجزا برای به دست آوردن این دانش بود. تستهای یادگیری آزمایشهای دقیقی بودند که به افزایش درک ما کمک کردند. نه تنها تستهای یادگیری رایگان هستند بلکه یادگیری استفاده از آنها نوعی سرمایه گذاری نیز به حساب میآید. زمانی که نسخههای جدیدی از این کدها و کتابخانهها منتشر میشوند ما با اجرای همان تستها میتوانیم بفهمیم که آیا در نسخه جدید رفتار متفاوتی وجود دارد یا خیر.
توسعهدهندگان اصلی این کدها معمولا برای اینکه نیازهای جدید را پاسخ دهند تحت فشار هستند.
آنها مجبورند تا باگها را برطرف کنند و قابلیتهای جدید را به کدهای خود بیافزایند، اگر این تغییرات باعث شود که رفتاری ناسازگار با کدهای ما از آنها سر بزند، با اجرای تستها ما فورا متوجه خواهیم شد.
چه به این شیوهی یادگیری معتقد باشید چه نباشید، تستها باید یک مرز خارجی تمیز و مشخص برای عملکردهای مشابه برنامه به وجود بیاورند تا اسکوپ و مرزهای برنامه مشخص باشد.
نوع دیگری از مرزها وجود دارند، نوعی که معلومات را از مجهولات تمایز میدهند. جاهایی در مسیر کد وجود دارند که از دانش ما فراتر هستند. گاهی هیچ دیدی از آنچه که ممکن است در آینده رخ دهد نداریم. گاهی انتخاب میکنیم یا مجبور میشویم که فراتر از مرزهای کد را نبینیم.
چند سال پیش من عضوی از یک تیم توسعه نرمافزار برای سیستم ارتباطات رادیویی بودم. یک زیرسیستم به نام «فرستنده» وجود داشت که ما اطلاعات کمی درباره آن داشتیم، و افراد مسئول این زیرسیستم به نقطه تعریف رابط خود نرسیده بودند. ما نمیخواستیم معطل شویم، بنابراین کار خود را دور از قسمت ناشناخته کد شروع کردیم.
ما دید بسیار خوبی از این که مرزهای زیرسیستم ما کجا شروع میشوند و در کجا خاتمه مییابند داشتیم. اگر چه نداشتن اطلاعات از آن سوی سیستم ما را در یک جهل قرار داده بود اما این کار ما باعث شد تا از آنچه که از رابط با آن زیر سیستم انتظار داشتیم آگاه شویم و این مسئله برای ما شفاف شود.
ما میخواستیم چنین چیزی به فرستنده بگوییم: فرستنده را روی فرکانس ارائه شده کلید زده و آن، یک نمایش آنالوگ از دادههای حاصل از این جریان منتشر میکند. ما نمیدانستیم که چگونه این کار انجام میشود زیرا API هنوز طراحی نشده بود. بنابراین تصمیم گرفتیم که بعداً جزئیات را بررسی کنیم. برای جلوگیری از معطل شدن، رابط کاربری خود را تعریف کردیم. اسمی که برای آن انتخاب کرده بودیم یک اسم جذاب مانند فرستنده بود. ما یک تابع به نام "transmit" تعریف کردیم که یک فرکانس و یک جریان داده را به عنوان ورودی میگرفت. این رابطی بود که انتظار داشتیم داشته باشیم.
یک نکته مثبت درمورد نوشتن یک رابط که مورد انتظار ما بود این بود که تحت کنترل ما بود. این به ما کمک میکرد که خوانایی کد مشتری را حفظ کنیم و بر روی وظیفهای که باید انجام میشد متمرکز شویم.
در شکل 8-2، می بینید که ما کلاسهای CommunicationsController را از API فرستنده (که خارج از کنترل ما بود و تعریف نشده بود) ایزوله کردیم. با استفاده از رابط کاربری مشخص برنامه خود، کد CommunicationsController خود را تمیز و گویا نگه داشتیم. هنگامی که API فرستنده تعریف شد، TransmitterAdapter را نوشتیم تا شکاف را پر کنیم. ADAPTER2 تعامل با API را کپسوله می کند و یک نقطه واحد برای تغییر، در زمانی که API تکامل می یابد، فراهم می کند.
اتفاقات جالبی در مرزهای برنامهها میافتد. یکی از این اتفاقات تغییرات است. یک طراحی نرم افزاری خوب، طراحیای است که بتواند با حداقل هزینه و زمان تغییرات را مدیریت کند و انعطاف مناسبی در برابر آنها داشته باشد. زمانی که از یک کد که خارج از کنترل ما است استفاده میکنیم باید به شدت مواظب باشیم تا طراحی مناسب خود را از دست ندهیم و هزینههای جاری و آتی اضافهای را به سیستم تحمیل نکنیم.
کد در مرزها نیاز به جداسازی واضح و تستهایی دارد که انتظارات را مشخص میکند. ما باید از اطلاع بیش از حد کد خود در مورد جزئیات کدهای طرف سوم خودداری کنیم. بهتر است به چیزی که کنترل میکنیم متکی باشیم تا چیزی که کنترل آن را نداریم، تا مبادا در نهایت شما را کنترل کند.
ما مرزهای شخص ثالث را با داشتن مکان های بسیار کمی در کد که به آنها اشاره دارد، مدیریت می کنیم. ممکن است آنها را همانطور که با Map انجام دادیم بپیچانیم، یا ممکن است از یک آداپتور برای تبدیل از رابط کامل خود به رابط ارائه شده استفاده کنیم. در هر صورت کد ما بهتر با ما صحبت میکند، در استفادههای داخلی سازگارتر است و هنگام تغییر کد طرف سوم، نقاط کمتری برای نگهداری دارد.