Skip to content

فصل ۱۴ — ابزارهای یک رشتهٔ مهندسی

بخش چهارم — ابزارهای پشتیبانی مهندسی در نرم‌افزار

وقتی فکر می‌کنم یک رشتهٔ مهندسی واقعی برای نرم‌افزار چه باید باشد، زیاد به ابزار، زبان برنامه‌نویسی، فرایند یا تکنیک ترسیم نمودار خاص فکر نمی‌کنم. به نتایج فکر می‌کنم.

هر رویکردی که نام مهندسی نرم‌افزار را می‌برد باید حول نیاز ما به یادگیری، کاوش و آزمایش ایده‌ها ساخته شود. مهم‌تر از همه، اگر به ساخت نرم‌افزار بهتر و سریع‌تر کمک نکند، «مد» است نه مهندسی. مهندسی چیزی است که کار می‌کند؛ اگر کار نکند، تا وقتی کار کند آن را تغییر می‌دهیم.

شاید به ابزار خاص فکر نکنم، اما به معنای نبودن ابزار نیست. این کتاب بر ایدهٔ وجود «ابزارهای» فکری بنا شده که می‌توان به‌طور جهانی به توسعهٔ نرم‌افزار اعمال کرد و شانس ساخت نرم‌افزار بهتر و سریع‌تر را به‌طور قابل‌توجهی بهبود داد. همهٔ ایده‌ها برابر نیستند؛ بعضی به‌سادگی بد هستند و باید کنار گذاشته شوند.

در این فصل برخی ایده‌هایی را که در سراسر کتاب گفته‌ام بررسی می‌کنم. همهٔ چیز دیگر کتاب را کنار هم می‌آورند. اگر همهٔ چیز دیگر را نادیده بگیرید، فقط این ایده‌ها را بپذیرید و اصل بنیادین کار توسعهٔ نرم‌افزار بدانید، نتایج بهتر می‌گیرید و با گذشت زمان همهٔ ایده‌های دیگر این کتاب را — چون پیامد منطقی آن‌ها هستند — کشف می‌کنید.

توسعهٔ نرم‌افزار چیست؟

توسعهٔ نرم‌افزار قطعاً بیش از دانستن syntax و libraryهای یک زبان برنامه‌نویسی است. ایده‌هایی که ثبت می‌کنیم به‌طرق مختلف مهم‌تر از ابزارهایی هستند که برای ثبت آن‌ها استفاده می‌کنیم. در نهایت برای حل مسئله پول می‌گیریم، نه برای به‌کار بردن ابزار.

نوشتن نرم‌افزار برای هر هدفی بدون دانستن اینکه کار می‌کند یا نه چه معنایی دارد؟

اگر کد را با دقت بررسی کنیم اما هرگز اجرا نکنیم، خود را به بخت و اقبال می‌سپاریم. انسان‌ها این‌طور کار نمی‌کنند. حتی برای زبان‌های loosely interpreted مثل زبان گفتاری، همیشه اشتباه می‌کنیم. آیا تا به حال چیزی — مثلاً یک ایمیل — نوشته‌اید، بدون ویرایش فرستاده‌اید و دیر فهمیدید خطای دستوری یا املایی دارد؟

من و ویراستاران سخت کار کردیم اشتباه در این کتاب را حذف کنیم، اما مطمئنم باز هم چند مورد پیدا کرده‌اید. انسان‌ها مستعد خطا هستند. به‌ویژه در بررسی چیزها بد عمل می‌کنیم، چون اغلب آنچه انتظار داریم می‌بینیم نه آنچه واقعاً هست.

این انتقاد از تنبلی نیست؛ به‌رسمیت شناخت محدودیت زیست‌شناختی ماست. برای انسان وحشی در محیط خصمانه ساخته شده‌ایم که به نتیجه بپرد — ویژگی بسیار خوبی.

نرم‌افزار نسبت به خطا بی‌تحمل است؛ ویرایش و code review کافی نیست. باید test کنیم که کار می‌کند. testing می‌تواند اشکال مختلف داشته باشد — اجرای غیررسمی، debugger، مجموعه‌ای از سناریوهای BDD — همه فقط تلاش برای گرفتن feedback روی تغییراتمان است.

همان‌طور که در فصل ۵ بحث شد، feedback باید fast و efficient باشد تا ارزشمند باشد.

پس اگر باید test کنیم، تنها سؤال این است: چگونه تا حد ممکن efficient و effective؟

می‌توانیم صبر کنیم تا فکر کنیم کار تمام شده و همه‌چیز را با هم test کنیم. شاید نرم‌افزار را در production release کنیم و کاربران رایگان test کنند؟ محتمل‌ترین مسیر به موفقیت نیست! هزینهٔ تجاری برای کار کم‌کیفیت وجود دارد؛ به همین دلیل engineering approach مهم است.

به‌جای امید و دعا، باید ارزیابی قبل از release در production انجام دهیم. چند راه مختلف برای سازمان‌دهی آن وجود دارد.

اگر صبر کنیم تا فکر کنیم تمام شده، به‌وضوح feedback با کیفیت و به‌موقع نمی‌گیریم. جزئیات کار را فراموش می‌کنیم؛ testing تا حدی سطحی می‌شود. کار هم بسیار سنگین می‌شود.

در این نقطه بسیاری از سازمان‌ها تصمیم می‌گیرند افرادی استخدام کنند این کار را انجام دهند. دوباره به بخت و اقبال می‌سپاریم: حدس می‌زنیم نرم‌افزار احتمالاً کار می‌کند و به دیگران تکیه می‌کنیم بگویند نکرد. نسبت به انتظار برای فریاد کاربران در production قدمی به جلو است، اما هنوز نتیجهٔ کم‌کیفیت است.

