Fullstar

Archives

  • December 2025
  • August 2024
  • July 2024
  • February 2024
  • November 2023
  • August 2023
  • July 2023
  • January 2023
  • November 2022
  • October 2022
  • September 2022
  • February 2022
  • January 2022
  • September 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020

Categories

  • Code
  • Lens
  • Life
0
Fullstar
  • Code

WordPress 后台任务利器:使用 BGRunner 构建可靠的异步处理

  • December 14, 2025
  • Brandon
Total
0
Shares
0
0
0

在开发 WordPress 插件时,我们经常会遇到需要执行耗时操作的情况,比如:

  • 批量处理文章、评论或用户数据。
  • 发送大量的电子邮件通知。
  • 与外部 API 进行复杂的数据同步。
  • 生成报告或执行定期维护任务。

如果这些操作直接在用户请求的处理过程中执行,可能会导致页面加载缓慢、超时甚至崩溃,严重影响用户体验。幸运的是,WordPress 提供了内置的计划任务系统 WP-Cron,我们可以利用它来创建和管理后台任务。

然而,原生的 WP-Cron 有其局限性(如依赖访问触发、不够精确)。为了解决这些问题,许多开发者会构建更健壮的后台任务处理框架。今天,我们将深入探讨如何使用一个名为 BGRunner 的示例类,来构建一个强大、可控且相对可靠的 WordPress 后台任务系统。

为什么需要后台任务?

想象一下,你的插件需要一次性处理 1000 篇文章的元数据。如果用户点击一个按钮来触发这个过程:

  • 同步处理: 用户点击按钮,PHP 脚本开始循环处理 1000 篇文章。如果每篇文章处理需要 0.5 秒,总共需要 500 秒 (约 8 分钟)。在此期间,用户的浏览器将一直处于等待状态,极有可能超时,任务失败,并且用户体验极差。
  • 异步/后台处理: 用户点击按钮,系统立即返回一个“任务已启动,请稍后查看结果”的提示。实际的处理在后台发生,不影响用户继续浏览网站。

后台任务的核心优势在于:

  1. 提升用户体验: 操作响应迅速,避免长时间阻塞。
  2. 提高可靠性: 即使网络中断或用户关闭浏览器,后台任务仍可继续执行(如果正确实现)。
  3. 资源管理: 可以更精细地控制任务执行过程中的服务器资源消耗。

WP-Cron:WordPress 的内置计划任务

WP-Cron 是 WordPress 的计划任务系统。它不是一个真正的系统级 Cron 服务,而是通过 WordPress 网站的访问 来触发执行计划的任务。

  • 工作原理: 当用户访问你的 WordPress 网站(任何页面,包括 wp-admin)时,WordPress 会检查是否有计划在当前时间或之前运行的任务。如果有,它就会执行这些任务。
  • 局限性:
    • 不可靠: 如果网站访问量低,任务可能会延迟执行甚至不执行。
    • 性能影响: 任务执行会占用当前请求的处理时间,耗时过长的任务会拖慢页面加载速度。
    • 精确度: 执行时间不精确,依赖于用户访问。

提高 WP-Cron 可靠性的常用方法:
在 wp-config.php 文件中添加 define('DISABLE_WP_CRON', true);,然后使用服务器的系统 Cron job 定时访问 wp-cron.php 文件。这能确保任务按时触发。

BGRunner:一个强大的后台任务管理框架

BGRunner 类(我们将在示例中使用)旨在解决原生 WP-Cron 的一些痛点,并提供一个更结构化的方式来管理后台任务。它具备以下核心功能:

  1. 任务注册: 允许你为不同的后台任务类型定义回调函数。
  2. 状态管理: 跟踪每个任务的当前状态(如:运行中、暂停、停止、已完成),并持久化存储(使用 WordPress 的 Transients API 和 Options API)。
  3. 生命周期控制: 提供 start, pause, resume, stop 等方法,允许你在运行时动态控制任务。
  4. 批处理与资源限制: 将长时间任务分解为小批次执行,并考虑 PHP 的 max_execution_time 和 memory_limit,防止一次执行耗尽资源。
  5. 锁定机制: 防止同一个任务类型被并发执行,增加健壮性。
  6. WP-Cron 集成: 利用 WP-Cron 作为触发器,但内部逻辑使其更可控。

构建你的后台任务插件:一个实际示例

