PHP 获取 Minecraft 服务器状态:原理、实践与优化全攻略142

```html

作为一名专业的程序员,我们经常会遇到将服务器后端数据实时呈现在前端页面的需求。对于广受欢迎的沙盒游戏 Minecraft 来说,其服务器状态信息——例如当前在线人数、最大玩家容量、MOTD(Message of the Day)、服务器版本等——是许多社区网站、服务器列表或监控面板不可或缺的功能。本文将深入探讨如何使用 PHP 获取 Minecraft 服务器的实时信息,从底层的协议原理到实际的代码实现,再到性能优化和最佳实践,为您提供一份详尽的指南。

一、理解 Minecraft 服务器查询协议

Minecraft 服务器提供多种协议供客户端和第三方工具与其交互,其中最常用于获取公开服务器状态信息的是以下两种:

1. Minecraft Query Protocol (UDP)


这是专门为第三方工具设计的查询协议,通常通过 UDP 端口进行通信。它能提供相对详细的服务器信息,包括:
MOTD (服务器欢迎信息)
在线玩家数与最大玩家数
游戏模式 (例如生存、创造)
服务器版本
插件列表 (如果服务器允许公开)
部分服务器变量 (例如 'hostip', 'hostport' 等)

要启用此协议,Minecraft 服务器的 `` 文件中需要设置 `enable-query=true`,并且通常使用与游戏端口不同的 `` (默认为 25565,但可以自定义)。

2. Server List Ping Protocol (TCP)


这是游戏客户端在添加服务器到列表时使用的协议,通过 TCP 端口进行通信 (默认为 25565)。它提供的信息相对较少,但足以满足大部分基础需求,包括:
MOTD (JSON 格式,支持颜色和格式化)
在线玩家数与最大玩家数
服务器版本信息 (协议版本号和显示名称)
服务器图标 (Base64 编码的 Favicon)

这种协议更为轻量和快速,因为它与游戏客户端直接交互,无需额外的配置。

选择哪个协议?
如果需要获取详细的玩家列表、插件信息等,或者服务器已明确开启 Query 端口,则优先考虑 Minecraft Query Protocol。
如果只需要基础的在线人数、MOTD、版本,且追求简单快速,则 Server List Ping Protocol 更合适。
在 PHP 中,通常会选择实现 Query Protocol,因为它提供了更丰富的数据。但为了兼容性和速度,也可以同时实现或选择 Server List Ping。

二、PHP 实现 Minecraft Query Protocol

我们将以实现 Minecraft Query Protocol 为例,详细介绍其 PHP 代码实现。在此之前,请确保您的 Minecraft 服务器已在 `` 中设置 `enable-query=true`。

1. 协议原理简述


Query Protocol 的通信过程大致如下:
握手 (Handshake): 客户端发送一个握手包到服务器,请求一个挑战令牌 (Challenge Token)。
获取挑战令牌: 服务器回复一个挑战令牌。
全状态请求 (Full Status Request): 客户端使用获取到的挑战令牌,发送一个包含挑战令牌的全状态请求包。
接收状态信息: 服务器回复包含所有查询信息的完整数据包。

所有的数据包都以 `0xFEFD` (Magic Bytes) 开头,后面跟着包类型和会话 ID。会话 ID 可以是任意的,但需要保持一致。

2. PHP 代码实现


为了更好地组织代码,我们可以创建一个 `MinecraftQuery` 类来封装这些功能。<?php
class MinecraftQuery
{
const HANDSHAKE = 0x09;
const STATUS = 0x00;
private $Socket;
private $ChallengeToken;
private $ServerAddress;
private $ServerPort;
private $Timeout;
public function __construct(string $address, int $port = 25565, int $timeout = 3)
{
$this->ServerAddress = $address;
$this->ServerPort = $port;
$this->Timeout = $timeout;
}
private function connect(): void
{
$this->Socket = @fsockopen('udp://' . $this->ServerAddress, $this->ServerPort, $errno, $errstr, $this->Timeout);
if (!$this->Socket) {
throw new Exception("无法连接到服务器: $errstr ($errno)");
}
stream_set_timeout($this->Socket, $this->Timeout);
stream_set_blocking($this->Socket, true);
}
private function disconnect(): void
{
if ($this->Socket) {
fclose($this->Socket);
$this->Socket = null;
}
}
private function writePacket(int $type, string $payload = ''): void
{
$sessionId = 0x01010101; // 任意会话 ID
$packet = pack('cccc', 0xFE, 0xFD, $type, ($sessionId >> 24) & 0xFF);
$packet .= pack('ccc', ($sessionId >> 16) & 0xFF, ($sessionId >> 8) & 0xFF, $sessionId & 0xFF);
$packet .= $payload;
fwrite($this->Socket, $packet);
}
private function readPacket(): string
{
$buffer = fread($this->Socket, 4096); // 读取最大 4KB 数据
if (empty($buffer)) {
$info = stream_get_meta_data($this->Socket);
if ($info['timed_out']) {
throw new Exception("服务器响应超时。");
}
throw new Exception("从服务器读取数据失败。");
}
// 检查 Magic Bytes
if (substr($buffer, 0, 2) !== "\xFE\xFD") {
throw new Exception("无效的 Minecraft Query 响应。");
}
// 丢弃 Magic Bytes 和类型字节 (共3字节)
return substr($buffer, 3);
}
private function getChallengeToken(): string
{
$this->writePacket(self::HANDSHAKE);
$response = $this->readPacket();
// 挑战令牌是 ASCII 数字字符串
return substr($response, 0, -1); // 移除末尾的换行符
}
public function getStatus(): array
{
$this->connect();
try {
$this->ChallengeToken = $this->getChallengeToken();
// 构造全状态请求的 payload
$sessionId = 0x01010101;
$payload = pack('N', (int)$this->ChallengeToken); // 挑战令牌作为整数

$this->writePacket(self::STATUS, $payload);
$response = $this->readPacket();
} finally {
$this->disconnect();
}
// 解析响应数据
// 响应数据格式复杂,通常是键值对列表,用特定字节分隔
$data = [];
$parts = explode("\x00\x00\x00", $response, 2); // 分隔基本信息和玩家列表

if (count($parts) < 2) {
throw new Exception("无法解析服务器状态响应。");
}
$infoPart = $parts[0];
$playersPart = $parts[1];
// 解析基本信息
$infoPairs = explode("\x00", substr($infoPart, 1)); // 移除开头的类型字节
for ($i = 0; $i < count($infoPairs) - 1; $i += 2) {
$key = $infoPairs[$i];
$value = $infoPairs[$i + 1];
$data[$key] = $value;
}
// 解析玩家列表
if (isset($data['players'])) {
$playerList = explode("\x00", substr($playersPart, 1, -2)); // 移除开头的 'player_' 和末尾的 \x00\x00
$data['players'] = array_filter($playerList); // 过滤空字符串
} else {
$data['players'] = [];
}
// 格式化数据,例如在线人数等
if (isset($data['numplayers'])) {
$data['numplayers'] = (int)$data['numplayers'];
}
if (isset($data['maxplayers'])) {
$data['maxplayers'] = (int)$data['maxplayers'];
}
return $data;
}
}
// --- 使用示例 ---
try {
$query = new MinecraftQuery('localhost', 25565); // 替换为您的服务器地址和Query端口
$status = $query->getStatus();
echo "<h2>Minecraft 服务器状态:</h2>";
echo "<p>MOTD: " . htmlspecialchars($status['motd']) . "</p>";
echo "<p>在线玩家: " . htmlspecialchars($status['numplayers']) . " / " . htmlspecialchars($status['maxplayers']) . "</p>";
echo "<p>版本: " . htmlspecialchars($status['version']) . "</p>";
echo "<p>游戏模式: " . htmlspecialchars($status['gametype']) . "</p>";

if (!empty($status['players'])) {
echo "<p>在线玩家列表: " . implode(', ', array_map('htmlspecialchars', $status['players'])) . "</p>";
} else {
echo "<p>当前无玩家在线。</p>";
}
// 打印所有原始数据
echo "<h3>原始数据:</h3><pre>";
print_r($status);
echo "</pre>";
} catch (Exception $e) {
echo "<h2>获取服务器状态失败:</h2>";
echo "<p style='color: red;'>错误: " . htmlspecialchars($e->getMessage()) . "</p>";
}
?>

代码解释:
`connect()` 和 `disconnect()`:处理 UDP socket 的建立和关闭,使用 `fsockopen` 函数。
`writePacket()`:构建并发送数据包。`pack('cccc', ...)` 用于将字节数据打包成二进制字符串,其中 `0xFEFD` 是 Minecraft Query Protocol 的魔术字节。会话 ID 随意选择,但要保持发送和接收时一致(虽然在此协议中服务器不会验证它)。
`readPacket()`:从 socket 读取服务器响应。它会检查魔术字节以确保响应有效,并处理超时。
`getChallengeToken()`:发送握手包,获取服务器返回的挑战令牌。
`getStatus()`:核心方法,首先获取挑战令牌,然后构造并发送全状态请求包。
数据解析: 服务器返回的全状态数据结构比较特殊,是多个键值对通过 `\x00` 分隔,最后以 `\x00\x00\x00` 分隔基本信息和玩家列表。玩家列表本身也以 `\x00` 分隔。代码中通过 `explode("\x00\x00\x00", ...)` 和 `explode("\x00", ...)` 来逐级解析这些数据。
错误处理: 使用 `try-catch` 块捕获连接失败、超时或数据解析错误。

三、PHP 实现 Server List Ping Protocol (简化)

Server List Ping 协议相对简单,主要通过 TCP 连接发送特定字节流并接收 JSON 响应。这里提供一个简化的实现思路。<?php
// ... (MinecraftQuery class from above, or remove it for simplicity if only Ping is needed) ...
class MinecraftPing
{
private $Socket;
private $ServerAddress;
private $ServerPort;
private $Timeout;
public function __construct(string $address, int $port = 25565, int $timeout = 3)
{
$this->ServerAddress = $address;
$this->ServerPort = $port;
$this->Timeout = $timeout;
}
private function connect(): void
{
$this->Socket = @fsockopen('tcp://' . $this->ServerAddress, $this->ServerPort, $errno, $errstr, $this->Timeout);
if (!$this->Socket) {
throw new Exception("无法连接到服务器: $errstr ($errno)");
}
stream_set_timeout($this->Socket, $this->Timeout);
stream_set_blocking($this->Socket, true);
}
private function disconnect(): void
{
if ($this->Socket) {
fclose($this->Socket);
$this->Socket = null;
}
}
private function writeVarInt(int $value): string
{
$buffer = '';
do {
$temp = $value & 0b01111111;
$value >>>= 7; // 无符号右移
if ($value !== 0) {
$temp |= 0b10000000;
}
$buffer .= chr($temp);
} while ($value !== 0);
return $buffer;
}
private function readVarInt(): int
{
$numRead = 0;
$result = 0;
do {
if ($numRead >= 5) { // VarInt 最大 5 字节
throw new Exception("VarInt 太长");
}
$byte = ord(fread($this->Socket, 1));
$value = ($byte & 0b01111111);
$result |= ($value << (7 * $numRead));
$numRead++;
} while (($byte & 0b10000000) !== 0);
return $result;
}
private function readString(int $length): string
{
$str = '';
while (strlen($str) < $length) {
$data = fread($this->Socket, $length - strlen($str));
if ($data === false || $data === '') {
throw new Exception("从服务器读取字符串失败。");
}
$str .= $data;
}
return $str;
}
public function getStatus(): array
{
$this->connect();
try {
// 1. Handshake Packet (Packet ID 0x00)
// Protocol Version (-1 for V47 ping), Server Address, Server Port, Next State (1 for status)
$handshakeData = $this->writeVarInt(-1) . $this->writeVarInt(strlen($this->ServerAddress)) . $this->ServerAddress . pack('n', $this->ServerPort) . $this->writeVarInt(1);
$handshakePacket = $this->writeVarInt(strlen($handshakeData) + 1) . $this->writeVarInt(0x00) . $handshakeData;
fwrite($this->Socket, $handshakePacket);
// 2. Status Request Packet (Packet ID 0x00)
$requestPacket = $this->writeVarInt(1) . $this->writeVarInt(0x00); // Packet length + Packet ID
fwrite($this->Socket, $requestPacket);
// 3. Read Response
$length = $this->readVarInt(); // Total response packet length
$packetId = $this->readVarInt(); // Packet ID (should be 0x00)
if ($packetId !== 0x00) {
throw new Exception("Ping 响应包 ID 错误。");
}
$jsonLength = $this->readVarInt(); // Length of the JSON string
$jsonString = $this->readString($jsonLength);
$data = json_decode($jsonString, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("JSON 解析失败: " . json_last_error_msg());
}
return $data;
} finally {
$this->disconnect();
}
}
}
// --- 使用示例 ---
echo "<h2>Minecraft Server List Ping 状态:</h2>";
try {
$ping = new MinecraftPing('localhost', 25565); // 替换为您的服务器地址和游戏端口
$status = $ping->getStatus();
if (isset($status['description']['text'])) {
echo "<p>MOTD: " . htmlspecialchars($status['description']['text']) . "</p>";
} elseif (isset($status['description'])) {
// 如果是 JSON 对象,例如 [{"text":"Hello"}, {"text":"World"}]
$motdText = '';
if (is_array($status['description'])) {
foreach ($status['description'] as $part) {
if (isset($part['text'])) {
$motdText .= $part['text'];
}
}
} elseif (is_string($status['description'])) {
$motdText = $status['description'];
}
echo "<p>MOTD: " . htmlspecialchars($motdText) . "</p>";
}
if (isset($status['players'])) {
echo "<p>在线玩家: " . htmlspecialchars($status['players']['online']) . " / " . htmlspecialchars($status['players']['max']) . "</p>";
}
if (isset($status['version']['name'])) {
echo "<p>版本: " . htmlspecialchars($status['version']['name']) . "</p>";
}
if (isset($status['favicon'])) {
echo "<p><img src='" . htmlspecialchars($status['favicon']) . "' alt='Favicon' style='width: 32px; height: 32px;'></p>";
}
// 打印所有原始数据
echo "<h3>原始数据:</h3><pre>";
print_r($status);
echo "</pre>";
} catch (Exception $e) {
echo "<h2>获取服务器 Ping 状态失败:</h2>";
echo "<p style='color: red;'>错误: " . htmlspecialchars($e->getMessage()) . "</p>";
}
?>

代码解释:
`writeVarInt()` 和 `readVarInt()`:Minecraft 协议中大量使用 VarInt (Variable-length Integer) 编码。这些函数用于将整数编码为可变长度的字节序列,或从字节序列解码出整数。
`getStatus()`:

Handshake Packet (握手包): 客户端首先发送一个握手包,告知服务器协议版本、服务器地址、端口以及下一个状态(这里是 `status`)。
Status Request Packet (状态请求包): 接着发送一个简单的状态请求包。
Read Response (读取响应): 服务器会返回一个包含 VarInt 编码的 JSON 字符串长度,然后是实际的 JSON 字符串。
JSON 解析: 使用 `json_decode()` 将响应解析为 PHP 数组。



四、最佳实践与性能优化

直接在每次页面加载时查询 Minecraft 服务器是非常低效的,因为它涉及到网络请求和服务器端的处理。对于高流量的网站,这可能导致页面加载缓慢,甚至可能对 Minecraft 服务器造成不必要的压力。以下是一些最佳实践和优化策略:

1. 强大的库支持:推荐使用 xPaw/MinecraftQuery


在生产环境中,强烈建议使用经过良好测试和维护的第三方库,而不是自己从零开始实现。 是一个非常流行的选择,它完美地封装了 Minecraft Query Protocol 和 Server List Ping Protocol,并提供了更好的错误处理和兼容性。

安装方式 (Composer):composer require xpaw/php-minecraft-query

使用示例:<?php
require __DIR__ . '/vendor/';
use xPaw\MinecraftQuery;
use xPaw\MinecraftQueryException;
try
{
$Query = new MinecraftQuery();
$Query->Connect('', 25565, 3, MinecraftQuery::QUERY); // 使用 QUERY 协议
// 或者使用 PING 协议
// $Query->Connect('', 25565, 3, MinecraftQuery::PING);
echo '<pre>';
print_r($Query->GetInfo());
print_r($Query->GetPlayers());
echo '</pre>';
}
catch (MinecraftQueryException $e)
{
echo '<pre>';
print_r($e->getMessage());
echo '</pre>';
}
?>

2. 缓存机制


这是最重要的优化措施。将查询结果缓存一段时间(例如 30 秒到 5 分钟),可以大大减少对 Minecraft 服务器的请求次数。
文件缓存: 将查询结果序列化后存储到文件中。简单易实现。
内存缓存: 使用 Memcached 或 Redis 等内存数据库。性能更佳,适合分布式环境。
数据库缓存: 将结果存储到 MySQL 等数据库中。适合需要持久化历史数据的场景。

缓存逻辑示例:<?php
// 假设您已经集成了 xPaw/PHP-Minecraft-Query
$serverAddress = '';
$serverPort = 25565;
$cacheKey = md5($serverAddress . ':' . $serverPort);
$cacheFile = 'cache/' . $cacheKey . '.json';
$cacheDuration = 60; // 缓存 60 秒
$status = null;
// 尝试从缓存中读取
if (file_exists($cacheFile) && (filemtime($cacheFile) + $cacheDuration > time())) {
$cachedData = file_get_contents($cacheFile);
$status = json_decode($cachedData, true);
// echo "<p>数据来自缓存。</p>";
}
// 如果缓存不存在或已过期,则进行实时查询
if ($status === null) {
try {
require __DIR__ . '/vendor/';
use xPaw\MinecraftQuery;
use xPaw\MinecraftQueryException;
$Query = new MinecraftQuery();
$Query->Connect($serverAddress, $serverPort, 3, MinecraftQuery::QUERY);

$info = $Query->GetInfo();
$players = $Query->GetPlayers();

$status = [
'info' => $info,
'players' => $players,
'timestamp' => time()
];
// 将结果写入缓存
file_put_contents($cacheFile, json_encode($status));
// echo "<p>数据实时查询并已缓存。</p>";
} catch (MinecraftQueryException $e) {
// 如果查询失败,可以尝试读取旧的缓存数据,或者显示错误信息
if (file_exists($cacheFile)) {
$cachedData = file_get_contents($cacheFile);
$status = json_decode($cachedData, true);
// echo "<p>实时查询失败,显示旧的缓存数据。</p>";
} else {
$status = ['error' => $e->getMessage()];
// echo "<p style='color: red;'>错误: " . htmlspecialchars($e->getMessage()) . "</p>";
}
}
}
// 显示状态
if (isset($status['error'])) {
echo "<p style='color: red;'>获取服务器状态失败: " . htmlspecialchars($status['error']) . "</p>";
} elseif (isset($status['info'])) {
echo "<h2>Minecraft 服务器状态:</h2>";
echo "<p>MOTD: " . htmlspecialchars($status['info']['HostName']) . "</p>";
echo "<p>在线玩家: " . htmlspecialchars($status['info']['Players']) . " / " . htmlspecialchars($status['info']['MaxPlayers']) . "</p>";
echo "<p>版本: " . htmlspecialchars($status['info']['Version']) . "</p>";
if (!empty($status['players'])) {
echo "<p>在线玩家列表: " . implode(', ', array_map(function($player){ return htmlspecialchars($player['Name']); }, $status['players'])) . "</p>";
} else {
echo "<p>当前无玩家在线。</p>";
}
echo "<p>上次更新时间: " . date('Y-m-d H:i:s', $status['timestamp']) . "</p>";
} else {
echo "<p>无法获取服务器状态。</p>";
}
?>

3. 异步加载 (AJAX)


为了提供更好的用户体验,可以将服务器状态的获取和显示设计为异步加载。页面加载时先显示一个占位符,然后通过 JavaScript 发送 AJAX 请求到后端 PHP 脚本,该脚本负责查询和缓存逻辑,并将结果返回给前端,再由前端动态更新页面。这样可以避免阻塞页面渲染。

4. 错误处理与超时


网络请求总是可能失败。设置合理的超时时间 (`fsockopen` 的 `timeout` 参数) 是非常重要的。同时,对所有可能的异常和错误进行捕获和优雅处理,例如显示“服务器离线”而不是一个空白页面或 PHP 错误。

5. 服务器防火墙与端口


确保 PHP 运行的服务器可以访问 Minecraft 服务器的 Query 端口 (通常是 25565)。如果 Minecraft 服务器有防火墙,需要放行相应的 UDP 端口。如果使用 Server List Ping,则需要放行 TCP 端口。

五、总结

通过本文,我们详细探讨了使用 PHP 获取 Minecraft 服务器信息的方法。从底层协议的解析到具体的 PHP 代码实现,再到生产环境中的优化策略,您现在应该对如何构建一个稳定高效的 Minecraft 服务器状态查询系统有了全面的了解。

无论是选择自行实现 Query 或 Ping 协议,还是更推荐地使用像 xPaw/PHP-Minecraft-Query 这样的成熟库,结合缓存和异步加载等最佳实践,都能够帮助您在网站上完美地展示 Minecraft 服务器的实时信息,为玩家提供更好的体验。```

2026-03-03


上一篇:PHP实现LBS:高效获取附近商家与地点数据深度指南

下一篇:PHP 字符串拼接艺术:从基础操作到性能优化与最佳实践