افزودن گام جدا به فرایند — گروه جداگانهٔ افراد — سرعت یا کیفیت feedback را بهبود نمی‌دهد. این انتقاد از افراد نیست؛ همه برای رقابت با رویکرد خودکار جمع‌آوری feedback که نیاز داریم، بیش از حد کند، متغیر و گران‌اند.

feedback هم دیر می‌آید و در حین توسعه هیچ تصوری از خوب یا بد بودن نرم‌افزار نداریم. فرصت‌های یادگیری ارزشمندی را از دست می‌دهیم. صبر می‌کنیم تا فکر کنیم تمام شده، سپس feedback کند و کم‌کیفیت از افرادی — هرچقدر ماهر و دقیق — که درون‌کارکرد سیستمی که با در نظر گرفتن testing طراحی نشده را نمی‌شناسند.

ممکن است از کیفیت به‌شکل خوشایندی شگفت‌زده شویم، اما احتمال بیشتر این است که از اشتباهات احمقانه‌ای که گذاشته‌ایم شوکه شویم. به یاد داشته باشید: تا الان هیچ testing دیگری — حتی اجرا — نکرده‌ایم.

مطمئناً می‌بینید که فکر می‌کنم این به هیچ‌وجه به اندازهٔ کافی خوب نیست.

این ایده بد است؛ باید نوعی بررسی در فرایند قبل از رسیدن به این نقطه بسازیم. خیلی دیر است بفهمیم کاربر نمی‌تواند login کند و feature جدید جالب disk را corrupt می‌کند.

پس اگر باید test کنیم، هوشمندانه عمل کنیم. چگونه کار را سازمان‌دهی کنیم که میزان کار را به حداقل برسانیم و بینش را بیشینه کنیم؟

در بخش II دربارهٔ بهینه‌سازی برای یادگیری صحبت کردیم؛ چه می‌خواهیم یاد بگیریم و کارآمدترین و مؤثرترین راه چیست؟

چهار دستهٔ یادگیری در نقطه‌ای که می‌خواهیم کد بنویسیم relevant است:

  • «آیا مسئلهٔ درست را حل می‌کنیم؟»
  • «آیا راه‌حل همان‌طور که فکر می‌کنیم کار می‌کند؟»
  • «کیفیت کار ما چیست؟»
  • «آیا به‌صورت efficient کار می‌کنیم؟»

این‌ها قطعاً سؤالات پیچیده‌ای برای پاسخ دادن هستند، اما اساساً فقط به این‌ها علاقه‌مندیم.

Testability به‌عنوان ابزار

اگر نرم‌افزار را test می‌کنیم، منطقی است برای آسان‌تر کردن زندگی، نرم‌افزار را آسان test کنیم. در فصل ۱۱ توضیح دادم separation of concerns و dependency injection چگونه کد را testableتر می‌کند. در واقع سخت است کد testable تصور کنیم که modular، cohesive، separation of concerns خوب و information hiding نداشته باشد. اگر همه این‌ها را داشته باشد، نهایتاً به‌طور مناسب coupled خواهد بود.

بیایید مثال ساده‌ای از تأثیر testable کردن کد ببینیم. فقط خط استدلال «می‌خواهم چیزی test کنم» را دنبال می‌کنم. Listing 14.1 class ساده Car را نشان می‌دهد.

Listing 14.1 مثال ساده Car

java
public class Car {
  private final Engine engine = new PetrolEngine();


  public void start() {
      putIntoPark();
      applyBrakes();
      this.engine.start();
  }


  private void applyBrakes() {
  }


  private void putIntoPark() {
  }
}

این class یک engine دارد — PetrolEngine. وقتی «ماشین را روشن می‌کنید»، چند کار انجام می‌دهد: park، brakes، start Engine. خوب به نظر می‌رسد؛ بسیاری کد شبیه این می‌نویسند.

حالا test کنیم، همان‌طور که در Listing 14.2 نشان داده شده.

Listing 14.2 Test برای Car ساده

java
@Test
public void shouldStartCarEngine() {
    Car car = new Car();
    car.start();
    // Nothing to assert!!
}

فوراً به مشکل می‌خوریم. مگر encapsulation Car را بشکنیم و field خصوصی engine را public کنیم، یا راه دور مخرب برای خواندن متغیر خصوصی بدهیم (هر دو ایدهٔ وحشتناکی)، نمی‌توانیم Car را test کنیم! این کد اصلاً testable نیست چون اثر «روشن کردن ماشین» را نمی‌بینیم.

مشکل این است که به نوعی endpoint خورده‌ایم. آخرین نقطهٔ دسترسی فراخوانی start است. بعد از آن درون‌کارکرد نامرئی است. اگر Car را test کنیم، باید دسترسی بدهیم که مورد ویژهٔ فقط برای testing نباشد. می‌خواهیم engine را ببینیم.

می‌توانیم با افزودن measurement point از طریق dependency injection این را برطرف کنیم. BetterCar مثال بهتری است؛ در این مثال به‌جای پنهان کردن Engine، آن را به BetterCar پاس می‌دهیم. Listing 14.3 BetterCar و Listing 14.4 test آن را نشان می‌دهند.

Listing 14.3 BetterCar

java
public class BetterCar {
    private final Engine engine;


      public BetterCar(Engine engine) {
          this.engine = engine;
      }


      public void start() {
          putIntoPark();
          applyBrakes();
          this.engine.start();
      }


      private void applyBrakes() {
      }


      private void putIntoPark() {
      }
}