让我们通过创建一个简单的插件来演示 BGRunner 的使用。这个插件将实现一个模拟数据处理任务,并通过一个简单的管理页面来控制它。

项目结构:

my-bg-plugin/
├── my-bg-plugin.php           # 插件主文件
├── includes/
│   └── BGRunner.php           # BGRunner 类文件 (假设已提供)
├── admin/
│   └── dashboard.php          # 管理页面的 HTML 模板
└── assets/
    └── js/
        └── admin-script.js    # 管理页面的 JavaScript

步骤 1: 插件主文件 (my-bg-plugin.php)

<?php
/**
 * Plugin Name: BG Runner Example Plugin
 * Description: Demonstrates how to use the BGRunner class for background tasks.
 * Version: 1.0
 * Author: Your Name
 * Text Domain: my-bg-plugin
 */

// Prevent direct file access
defined('ABSPATH') || exit;

// --- 配置 ---
// 定义一个唯一标识符,用于 BGRunner 避免与其他插件冲突
define('WPMCS_TOKEN', 'my_bg_plugin');

// --- 引入 BGRunner 类 ---
// 假设 BGRunner.php 文件位于 includes 目录下
require_once plugin_dir_path(__FILE__) . 'includes/BGRunner.php';

use Dudlewebs\WPMCS\BGRunner; // 引入 BGRunner 类

// --- 初始化 BGRunner ---
$bg_runner = BGRunner::instance();

// --- 定义你的后台任务回调函数 ---
/**
 * 模拟一个后台数据处理任务
 * @param int $current_iteration 当前迭代次数 (从0开始)
 * @param int $total_iterations  本次任务总共需要迭代的次数
 * @return bool 成功返回 true, 失败返回 false
 */
function my_custom_data_processing_task($current_iteration, $total_iterations) {
    // 模拟每次处理需要 0.5 秒
    usleep(500000); // 500,000 微秒 = 0.5 秒

    // 模拟每处理 10 个数据项,有 1 个会失败
    if (($current_iteration + 1) % 10 === 0) {
        error_log("BGRunner Example: Task 'my_data_processor' failed for item " . ($current_iteration + 1));
        return false; // 返回 false 表示本次迭代失败
    }

    error_log("BGRunner Example: Task 'my_data_processor' processed item " . ($current_iteration + 1) . " of " . $total_iterations);
    return true; // 返回 true 表示本次迭代成功
}

// --- 注册任务 ---
// 将 'my_data_processor' 这个类型字符串映射到我们定义的回调函数
$bg_runner->set_callback('my_data_processor', 'my_custom_data_processing_task');

// --- 创建后台管理页面 ---
add_action('admin_menu', function() {
    add_menu_page(
        'BG Runner Example',        // 页面标题
        'BG Runner',                // 菜单标题
        'manage_options',           // 所需权限
        'bg-runner-example',        // 菜单 slug
        'my_bg_runner_dashboard_page', // 渲染页面的回调函数
        'dashicons-clock',          // 图标
        80                          // 菜单位置
    );
});

// 渲染管理页面的回调函数
function my_bg_runner_dashboard_page() {
    require_once plugin_dir_path(__FILE__) . 'admin/dashboard.php';
}

// --- 加载管理页面所需的 JS 和 AJAX 设置 ---
add_action('admin_enqueue_scripts', function($hook) {
    // 只在我们的插件页面加载脚本
    if ('toplevel_page_bg-runner-example' !== $hook) {
        return;
    }

    wp_enqueue_script('bg-runner-admin-script', plugin_dir_url(__FILE__) . 'assets/js/admin-script.js', ['jquery'], '1.0', true);

    // 将 AJAX URL 和安全 Nonce 传递给 JavaScript
    wp_localize_script('bg-runner-admin-script', 'bgRunnerAjax', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce'    => wp_create_nonce('bg_runner_ajax_nonce'), // 安全令牌
    ]);
});

// --- AJAX 请求处理器 ---
// 注册 AJAX handlers
add_action('wp_ajax_bg_runner_start_task', 'handle_ajax_start_task');
add_action('wp_ajax_bg_runner_pause_task', 'handle_ajax_pause_task');
add_action('wp_ajax_bg_runner_resume_task', 'handle_ajax_resume_task');
add_action('wp_ajax_bg_runner_stop_task', 'handle_ajax_stop_task');
add_action('wp_ajax_bg_runner_get_status', 'handle_ajax_get_status');

