مشروع التخرّج: تطبيق جافا حقيقي

إضافة واجهة التطبيق

15 دقيقة الدرس 6 من 13

إضافة واجهة التطبيق

في هذه المرحلة لديك نموذج مجال، وطبقة بيانات، ومنطق أعمال مُجمَّع في كائنات الخدمة. ما ينقص هو نقطة الدخول — الطبقة التي تقبل مدخلات المستخدم، وتستدعي الخدمة المناسبة، وتعرض النتيجة. في هذا الدرس ستبني واجهة سطر أوامر (CLI) متينة أولًا، ثم ستفهم كيفية استبدالها بواجهة HTTP أو تكاملها معها، وستتعلم المبادئ التصميمية التي تُبقي كود الواجهة رفيعًا وقابلًا للتبديل بسهولة.

لماذا يجب أن تكون الواجهة طبقة رفيعة؟

المسؤولية الوحيدة لطبقة الواجهة هي الترجمة: مدخلات المستخدم الخام → استدعاءات على الخدمات، ونتائج الخدمات → مخرجات مقروءة للإنسان (أو للآلة). يجب ألّا تتسرّب قواعد الأعمال إلى هذه الطبقة. إن وجدت نفسك تكتب منطق تحقق أو تفريعات شرطية مبنية على حالة المجال داخل CLI أو متحكم، فذلك الكود يخصّ الخدمة أو كائن المجال.

قاعدة الاعتماد: طبقة الواجهة تعتمد على طبقة الخدمة؛ أما طبقة الخدمة فلا تعتمد أبدًا على طبقة الواجهة. هذا يجعل CLI وHTTP قابلَين للتبادل — يمكنك إضافة خادم HTTP لاحقًا دون لمس أي منطق أعمال.

تصميم نقطة دخول CLI منظّمة

لا تشحن Java بإطار عمل CLI كامل في مكتبتها القياسية، غير أن نمط تحليل args[] إلى بنية أمر/أمر-فرعي سهل البناء ذاتيًا. للمشاريع الإنتاجية تُعدّ مكتبة picocli المعيار الاحترافي، لكنك هنا ستنفّذ النمط من الصفر ليكون واضح الآليات.

ابدأ بكلاس Main يربط التبعيات ويوزّع الأوامر:

public class Main { public static void main(String[] args) { // 1. تهيئة البنية التحتية DataSource dataSource = DataSourceFactory.create(); TaskRepository taskRepo = new JdbcTaskRepository(dataSource); UserRepository userRepo = new JdbcUserRepository(dataSource); // 2. بناء الخدمات TaskService taskService = new TaskService(taskRepo); UserService userService = new UserService(userRepo); // 3. بناء CLI وتشغيله Cli cli = new Cli(taskService, userService); cli.run(args); } }

لاحظ أن Main هو الكلاس الوحيد الذي يعرف كل الأنواع الملموسة — هذا هو نمط جذر التركيب (Composition Root). جميع الكلاسات الأخرى تعتمد على واجهات، مما يجعلها قابلة للاختبار بشكل مستقل.

تنفيذ موزّع CLI

يقرأ كلاس Cli الوسيطة الأولى باعتبارها اسم أمر ويوزّع التنفيذ على معالج مناسب:

public class Cli { private final TaskService taskService; private final UserService userService; private final PrintWriter out; public Cli(TaskService taskService, UserService userService) { this(taskService, userService, new PrintWriter(System.out, true)); } // مُنشئ للاختبارية — أدخِل أي Writer public Cli(TaskService taskService, UserService userService, PrintWriter out) { this.taskService = taskService; this.userService = userService; this.out = out; } public void run(String[] args) { if (args.length == 0) { printHelp(); return; } String command = args[0].toLowerCase(); String[] rest = Arrays.copyOfRange(args, 1, args.length); try { switch (command) { case "task:add" -> handleTaskAdd(rest); case "task:list" -> handleTaskList(rest); case "task:done" -> handleTaskDone(rest); case "task:delete" -> handleTaskDelete(rest); case "user:create" -> handleUserCreate(rest); case "help" -> printHelp(); default -> { out.println("Unknown command: " + command); printHelp(); } } } catch (IllegalArgumentException e) { // أخطاء التحقق من طبقة الخدمة — رسالة للمستخدم out.println("Error: " + e.getMessage()); } catch (RuntimeException e) { // أعطال بنية تحتية غير متوقعة out.println("Unexpected error: " + e.getMessage()); } } private void handleTaskAdd(String[] args) { requireArgs(args, 2, "task:add <userId> <title>"); long userId = parseLong(args[0], "userId"); String title = args[1]; Task task = taskService.createTask(userId, title); out.printf("Created task #%d: %s%n", task.getId(), task.getTitle()); } private void handleTaskList(String[] args) { requireArgs(args, 1, "task:list <userId>"); long userId = parseLong(args[0], "userId"); List<Task> tasks = taskService.getTasksForUser(userId); if (tasks.isEmpty()) { out.println("No tasks found."); } else { tasks.forEach(t -> out.printf("[%s] #%d %s%n", t.isDone() ? "x" : " ", t.getId(), t.getTitle())); } } private void handleTaskDone(String[] args) { requireArgs(args, 1, "task:done <taskId>"); long taskId = parseLong(args[0], "taskId"); taskService.markDone(taskId); out.println("Task #" + taskId + " marked as done."); } private void handleTaskDelete(String[] args) { requireArgs(args, 1, "task:delete <taskId>"); long taskId = parseLong(args[0], "taskId"); taskService.deleteTask(taskId); out.println("Task #" + taskId + " deleted."); } private void handleUserCreate(String[] args) { requireArgs(args, 1, "user:create <email>"); User user = userService.register(args[0]); out.printf("Created user #%d (%s)%n", user.getId(), user.getEmail()); } private void requireArgs(String[] args, int n, String usage) { if (args.length < n) { throw new IllegalArgumentException("Usage: " + usage); } } private long parseLong(String value, String name) { try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new IllegalArgumentException(name + " must be a number, got: " + value); } } private void printHelp() { out.println(""" Usage: app <command> [options] Commands: task:add <userId> <title> Create a task for a user task:list <userId> List all tasks for a user task:done <taskId> Mark a task as done task:delete <taskId> Delete a task user:create <email> Register a new user help Show this message """); } }
أدخِل كاتب المخرجات. قبول PrintWriter في المُنشئ بدلًا من ترميز System.out يتيح لاختبارات الوحدة تمرير StringWriter والتحقق من المخرجات دون التقاط stdout. هذا قرار تصميمي صغير لكنه بالغ الأثر.

تشغيل التطبيق

بعد التغليف في JAR موحّد (يُغطَّى في الدرس التاسع)، تبدو الأوامر هكذا:

# تسجيل مستخدم java -jar app.jar user:create alice@example.com # إضافة مهمة java -jar app.jar task:add 1 "Write unit tests" # عرض المهام java -jar app.jar task:list 1 # وضع علامة "منجزة" java -jar app.jar task:done 1

إضافة وضع REPL تفاعلي

للتفاعل الأغنى يمكنك لفّ الموزّع في حلقة Scanner تقرأ الأسطر من System.in:

public void repl() { out.println("Task Manager — type 'help' for commands, 'exit' to quit."); try (Scanner scanner = new Scanner(System.in)) { while (scanner.hasNextLine()) { String line = scanner.nextLine().trim(); if (line.equalsIgnoreCase("exit")) break; if (line.isBlank()) continue; run(line.split("\\s+", -1)); } } out.println("Goodbye."); }

متى تُضيف طبقة HTTP بدلًا من ذلك؟

تُعدّ CLI مثالية لأدوات المطوّرين والمهام الدُفعية والسكربتات. حين يحتاج تطبيقك لخدمة مستخدمين متزامنين أو التكامل مع خدمات أخرى أو تقديم API معياري، تُضيف طبقة HTTP. الفكرة الجوهرية هي أن طبقة خدمتك لا تتغير إطلاقًا — تكتب فقط مهايئًا جديدًا.

مع إطار Javalin الخفيف (تبعية واحدة، ~900 كيلوبايت)، يستغرق إضافة HTTP نحو 30 سطرًا:

import io.javalin.Javalin; import io.javalin.http.Context; public class HttpServer { private final TaskService taskService; private final UserService userService; public HttpServer(TaskService taskService, UserService userService) { this.taskService = taskService; this.userService = userService; } public void start(int port) { Javalin app = Javalin.create().start(port); app.get("/users/{id}/tasks", this::listTasks); app.post("/users/{id}/tasks", this::createTask); app.patch("/tasks/{id}/done", this::markDone); app.delete("/tasks/{id}", this::deleteTask); } private void listTasks(Context ctx) { long userId = Long.parseLong(ctx.pathParam("id")); ctx.json(taskService.getTasksForUser(userId)); } private void createTask(Context ctx) { long userId = Long.parseLong(ctx.pathParam("id")); String title = ctx.bodyAsClass(CreateTaskRequest.class).title(); ctx.status(201).json(taskService.createTask(userId, title)); } private void markDone(Context ctx) { taskService.markDone(Long.parseLong(ctx.pathParam("id"))); ctx.status(204); } private void deleteTask(Context ctx) { taskService.deleteTask(Long.parseLong(ctx.pathParam("id"))); ctx.status(204); } }
لا تدع طبقة HTTP تتخذ قرارات المجال. خطأ شائع هو كتابة if (task.getOwnerId() != userId) ctx.status(403) داخل المعالج. فحص الملكية هذا قاعدة أعمال — يخصّ TaskService الذي يرمي AccessDeniedException. المعالج يترجم الاستثناء فحسب إلى رمز حالة HTTP.

الاختيار بين CLI وHTTP

  • CLI أولًا: الأسرع في البناء، بلا تكاليف شبكة، مثالية للسكربتات وأدوات المطوّرين.
  • HTTP أولًا: ضرورية لتطبيقات الويب متعددة المستخدمين، والخلفيات المحمولة، أو الخدمات المصغّرة.
  • كلاهما: المعمارية النظيفة التي بنيتها تجعل شحن كلتا الواجهتين من طبقة خدمة واحدة أمرًا تافهًا — يمكن لـ Main قبول علامة --server لتشغيل مهايئ HTTP بدلًا من REPL أو بجانبه.

الخلاصة

طبقة الواجهة تترجم بين العالم الخارجي وخدماتك. تستخدم CLI المصممة جيدًا موزّع أوامر، وتُدخِل كاتب مخرجاتها لتحقيق الاختبارية، وتُعيد توجيه كل أخطاء المجال بوضوح للمستخدم. حين تحتاج HTTP، يُربَط إطار خفيف كـ Javalin في دقائق لأن طبقة خدمتك معزولة بالفعل. في الدرس التالي ستُضيف معالجة أخطاء دفاعية وتحقق من المدخلات يحمي كلتا الواجهتين في آنٍ واحد.