Listing 14.3 یک Engine inject می‌کند. این گام ساده coupling با PetrolEngine را کاملاً تغییر می‌دهد؛ حالا class انتزاعی‌تر است چون از نظر Engine کار می‌کند نه PetrolEngine. separation of concerns و cohesion بهبود یافت چون BetterCar دیگر به ساخت PetrolEngine علاقه‌مند نیست.

در Listing 14.4 test BetterCar را می‌بینیم.

Listing 14.4 Test برای BetterCar

java
@Test
public void shouldStartBetterCarEngine() {
    FakeEngine engine = new FakeEngine();
    BetterCar car = new BetterCar(engine);
    car.start();
    assertTrue(engine.startedSuccessfully());
}

این BetterCarTest از FakeEngine استفاده می‌کند، که برای کامل بودن در Listing 14.5 آمده.

Listing 14.5 FakeEngine برای Test BetterCar

java
public class FakeEngine implements Engine {
    private boolean started = false;


      @Override
      public void start() {
          started = true;
      }


      public boolean startedSuccessfully() {
          return started;
      }
}

FakeEngine فقط ثبت می‌کند start فراخوانی شد.¹

این تغییر ساده کد را testable و — همان‌طور که دیدیم — بهتر کرد. علاوه بر ویژگی‌های کیفیت انتزاعی مثل modularity و cohesion، در راهی ساده‌تر و عملی‌تر بهتر است.

چون کد را testable کردیم، flexibleتر شد. ساخت BetterCar با PetrolEngine ساده است، اما با ElectricEngine، FakeEngine یا حتی JetEngine هم ساده است. BetterCar کد بهتری است چون روی آسان test تمرکز کردیم.

طراحی برای بهبود testability ما را به طراحی کد با کیفیت بالاتر هدایت می‌کند. البته راه‌حل جادویی نیست. اگر در coding بد باشید، ممکن است هنوز بد باشد، اما اگر برای testable بودن تلاش کنید بهتر از معمول خواهد بود. اگر در coding عالی باشید، با testable کردن عالی‌تر می‌شود.

Measurement Points

FakeEngine در مثال ما ایدهٔ مهم دیگری را نشان می‌دهد: measurement points. اگر می‌خواهیم کد testable باشد، باید بتوانیم variables را کنترل کنیم. می‌خواهیم دقیقاً اطلاعات لازم را inject کنیم و فقط همان. برای test، نرم‌افزار را به حالتی می‌بریم که بتوانیم test کنیم، رفتاری را فراخوانی می‌کنیم و سپس نتایج باید visible و measurable باشند.

این واقعاً همان چیزی است که منظورم از «طراحی برای testability» است. سیستم را طوری طراحی می‌کنیم measurement pointهای زیادی داشته باشد — جاهایی که بتوانیم رفتار سیستم را بدون به خطر انداختن یکپارچگی آن بررسی کنیم. این measurement pointها بسته به ماهیت component و سطحی که دربارهٔ testability فکر می‌کنیم، اشکال مختلفی می‌گیرند.

برای testing در مقیاس ریزدانه، به parameters و return values از function یا method تکیه می‌کنیم، اما dependency injection هم استفاده می‌کنیم، همان‌طور که در Listing 14.4 نشان داده شد.

برای testing در مقیاس بزرگ‌تر و سطح سیستم، dependencyهای خارجی را fake می‌کنیم تا probeهای measurement point را در سیستم قرار دهیم و test input تزریق کنیم یا test output جمع کنیم، همان‌طور که در فصل ۹ توضیح دادم.

مشکلات دستیابی به Testability

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

همان‌طور که قبلاً بررسی کردیم، هر شکل testing نیاز دارد به measurement pointهای منطقی دسترسی داشته باشیم. برای بیشتر کد با تکنیک‌هایی مثل dependency injection و طراحی modular خوب است، اما در لبه‌های سیستم — جایی که سیستم به نوعی با دنیای واقعی (یا حداقل نسخهٔ نزدیک کامپیوتری آن) تعامل دارد — سخت می‌شود.

اگر کدی بنویسیم که به disk می‌نویسد، روی screen می‌کشد یا hardware را کنترل یا واکنش نشان می‌دهد، آن لبهٔ سیستم سخت test کردن است: چگونه test code تزریق کنیم تا test data بدهیم یا test results جمع کنیم؟

پاسخ واضح این است که سیستم را طوری طراحی کنیم این «edge»ها به حاشیه رانده و پیچیدگی آن‌ها به حداقل برسد. این واقعاً دربارهٔ کاهش coupling بخش عمدهٔ سیستم نسبت به این edgeهاست. این به نوبهٔ خود وابستگی به عناصر third-party را کم می‌کند و کد را با کار اضافهٔ کم، flexibleتر می‌گذارد.

abstraction مناسبی می‌سازیم که تعامل در این edge را نمایندگی کند، testهایی می‌نویسیم که تعامل سیستم با نسخهٔ fake این abstraction را ارزیابی کند، سپس کد ساده‌ای برای ترجمهٔ abstraction به تعامل واقعی با فناوری edge می‌نویسیم. راه طولانی برای گفتن این است که یک لایهٔ indirection اضافه می‌کنیم.

Listing 14.6 مثال ساده‌ای از کدی است که باید چیزی display کند. می‌توانیم robot با camera برای ثبت خروجی روی screen بسازیم، اما بیش از حد است. به‌جای آن عمل نمایش را abstract می‌کنیم با inject کردن قطعه‌ای از کد خودمان که توانایی «display» کردن متن را فراهم کند.

Listing 14.6 Stuff to Display

java
public interface Display
{
    void show(String stringToDisplay);
}


public class MyClassWithStuffToDisplay
{
    private final Display display;


      public MyClassWithStuffToDisplay(Display display)
      {
          this.display = display;
      }