// 处理启动任务的 AJAX 请求
function handle_ajax_start_task() {
    check_ajax_referer('bg_runner_ajax_nonce', 'nonce'); // 验证安全 Nonce
    $task_type = isset($_POST['task_type']) ? sanitize_key($_POST['task_type']) : '';
    $total_iterations = isset($_POST['iterations']) ? intval($_POST['iterations']) : 0;

    if (empty($task_type) || $total_iterations <= 0) {
        wp_send_json_error('无效的参数。');
    }

    try {
        BGRunner::instance()->start($task_type, $total_iterations);
        wp_send_json_success('任务已启动!');
    } catch (\Exception $e) {
        wp_send_json_error('启动任务失败: ' . $e->getMessage());
    }
}

// 处理暂停任务的 AJAX 请求
function handle_ajax_pause_task() {
    check_ajax_referer('bg_runner_ajax_nonce', 'nonce');
    $task_type = isset($_POST['task_type']) ? sanitize_key($_POST['task_type']) : '';
    if (empty($task_type)) wp_send_json_error('无效的任务类型。');
    try {
        BGRunner::instance()->pause($task_type);
        wp_send_json_success('暂停请求已发送。');
    } catch (\Exception $e) {
        wp_send_json_error('暂停任务失败: ' . $e->getMessage());
    }
}

// 处理恢复任务的 AJAX 请求
function handle_ajax_resume_task() {
    check_ajax_referer('bg_runner_ajax_nonce', 'nonce');
    $task_type = isset($_POST['task_type']) ? sanitize_key($_POST['task_type']) : '';
    if (empty($task_type)) wp_send_json_error('无效的任务类型。');
    try {
        BGRunner::instance()->resume($task_type);
        wp_send_json_success('恢复请求已发送。');
    } catch (\Exception $e) {
        wp_send_json_error('恢复任务失败: ' . $e->getMessage());
    }
}

// 处理停止任务的 AJAX 请求
function handle_ajax_stop_task() {
    check_ajax_referer('bg_runner_ajax_nonce', 'nonce');
    $task_type = isset($_POST['task_type']) ? sanitize_key($_POST['task_type']) : '';
    if (empty($task_type)) wp_send_json_error('无效的任务类型。');
    try {
        BGRunner::instance()->stop($task_type);
        wp_send_json_success('停止请求已发送。');
    } catch (\Exception $e) {
        wp_send_json_error('停止任务失败: ' . $e->getMessage());
    }
}

// 处理获取所有任务状态的 AJAX 请求
function handle_ajax_get_status() {
    check_ajax_referer('bg_runner_ajax_nonce', 'nonce');
    try {
        $statuses = BGRunner::instance()->all_statuses();
        wp_send_json_success($statuses);
    } catch (\Exception $e) {
        wp_send_json_error('获取状态失败: ' . $e->getMessage());
    }
}

步骤 2: BGRunner 类 (includes/BGRunner.php)

请将您提供的 BGRunner 类代码保存在此文件中。确保命名空间 Dudlewebs\WPMCS 正确。

步骤 3: 管理页面 HTML (admin/dashboard.php)

<?php defined('ABSPATH') || exit; ?>
<div class="wrap">
    <h1>BG Runner Example Dashboard</h1>

    <div id="task-controls" style="margin-bottom: 20px; border: 1px solid #ccc; padding: 15px;">
        <h2>任务控制面板</h2>
        <label for="task-type">选择任务:</label>
        <select id="task-type">
            <option value="my_data_processor">模拟数据处理器</option>
            <!-- 可以添加更多注册的任务类型 -->
        </select>
        <br><br>
        <label for="total-iterations">总迭代次数:</label>
        <input type="number" id="total-iterations" value="100" min="1" style="width: 80px;">
        <p><small>决定任务将尝试处理多少个项目。</small></p>
        <br>
        <button id="start-task-btn">启动任务</button>
        <button id="pause-task-btn" disabled>暂停任务</button>
        <button id="resume-task-btn" disabled>恢复任务</button>
        <button id="stop-task-btn" disabled>停止任务</button>
        <button id="force-reset-btn" style="background-color: #f0ad4e; color: white; border: none; padding: 5px 10px; cursor: pointer;">强制重置锁</button>
        <p><small><strong>注意:</strong> 任务执行依赖 WP-Cron。如果网站长时间无人访问,任务可能需要几分钟才能开始。</small></p>
    </div>

    <div id="task-status-display" style="border: 1px solid #eee; background-color: #f9f9f9; padding: 15px;">
        <h2>任务状态</h2>
        <pre id="status-output" style="white-space: pre-wrap; word-wrap: break-word; font-family: monospace;"></pre>
    </div>