      public void showStuff(String stuff)
      {
          display.show(stuff);
      }
}

با abstract کردن عمل نمایش، اثر جانبی خوبی هم گرفتم: class با چیزهایی برای نمایش حالا از هر display device واقعی decouple شده — حداقل خارج از مرزهای abstractionی که فراهم کرده‌ام. البته یعنی حالا می‌توانیم این کد را بدون Display واقعی test کنیم. مثال چنین testی را در Listing 14.7 گنجانده‌ام.

Listing 14.7 Testing Stuff to Display

java
@Test
public void shouldDisplayOutput() throws Exception
{
    Display display = mock(Display.class);
    MyClassWithStuffToDisplay displayable = new MyClassWithStuffToDisplay(display);


      displayable.showStuff("My stuff");


      verify(display).show(eq("My stuff"));
}

سرانجام می‌توانیم پیاده‌سازی concrete از Display بسازیم. در این مورد ساده در Listing 14.8 ConsoleDisplay است، اما می‌توانیم در صورت نیاز LaserDisplayBoard، MindImprintDisplay، 3DGameEngineDisplay و غیره را جایگزین کنیم.

Listing 14.8 Displaying Stuff

java
public class ConsoleDisplay implements Display
{
    @Override
    public void show(String stringToDisplay)
    {
        System.out.println(stringToDisplay);
    }
}

Listingهای 14.5 تا 14.8 ساده و پیش‌پاافتاده‌اند و abstraction برای فناوری پیچیده‌تر در edge به‌وضوح باید پیچیده‌تر باشد، اما اصل باقی می‌ماند.

Testing در Edges

در یکی از پروژه‌هایی که روی آن کار کردم، web DOM را این‌طور abstract کردیم تا منطق web page unit testable شود.

آن زمان گزینه‌های بهتری وجود دارد، اما unit test کردن web application بدون browser واقعی دشوار بود. نمی‌خواستیم testing را با راه‌اندازی browser برای هر test case کند کنیم، پس نحوهٔ نوشتن UI را تغییر دادیم.

libraryی از UI component نوشتیم که «جلوی DOM» قرار می‌گرفت (Ports & Adapters for DOM)، پس اگر table لازم بود، JavaScript Table را از طریق DOM factory خودمان می‌ساختیم. در زمان اجرا facade object نازکی می‌داد که table قابل استفاده بود. در زمان test، stub می‌داد که می‌توانستیم علیه آن test کنیم اما به browser یا DOM واقعی نیاز نداشت.

همیشه می‌توانید این کار را بکنید. واقعاً فقط مسئلهٔ آسان یا دشوار بودن فناوری است که می‌خواهید abstract کنید و اینکه تا چه حد فکر می‌کنید این تلاش ارزش دارد.

برای این «edge»های سیستم تقریباً همیشه ارزش تلاش دارد. گاهی در web UI یا mobile app testing، مثلاً، دیگران کار را برایتان انجام داده‌اند، اما این نحوهٔ unit test تا لبه‌ها است.

مشکل این رویکرد — و هر رویکردی برای حل این مسئله — فرهنگی است. اگر testability را جدی بگیریم و از ابتدا در رویکرد طراحی بپذیریم، همهٔ این نسبتاً آسان است.

وقتی به کدی برخورد کنیم که با testability در نظر ساخته نشده یا افراد فکر نمی‌کنند مهم است، بسیار سخت‌تر می‌شود. این تضاد فرهنگی مشکل سختی است.

کد احتمالاً بخش آسان‌تر مشکل است، هرچند آسان‌تر لزوماً به معنای «آسان» نیست. همیشه می‌توانیم abstractionهای خودمان را اضافه کنیم، حتی اگر leaky باشند، و test را آسان‌تر کنیم. اگر واقعاً مجبور شویم، می‌توانیم کد edge سرسخت را در scope test قرار دهیم. مصالحهٔ ناخوشایند است اما در برخی شرایط قابل‌اجرا.

مشکل سخت‌تر افراد است. مغرور نیستم که بگویم هرگز تیمی TDD واقعی را — به معنای «قبل از نوشتن کد test بنویس» — اجرا کرده و کار نکرده، اما هرگز چنین تیمی ندیده‌ام.

تیم‌های زیادی گفته‌اند «TDD را امتحان کردیم و جواب نداد»، اما همهٔ گروه‌هایی که دیده‌ام منظورشان این بود که unit test را بعد از نوشتن کد امتحان کرده‌اند. این اصلاً همان چیز نیست.

تفاوت این است که TDD طراحی کد testable را تشویق می‌کند و unit testing این کار را نمی‌کند. unit testing بعد از نوشتن کد ما را تشویق می‌کند گوشه‌گیری کنیم، encapsulation را بشکنیم و test را به‌طور سخت به کدی که نوشته‌ایم couple کنیم.

TDD به‌عنوان سنگ‌بنای یک engineering approach به توسعهٔ نرم‌افزار ضروری است. practice دیگری نمی‌شناسم که به‌اندازهٔ آن مؤثر باشد در تشویق و تقویت توانایی ما برای ایجاد طراحی خوب مطابق ایده‌های این کتاب.

قوی‌ترین استدلال علیه TDD که گاهی می‌شنوم این است که کیفیت طراحی را به خطر می‌اندازد و توانایی تغییر را محدود می‌کند چون testها به کد coupled هستند. من هرگز ندیده‌ام این را در codebaseای که با «test-first TDD» ساخته شده. این رایج — می‌گویم اجتناب‌ناپذیر — نتیجهٔ «unit testing بعد از نوشتن کد» است. پس گمان من این است که وقتی می‌گویند «TDD جواب نمی‌دهد»، واقعاً منظورشان این است که واقعاً TDD را امتحان نکرده‌اند؛ مطمئنم در همهٔ موارد درست نیست، اما به همان اندازه مطمئن هستم در بیشتر موارد درست است و پس تقریب خوبی برای حقیقت است.

انتقاد دربارهٔ کیفیت طراحی به‌ویژه برایم نزدیک است، چون — همان‌طور که از این کتاب حدس می‌زنید — خیلی به کیفیت طراحی اهمیت می‌دهم.

بی‌صادقانه است اگر وانمود کنم در توسعهٔ نرم‌افزار، طراحی نرم‌افزار و TDD مهارت ندارم. نسبتاً خوب هستم و فقط می‌توانم حدس بزنم چرا. باتجربه هستم، شاید کمی استعداد ذاتی دارم، اما مهم‌تر از همه عادت‌های خوبی دارم که مرا از دردسر دور نگه می‌دارند. TDD feedback واضح‌تری روی کیفیت طراحی من با تکاملش می‌دهد از هر چیز دیگری که می‌شناسم و سنگ‌بنای روشی است که کار می‌کنم و به دیگران توصیه می‌کنم.

چگونه Testability را بهبود دهیم

بخش II اهمیت بهینه‌سازی برای یادگیری را توصیف می‌کند. منظورم در معنای بلندپروازانه و آکادمیک نیست. منظورم در معنای ریزدانه و عملی از مهندسی روزمره است. پس به‌صورت تکراری کار می‌کنیم و test برای قطعهٔ کار جلویمان اضافه می‌کنیم. می‌خواهیم feedback سریع، کارآمد و واضح از test بگیریم تا بتوانیم به‌سرعت یاد بگیریم — در مقیاس‌های کوچک، هر چند دقیقه — که کد دقیقاً همان‌طور که انتظار داریم عمل می‌کند.

برای این کار می‌خواهیم سیستم را بخش‌بندی کنیم تا feedback واضح باشد. به‌صورت افزایشی روی قطعات کوچک و جدا کار می‌کنیم و دامنهٔ ارزیابی را محدود می‌کنیم تا واضح باشد چه اتفاقی می‌افتد.

می‌توانیم به‌صورت آزمایشی کار کنیم و هر test case را به‌عنوان آزمایش کوچک ساختار دهیم که رفتار مورد نظر کد را پیش‌بینی و تأیید می‌کند. test می‌نویسیم تا فرضیهٔ رفتار نرم‌افزار را ثبت کند. پیش‌بینی می‌کنیم test چگونه fail خواهد شد قبل از اجرا تا تأیید کنیم test واقعاً چیزی را test می‌کند که انتظار داریم. سپس کدی می‌سازیم که test را pass کند و از ترکیب پایدار و passing کد و test به‌عنوان بستری برای بازبینی طراحی و ایجاد تغییرات کوچک، ایمن و با حفظ رفتار برای بهینه‌سازی کیفیت کد و testها استفاده می‌کنیم.

این رویکرد بینش عمیقی به طراحی ما در حین پیشرفت می‌دهد، در معنایی بسیار عمیق‌تر از «آیا test را pass می‌کند؟». اگر توجه کنیم، testability کد ما را به سمت نتیجهٔ با کیفیت بالاتر هدایت می‌کند.

ابزار کافی که این کار را برای ما انجام دهد نداریم و نادیده گرفتنش به خطر خودمان است. developerها و تیم‌های توسعهٔ زیادی این را نادیده می‌گیرند و نرم‌افزار بدتر و آهسته‌تر تولید می‌کنند از آنچه می‌توانستند و باید می‌کردند.

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

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

در سطح ریزدانه از functionها و classها، مهم‌ترین جنبهٔ testability که باید روی آن تمرکز کنیم measurement pointها هستند. آن‌ها آسانی‌ای را که می‌توانیم کد را در state خاصی برقرار کنیم و آسانی مشاهده و ارزیابی نتایج رفتارش را تعریف می‌کنند.

در سطح سیستمی و چندسیستمی، تمرکز بیشتر روی دامنهٔ ارزیابی و testing است. اصول measurement pointها هنوز مهم‌اند، اما دامنهٔ ارزیابی خودش ابزار مهمی است.

Deployability

در کتاب Continuous Delivery، رویکردی به توسعه توصیف کردیم که بر ایدهٔ کار کردن طوری بنا شده که نرم‌افزار همیشه در حالت releasable باشد. بعد از هر تغییر کوچک، نرم‌افزار را ارزیابی می‌کنیم تا releasability آن را تعیین کنیم و این feedback را چندین بار در روز می‌گیریم.

برای دستیابی به این، از مکانیزمی به نام deployment pipeline استفاده می‌کنیم. deployment pipeline قرار است releasability را تا حد عملی از طریق سطح بالای خودکارسازی تعیین کند.

پس «releasable» یعنی چه؟ ناگزیر تا حدی بسترمند است. قطعاً باید بدانیم کد همان‌طور که developerها فکر می‌کنند کار می‌کند، و خوب است بدانیم آنچه userها نیاز دارند انجام می‌دهد. بعد از آن می‌خواهیم بدانیم نرم‌افزار به اندازهٔ کافی سریع، به اندازهٔ کافی امن، به اندازهٔ کافی تاب‌آور است و شاید با مقررات applicable سازگار است.

همهٔ این‌ها وظیفهٔ deployment pipeline هستند. تا اینجا deployment pipeline را از منظر releasability توصیف کردم، اما نکتهٔ ظریف کوچکی هست که قبل از ادامه می‌خواهم روشن کنم.