</div>

步骤 4: 管理页面 JavaScript (assets/js/admin-script.js)

jQuery(document).ready(function($) {
    var $taskTypeSelect = $('#task-type');
    var $iterationsInput = $('#total-iterations');
    var $startBtn = $('#start-task-btn');
    var $pauseBtn = $('#pause-task-btn');
    var $resumeBtn = $('#resume-task-btn');
    var $stopBtn = $('#stop-task-btn');
    var $forceResetBtn = $('#force-reset-btn');
    var $statusOutput = $('#status-output');

    var taskStatusInterval = null; // 用于管理状态刷新定时器

    // --- 更新按钮状态 ---
    function updateButtonStates(taskStatus) {
        var isRunning = taskStatus && taskStatus.status === 'running';
        var isPaused = taskStatus && taskStatus.status === 'paused';
        var isStoppedOrCompleted = taskStatus && (taskStatus.status === 'stopped' || taskStatus.status === 'completed');
        var isIdle = !isRunning && !isPaused && !isStoppedOrCompleted; // 任务未运行或暂停

        $startBtn.prop('disabled', !isIdle); // 仅在空闲时可启动
        $pauseBtn.prop('disabled', !isRunning); // 仅在运行时可暂停
        $resumeBtn.prop('disabled', !isPaused); // 仅在暂停时可恢复
        $stopBtn.prop('disabled', !(isRunning || isPaused)); // 运行时或暂停时可停止

        // 如果任务已完成或停止,确保按钮显示为可启动状态
        if (isStoppedOrCompleted && taskStatus.percentage === 100) {
             $startBtn.prop('disabled', false);
        }
    }

    // --- 获取并显示任务状态 ---
    function refreshStatus() {
        $.post(bgRunnerAjax.ajax_url, {
            action: 'bg_runner_get_status',
            nonce: bgRunnerAjax.nonce
        }, function(response) {
            if (response.success) {
                var allStatuses = response.data;
                var selectedTaskType = $taskTypeSelect.val();
                // 获取当前选中任务的状态,如果不存在则提供默认值
                var currentTaskStatus = allStatuses[selectedTaskType] || {
                    status: 'stopped', percentage: 0, processed: 0, total: 0, remaining: 0, failed: 0,
                    last_run: 0, pause_requested: false, stop_requested: false
                };

                // 更新页面显示
                var display = `任务类型: ${selectedTaskType}\n`;
                display += `状态: ${currentTaskStatus.status}\n`;
                display += `进度: ${currentTaskStatus.percentage}%\n`;
                display += `已处理: ${currentTaskStatus.processed} / ${currentTaskStatus.total}\n`;
                display += `剩余: ${currentTaskStatus.remaining}\n`;
                display += `失败数: ${currentTaskStatus.failed}\n`;
                display += `上次运行: ${currentTaskStatus.last_run ? new Date(currentTaskStatus.last_run * 1000).toLocaleString() : '从未'}\n`;
                display += `暂停请求: ${currentTaskStatus.pause_requested ? '是' : '否'}\n`;
                display += `停止请求: ${currentTaskStatus.stop_requested ? '是' : '否'}\n`;

                $statusOutput.text(display);

                // 根据任务状态更新按钮可用性
                updateButtonStates(currentTaskStatus);

                // 如果任务正在运行或暂停,启动自动刷新;如果已完成或停止,则停止刷新
                if (taskStatusInterval === null && (currentTaskStatus.status === 'running' || currentTaskStatus.status === 'paused')) {
                    startStatusInterval();
                } else if (taskStatusInterval !== null && (currentTaskStatus.status === 'stopped' || currentTaskStatus.status === 'completed')) {
                    stopStatusInterval();
                }

            } else {
                $statusOutput.text('获取状态错误: ' + (response.data || '未知错误'));
                stopStatusInterval(); // 发生错误时停止刷新
            }
        });
    }

    // 启动状态自动刷新
    function startStatusInterval() {
        if (taskStatusInterval === null) {
            taskStatusInterval = setInterval(refreshStatus, 3000); // 每 3 秒刷新一次
        }
    }

    // 停止状态自动刷新
    function stopStatusInterval() {
        if (taskStatusInterval !== null) {
            clearInterval(taskStatusInterval);
            taskStatusInterval = null;
        }
    }

    // --- 按钮点击事件处理 ---
    $startBtn.on('click', function() {
        var taskType = $taskTypeSelect.val();
        var iterations = parseInt($iterationsInput.val(), 10);
        if (!taskType || iterations <= 0) { alert('请选择任务类型并输入有效的迭代次数。'); return; }

        $.post(bgRunnerAjax.ajax_url, { action: 'bg_runner_start_task', nonce: bgRunnerAjax.nonce, task_type: taskType, iterations: iterations }, function(response) {
            if (response.success) { alert('任务启动请求已发送!'); refreshStatus(); startStatusInterval(); }
            else { alert('发送任务启动请求失败: ' + response.data); }
        });
    });

    $pauseBtn.on('click', function() {
        var taskType = $taskTypeSelect.val();
        if (!taskType) return;
        $.post(bgRunnerAjax.ajax_url, { action: 'bg_runner_pause_task', nonce: bgRunnerAjax.nonce, task_type: taskType }, function(response) {
            if (response.success) { alert('暂停请求已发送。'); refreshStatus(); }
            else { alert('发送暂停请求失败: ' + response.data); }
        });
    });

    $resumeBtn.on('click', function() {
        var taskType = $taskTypeSelect.val();
        if (!taskType) return;
        $.post(bgRunnerAjax.ajax_url, { action: 'bg_runner_resume_task', nonce: bgRunnerAjax.nonce, task_type: taskType }, function(response) {
            if (response.success) { alert('恢复请求已发送。'); refreshStatus(); startStatusInterval(); }
            else { alert('发送恢复请求失败: ' + response.data); }
        });
    });

    $stopBtn.on('click', function() {
        var taskType = $taskTypeSelect.val();
        if (!taskType) return;
        $.post(bgRunnerAjax.ajax_url, { action: 'bg_runner_stop_task', nonce: bgRunnerAjax.nonce, task_type: taskType }, function(response) {
            if (response.success) { alert('停止请求已发送。'); refreshStatus(); stopStatusInterval(); }
            else { alert('发送停止请求失败: ' + response.data); }
        });
    });

    // 强制重置锁按钮
    $forceResetBtn.on('click', function() {
        if (!confirm('确定要强制重置所有 BG Runner 锁和状态吗?这通常只在调试时需要。')) return;
        // 构造带参数的 URL 并重新加载页面
        var url = window.location.href;
        url = url.includes('?') ? url + '&force_reset_sync=1' : url + '?force_reset_sync=1';
        window.location.href = url;
    });

    // --- 页面加载时初始化 ---
    refreshStatus(); // 首次加载页面时获取状态
});