در واقع وقتی deployment pipelineها را توصیف می‌کنم، بین releasable و deployable تمایز قائل می‌شوم. نکتهٔ ظریف است، اما از دیدگاه توسعه می‌خواهم ایدهٔ «آماده برای استقرار تغییر در production» را از «release کردن feature به userها» جدا کنم.

در continuous delivery می‌خواهیم آزادی ایجاد featureهای جدید در طول مجموعه‌ای از deploymentها را داشته باشیم. پس اینجا از صحبت دربارهٔ releasability — که کامل بودن feature و سودمندی برای userها را دلالت می‌کند — به deployability می‌رویم — یعنی نرم‌افزار برای release به production ایمن است حتی اگر برخی featureها هنوز آمادهٔ استفاده نیستند و به نوعی پنهان هستند.

پس deployability سیستم ما از ویژگی‌های مختلفی تشکیل می‌شود: واحد نرم‌افزار باید قابل استقرار باشد و همهٔ ویژگی‌های releasability که در context آن سیستم معنا دارند را برآورده کند: به اندازهٔ کافی سریع، به اندازهٔ کافی امن، به اندازهٔ کافی تاب‌آور، در حال کار و غیره.

این ایدهٔ deployability در سطح system و architectural tool بسیار مفیدی است. اگر deployment pipeline بگوید سیستم deployable است، آمادهٔ استقرار در production است.

بسیاری continuous delivery را اشتباه می‌فهمند، اما این همان چیزی است که deployment pipeline برایش است. اگر deployment pipeline بگوید تغییر خوب است، دیگر testing، sign-off و integration testing با بخش‌های دیگر سیستم قبل از deploy به production لازم نیست. مجبور نیستیم deploy به production کنیم، اما اگر تغییر توسط pipeline تأیید شد، آماده است اگر بخواهیم.

این discipline چیز مهمی می‌گوید. deployability را به‌عنوان «کار بیشتری باقی نمانده» تعریف می‌کند و یعنی برای دستیابی به نتیجهٔ deployable باید ایده‌های modularity، cohesion، separation of concerns، coupling و information hiding را در سطح واحدهای نرم‌افزاری قابل استقرار مستقل جدی بگیریم.

دامنهٔ ارزیابی همیشه باید واحد نرم‌افزاری قابل استقرار مستقل باشد. اگر نمی‌توانیم با اطمینان تغییر را بدون کار بیشتر به production release کنیم، واحد ارزیابی ما — دامنهٔ deployment pipeline — نادرست است.

راه‌های مختلفی برای رویکرد این وجود دارد. می‌توانیم همهٔ چیزهای سیستم را در دامنهٔ ارزیابی و دامنهٔ deployment pipeline قرار دهیم، یا سیستم را به واحدهای نرم‌افزاری قابل استقرار مستقل تجزیه کنیم، اما چیز دیگری معنا ندارد.

می‌توانیم چند component سیستم را در جاهای جدا build کنیم، از repoهای جدا، اما دامنهٔ ارزیابی توسط الزامات deployability هدایت می‌شود. پس اگر این مسیر را انتخاب کنیم و ضروری بدانیم این componentها را قبل از release با هم ارزیابی کنیم، دامنهٔ ارزیابی — دامنهٔ deployment pipeline — هنوز کل سیستم است. این مهم است چون هرچقدر ارزیابی بخش کوچک سیستم سریع باشد، زمانی که ارزیابی deployability یک تغییر می‌گیرد واقعاً مهم است. پس این دامنه باید هدف بهینه‌سازی باشد.

یعنی deployability نگرانی حیاتی در ایجاد سیستم‌هاست. فکر کردن به این منظر به تمرکز روی مسئله‌ای کمک می‌کند که باید برطرف کنیم: چگونه feedback در بازهٔ زمانی معقول بگیریم که بتوانیم تلاش‌های توسعه را هدایت کنیم؟

Speed

این ما را به سرعت می‌رساند. همان‌طور که در بخش II بحث کردیم، سرعت و کیفیت feedback که در فرایند توسعه می‌گیریم برای بهینه‌سازی برای یادگیری ضروری است. در فصل ۳ اهمیت measurement را بحث کردیم و روی استفاده از stability و throughput تمرکز کردیم. throughput به‌عنوان معیار کارایی فرایند توسعه به‌وضوح مرتبط با سرعت است.

وقتی با تیم‌ها مشاوره می‌کنم تا پذیرش continuous delivery را کمک کنم، توصیه می‌کنم روی کاهش زمانی که feedback می‌گیرند کار کنند.

معمولاً راهنمایی می‌دهم: روی بهینه‌سازی فرایند توسعه کار کنند تا بتوانند نتیجهٔ releasable — با کیفیت واحد نرم‌افزاری قابل استقرار در production — چندین بار در روز با ترجیح قوی برای زمان‌های کوتاه‌تر — به‌دست آورند. به‌عنوان هدف معمولاً توصیه می‌کنم هدف بگیرند چیزی که می‌تواند در کمتر از یک ساعت از commit هر تغییر به production deploy شود.

این هدف چالش‌برانگیز است، اما فقط در نظر بگیرید چه چیزی دلالت می‌کند. نمی‌توانید تیم‌های بیش از حد بزرگ داشته باشید چون communication overhead شما را بیش از حد کند می‌کند. نمی‌توانید تیم‌های siloed داشته باشید چون هزینهٔ هماهنگی بین تیم‌ها بیش از حد کند خواهد بود. باید موقعیت عالی روی automated testing داشته باشید، به مکانیزم feedbackهایی مثل continuous integration و continuous delivery نیاز دارید، باید architecture خوبی برای پشتیبانی از این استراتژی‌ها داشته باشید و باید واحدهای نرم‌افزاری قابل استقرار مستقل را ارزیابی کنید و چیزهای بسیار بیشتری تا نتیجهٔ releasable در کمتر از یک ساعت به‌دست آورید.