如何使用这个示例:

  1. 创建文件结构: 按照上面的 my-bg-plugin 文件夹和子目录结构,放置上述四个文件的代码。
  2. 激活插件: 将 my-bg-plugin 文件夹上传到你的 WordPress 站点 wp-content/plugins/ 目录下,然后在后台激活 “BG Runner Example Plugin”。
  3. 访问管理页面: 在 WordPress 后台菜单中,找到并点击 “BG Runner” -> “BG Runner Example Dashboard”。
  4. 开始任务:
    • 选择任务类型(默认为 “模拟数据处理器”)。
    • 输入总迭代次数(例如 50)。
    • 点击 “启动任务” 按钮。
  5. 观察状态:
    • 页面上的状态信息会每隔几秒更新一次。
    • 你将看到任务状态从 running 变为 completed,进度百分比增加,已处理计数上升。
    • 由于模拟了失败,你还会看到 Failed 计数。
  6. 测试控制:
    • 暂停与恢复: 启动任务后,在进度增加时点击 “暂停任务”。任务会暂停(状态变为 paused)。然后点击 “恢复任务”,任务会继续执行。
    • 停止任务: 启动任务后,点击 “停止任务”。任务会立即中断(即使回调函数还在执行中),状态变为 stopped,进度不会达到 100%。
    • 强制重置: 如果任务卡住或出现问题,点击 “强制重置锁” 按钮来清除所有 BGRunner 的状态和锁,让任务可以重新开始。