اگر رویکرد تکراری و آزمایشی بگیرید و فقط سرعت feedback در فرایند توسعه را بهبود دهید، به‌عنوان تابع fitness برای تمام نظریهٔ agile، تمام نظریهٔ lean و تمام continuous delivery و DevOps عمل می‌کند.

این تمرکز روی سرعت و feedback شما را ناگزیر به این ایده‌ها می‌رساند. این راهنمای بسیار قدرتمند و قابل‌اندازه‌گیری‌تر به سمت نتایج بهتر است از دنبال کردن آیین‌ها یا دستورالعمل‌های فرایند توسعهٔ آماده. این همان چیزی است که منظورم از مهندسی برای نرم‌افزار است.

سرعت ابزاری است که می‌توانیم برای هدایت به سمت نتایج با کیفیت بالاتر و کارآمدتر استفاده کنیم.

Controlling the Variables

اگر بخواهیم بتوانیم به‌سرعت، به‌طور قابل‌اتکا و تکرارپذیر سیستم‌ها را test و deploy کنیم، باید variance را محدود کنیم و variables را کنترل کنیم. می‌خواهیم هر بار که نرم‌افزار را deploy می‌کنیم همان نتایج را بگیریم، پس باید deployment را automate کنیم و configuration سیستم‌هایی که deploy می‌کنیم را تا حد امکان مدیریت کنیم.

جایی که نمی‌توانیم کنترل اعمال کنیم، باید آن حاشیه‌های سیستم که به دنیای کنترل‌نشده می‌رسند با احتیاط زیاد رفتار کنیم. اگر نرم‌افزار را در محیطی خارج از کنترل خودمان deploy می‌کنیم، می‌خواهیم به آن کمترین میزان ممکن وابسته باشیم. abstraction، separation of concerns و loose coupling ایده‌های کلیدی برای محدود کردن مواجهه با هر چیز خارج از کنترل مستقیم ماست.

می‌خواهیم testهایی که می‌سازیم برای همان نسخهٔ نرم‌افزار تحت test هر بار دقیقاً همان نتایج را بدهند. اگر نتایج test متغیر باشند، باید برای اعمال کنترل بیشتر، test را بهتر از تأثیرات خارجی isolate کنیم یا determinism در کد را بهبود دهیم. modularity و cohesion، separation of concerns، abstraction و coupling باز هم ایده‌های کلیدی برای اعمال این کنترل هستند.

جایی که وسوسهٔ testهای طولانی یا testهای دستی وجود دارد، اغلب نشانه‌های کمبود نامناسب کنترل variables هستند.

اغلب این ایده را به اندازهٔ کافی جدی نمی‌گیریم.

هزینهٔ کنترل ضعیف

یک‌بار برای سازمان بزرگی که distributed software system بزرگ و پیچیده‌ای می‌ساخت مشاوره کردم. بیش از ۱۰۰ تیم developer روی پروژه کار می‌کردند. از من خواستند در performance testing مشاوره دهم.

suite بزرگ و پیچیده‌ای از سراسری performance testها برای کل سیستم ساخته بودند. چهار بار تلاش کرده بودند performance test suite را اجرا کنند، اما حالا نمی‌دانستند نتایج چه معنایی دارند.

نتایج آن‌قدر متغیر بودند که راهی برای مقایسه بین اجراهای test نبود.

یکی از دلایل این بود که test را روی corporate network اجرا کرده بودند، پس بسته به آنچه در آن زمان در جریان بود، نتایج کاملاً منحرف می‌شد.

همهٔ کار برای ایجاد و اجرای این testها اساساً هدررفت بود چون هیچ‌کس نمی‌توانست بگوید نتایج چه معنایی دارند.

کامپیوترها فرصت فوق‌العاده به ما می‌دهند. اگر cosmic rayها و برخورد neutrino با NAND gateهایمان را نادیده بگیریم — هر دو با hardware error-correction protocols پوشش داده می‌شوند — کامپیوترها و نرم‌افزاری که روی آن‌ها اجرا می‌شود deterministic هستند. با همان inputs، کامپیوترها هر بار همان outputs تولید می‌کنند. تنها محدودیت این حقیقت concurrency است.

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

چگونه سیستم‌ها را طراحی و test کنیم تأثیر بزرگی روی میزان کنترلی که می‌توانیم اعمال کنیم دارد. این یک مزیت دیگر از هدایت طراحی‌ها از testها است.

کد به‌طور قابل‌اتکا testable در دامنهٔ یک test چندنخی نیست، به‌جز برخی انواع بسیار خاص test.

کد concurrent سخت test کردن است چون deterministic نیست. پس اگر کد را برای testable بودن طراحی کنیم، با دقت دربارهٔ concurrency فکر می‌کنیم و آن را به لبه‌های کنترل‌شده و شناخته‌شده سیستم منتقل می‌کنیم.

در تجربهٔ من این نتیجه می‌دهد کدی که test کردنش بسیار آسان‌تر است چون deterministic است، اما همچنین کدی که درکش بسیار آسان‌تر است و — قطعاً در جاهایی که کار می‌کنم — از نظر محاسباتی بسیار کارآمدتر.

Continuous Delivery

continuous delivery فلسفهٔ سازمان‌دهنده است که به کنار هم آوردن این ایده‌ها با هم در رویکرد مؤثر، کارآمد و قابل‌اجرا به توسعه کمک می‌کند. کار کردن طوری که نرم‌افزار همیشه releasable باشد ذهن ما را روی دامنهٔ ارزیابی در deployment pipeline و deployability نرم‌افزار متمرکز می‌کند. این ابزارهایی به ما می‌دهد که بتوانیم کد و سازمان‌ها را برای ایجاد این واحدهای نرم‌افزاری قابل استقرار مستقل ساختار دهیم.

continuous delivery فقط دربارهٔ خودکارسازی deployment نیست، هرچند بخشی از آن است؛ دربارهٔ ایدهٔ بسیار مهم‌تر سازمان‌دهی کار طوری است که جریان نیمه‌پیوستهٔ تغییرات ایجاد کنیم.

اگر این ایده را جدی بگیریم، از ما می‌خواهد همهٔ جنبه‌های رویکرد توسعه را برای دستیابی به این ساختار جریان بهینه کنیم. روی ساختار سازمان‌ها اثر می‌گذارد و وابستگی‌های سازمانی را به حداقل می‌رساند و autonomy تیم‌های کوچکی را ترویج می‌کند که می‌توانند به‌سرعت و با کیفیت بالا بدون نیاز به هماهنگی با دیگران کار کنند.

از ما می‌خواهد سطح بالای خودکارسازی اعمال کنیم، به‌ویژه در testing نرم‌افزار، تا بتوانیم به‌سرعت و کارآمد بفهمیم تغییراتمان ایمن هستند. در نتیجه تشویق می‌کند این testing را بسیار جدی بگیریم تا به testable software برسیم و از همهٔ مزایای آن بهره ببریم.

continuous delivery ما را راهنمایی می‌کند deployment و configuration سیستم‌ها را test کنیم و مجبورمان می‌کند ایده‌های controlling the variables را جدی بگیریم تا تکرارپذیری و قابلیت اطمینان در testها و — به‌عنوان اثر جانبی — در production deploymentها به‌دست آوریم.

continuous delivery استراتژی بسیار مؤثر است که می‌توان حول آن engineering discipline قوی برای توسعهٔ نرم‌افزار ساخت.

ابزارهای عمومی پشتیبانی مهندسی

این‌ها ابزارهای عمومی هستند. این ایده‌ها برای هر مسئله‌ای در نرم‌افزار قابل‌اعمال هستند.

بیایید مثال ساده‌ای ببینیم. فرض کنید می‌خواهیم نرم‌افزاری به سیستم اضافه کنیم — شاید third-party component، subsystem یا framework. چگونه آن را ارزیابی کنیم؟

البته باید کار کند و ارزشی به سیستم بدهد، اما قبل از آن می‌توانید از ایده‌های این فصل و بقیهٔ کتاب به‌عنوان معیار ارزیابی استفاده کنید.

آیا فناوری deployable است؟ آیا می‌توانیم deployment سیستم را automate کنیم تا به‌طور قابل‌اتکا و تکرارپذیر deploy کنیم؟

آیا testable است؟ آیا می‌توانیم تأیید کنیم آنچه نیاز داریم انجام می‌دهد؟ کار ما نیست third-party software را به‌طور کامل test کنیم؛ اگر مجبور باشیم، احتمالاً به اندازهٔ کافی کیفیت بالا نیست. اما تا حدی که می‌خواهیم test کنیم در context سیستم ما درست کار می‌کند — به‌درستی پیکربندی است، وقتی نیاز داریم بالا و در حال اجرا است و غیره — آیا می‌توانیم test کنیم؟

آیا اجازه می‌دهد variables را کنترل کنیم؟ آیا می‌توانیم به‌طور قابل‌اتکا و تکرارپذیر deploy کنیم؟ آیا می‌توانیم deployment و configuration را version control کنیم؟

آیا به اندازهٔ کافی سریع است تا در محیط continuous delivery کار کند؟ آیا می‌توانیم در زمان معقول deploy کنیم و چیزها را به اندازهٔ کافی سریع بالا و در حال اجرا کنیم تا چندین بار در روز استفاده و ارزیابی کنیم؟

اگر software componentی است که کد برای interface به آن می‌نویسیم، آیا رویکرد modular به طراحی کد را حفظ می‌کند یا مدل برنامه‌نویسی خودش را تحمیل می‌کند و طراحی ما را به خطر می‌اندازد؟

پاسخ اشتباه به هر کدام از این سؤالات تقریباً قطعاً باید فناوری را قبل از نگاه کردن به مفید بودن در contextهای دیگر رد کند.

مگر serviceای که این third-party tech فراهم می‌کند ضروری باشد، توصیه می‌کنم جایگزین بجوییم. اگر ضروری است، باید کار کنیم این ویژگی‌ها را با وجود فناوری به‌دست آوریم. این هزینه‌ای است که باید در محاسبهٔ هزینه-فایده برای این فناوری لحاظ شود.

این مثال کوچک برای ارائهٔ مدلی از عمومیت این سبک تفکر است. می‌توانیم ابزارهای یادگیری، ابزارهای مدیریت پیچیدگی و این ابزارها را برای پشتیبانی از engineering approach و آگاه کردن تصمیم‌ها در هر جنبهٔ کارمان استفاده کنیم.

خلاصه

این فصل ایده‌های درهم‌تنیده‌ای را که در این کتاب ارائه کرده‌ام به مدل منسجمی برای توسعهٔ مؤثرتر نرم‌افزار کنار هم می‌آورد. با پذیرش ایده‌های این فصل به‌عنوان اصول سازمان‌دهنده برای رویکردمان به توسعهٔ نرم‌افزار، نتیجهٔ بهتری نسبت به نادیده گرفتن آن‌ها خواهیم داشت.

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


¹ در test واقعی Mocking library را انتخاب می‌کردیم به‌جای نوشتن این کد خودمان. FakeEngine را برای وضوح مثال گنجانده‌ام.