深入理解 BGRunner 的工作流程

  1. 启动 (start):
    • 通过 AJAX 调用 BGRunner::instance()->start('my_data_processor', 100)。
    • BGRunner 将 state['my_data_processor']['status'] 设置为 'running',并保存状态。
  2. WP-Cron 触发 (run_all):
    • 每分钟,WordPress 访问时触发 run_all()。
    • run_all() 发现 'my_data_processor' 的状态是 'running'。
    • 它检查锁,如果没被锁,则加锁 (lock())。
    • 调用 process_iterations() 来执行实际任务。
  3. 执行与控制 (process_iterations):
    • process_iterations 进入一个 while 循环,每次最多执行 $max_per_run (例如 20) 次迭代,或直到达到资源限制。
    • 关键点: 在每次迭代开始时,都会重新读取控制状态 (get_transient($this->control_transient_key))。
    • 如果 stop_requested 或 pause_requested 为 true:
      • while 循环会 立即 break;。这意味着当前这批迭代中,尚未执行的任务回调函数将不会被执行。
      • process_iterations 的收尾逻辑会根据请求设置最终状态('paused' 或 'stopped'),并重置控制标志。
    • 如果请求标志为 false:
      • 任务回调函数 (my_custom_data_processing_task) 被执行。
      • 进度 (iterations_done) 和失败数 (failed_count) 被更新并保存。
      • 循环继续,直到满足退出条件。
  4. 状态更新与后续触发:
    • process_iterations 结束后,run_all 会解锁任务并保存最终状态。
    • 如果状态是 'stopped': 后续的 WP-Cron 触发,run_all 会发现状态不是 'running',并跳过 process_iterations。
    • 如果状态是 'paused': 后续的 WP-Cron 触发,run_all 同样会发现状态不是 'running',并跳过 process_iterations。只有在调用 resume() 后,状态才会被改回 'running',run_all 才会再次调用 process_iterations,并且 process_iterations 会从中断的地方继续执行。

总结与最佳实践

  • WP-Cron 的基础: WP-Cron 是 WordPress 后台任务的基石,但其可靠性和精确性有限。
  • BGRunner 的价值: 它提供了一个结构化的框架,通过状态管理、批处理、锁定和控制接口,极大地增强了后台任务的可管理性和可靠性。
  • AJAX 的作用: AJAX 是连接用户界面和后台任务处理逻辑的桥梁,用于发送控制命令和获取实时状态。
  • 可靠性增强: 对于生产环境,强烈建议禁用内置 WP-Cron (define('DISABLE_WP_CRON', true); 添加到 wp-config.php),并使用服务器的系统 Cron job 来定时触发 wp-cron.php。
  • 错误处理与日志: 务必在你的任务回调函数中加入详细的错误日志记录 (error_log()),这对于调试至关重要。
  • 资源管理: 始终关注 PHP 的 max_execution_time 和 memory_limit,BGRunner 在这方面提供了基础支持,但复杂的任务可能需要更精细的设计。

通过这种方式,你可以构建出既强大又用户友好的 WordPress 插件,有效地处理各种耗时且复杂的后台任务。

Total
0
Shares
Share 0
Tweet 0
Pin it 0
Brandon

Previous Article
  • Code

WordPress image offload

  • December 14, 2025
  • Brandon
View Post
You May Also Like
View Post
  • Code

WordPress image offload

  • Brandon
  • December 14, 2025
View Post
  • Code

ComfyUI应用手册

  • Brandon
  • December 6, 2025
View Post
  • Code

Leetcode Java常用代码

  • Brandon
  • February 17, 2024
View Post
  • Code

Golang入门

  • Brandon
  • February 4, 2024
View Post
  • Code

Setting Up and Maintaining a Ubuntu Environment for My Home Server

  • Brandon
  • November 24, 2023
View Post
  • Code

Swift Learning Log

  • Brandon
  • August 31, 2023
View Post
  • Code

English Learning – Food Related

  • Brandon
  • August 31, 2023
View Post
  • Code

Emacs for Java

  • Brandon
  • January 16, 2023

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Fullstar

Input your search keywords and press Enter.