xp-forge/frontend

Web前端

v6.3.0 2024-05-20 08:06 UTC

README

Build status on GitHub XP Framework Module BSD Licence Requires PHP 7.0+ Supports PHP 8.0+ Latest Stable Version

基于 xp-forge/web 的前端,使用基于注解的路由。

示例

前端使用带有HTTP动词注解的处理类方法来处理路由。这些方法返回一个上下文,该上下文与模板名称一起传递给模板引擎。

use web\frontend\{Handler, Get, Param};

#[Handler]
class Hello {

  #[Get]
  public function greet(#[Param('name')] $param) {
    return ['name' => $param ?: 'World'];
  }
}

注意:对于PHP 7,Param 注解必须单独一行,查看这里

对于上述类,模板引擎将接收 home 作为模板名称,并将返回的映射作为上下文。此库仅包含模板的骨架 - xp-forge/handlebars-templates 库实现它。对于其他示例,我们将使用它。

handlebars模板 hello.handlebars(从上述处理类名称的小写版本计算得出)相当简单

<!DOCTYPE html>
<html lang="en">
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Hello World</title>
</head>
<body>
  <h1>Hello {{name}}</h1>
</body>
</html>

最后,在应用程序类中将其连接起来,如下所示

use web\Application;
use web\frontend\{AssetsFrom, Frontend, Handlebars};

class Site extends Application {

  /** @return [:var] */
  public function routes() {
    $assets= new AssetsFrom($this->environment->path('src/main/webapp'));
    $templates= new Handlebars($this->environment->path('src/main/handlebars'));

    return [
      '/favicon.ico' => $assets,
      '/static'      => $assets,
      '/'            => new Frontend(new Hello(), $templates)
    ];
  }
}

要运行它,使用 xp -supervise web Site,它将在http://localhost:8080/上提供网站。

组织代码

在实际情况下,您不会希望将所有代码都放入 Hello 类中。为了将代码分离到各个类中,请将所有处理类放置在一个专用包中

@FileSystemCL<./src/main/php>
package org.example.web {

  public class org.example.web.Home
  public class org.example.web.User
  public class org.example.web.Group
}

然后使用 HandlersIn 类提供的代理API

use web\frontend\{Frontend, HandlersIn};

// ...inside the routes() method, as seen above:
new Frontend(new HandlersIn('org.example.web'), $templates);

处理路由和方法

Handler 注解可以包括一个路径,该路径用作处理类中所有方法路由的前缀。可以使用占位符从请求URI中选择方法参数。

use web\frontend\{Handler, Get};

#[Handler('/hello')]
class Hello {

  #[Get]
  public function world() {
    return ['greet' => 'World'];
  }

  #[Get('/{name}')]
  public function person(string $name) {
    return ['greet' => $name];
  }
}

上述方法路由将仅接受 GET 请求。可以使用 Post 注解 POST 请求方法,使用 Put 注解 PUT,依此类推。

路由方法可以返回 web.frontend.View 实例以对响应有更多控制

use web\Headers;
use web\frontend\View;

// Equivalent of the above world() method's return value
return View::named('hello')->with(['greet' => 'World']);

// Redirecting to either paths or absolute URIs
return View::redirect('/hello/World');

// Add headers and caching, here: for 7 days
return View::named('blog')
  ->with($article)
  ->header('Last-Modified', Headers::date($modified))
  ->cache('max-age=604800, must-revalidate')
;

要覆盖用于POST请求的方法,请传递特殊字段 _method

<form action="/example" method="POST">
  <input type="hidden" name="_method" value="PUT">
  <!-- Rest of form -->
</form>

这会将请求路由为如果它被作为 PUT /example HTTP/1.1 发布。

提供资产

如上所示,资产由 AssetsFrom 处理程序提供。它负责内容类型,处理部分内容的有条件请求和范围请求,以及压缩。

来源

构造函数接受单个路径以及将搜索请求资产的路径数组。选择提供资产的第一个路径,并从那里提供文件。

use web\frontend\AssetsFrom;

// Single source
$assets= new AssetsFrom($this->environment->path('src/main/webapp'));

// Multiple sources
$assets= new AssetsFrom([
  $this->environment->path('src/main/webapp'),
  $this->environment->path('vendor/example/layout-lib/src/main/webapp'),
]);

缓存

可以通过将 Cache-Control 头传递给 with 函数来通过缓存来提供资产。在此示例中,资产被缓存28天,但客户端在使用其缓存的副本之前被要求使用条件请求进行重新验证。

use web\frontend\AssetsFrom;

$assets= (new AssetsFrom($path))->with([
  'Cache-Control' => 'max-age=2419200, must-revalidate'
]);

压缩

还可以以压缩形式提供资产以节省带宽。典型的捆绑JavaScript库的原始大小可以是兆字节!通过使用例如Brotli,这可以大幅度减少到几百千字节。

  • 请求URI映射到资产文件名
  • 如果客户端发送 Accept-Encoding 头,则将其解析并与客户端偏好进行协商
  • 服务器尝试 [file].br(用于Brotli),[file].bz2(用于BZip2),[file].gz(用于GZip)和[file].dfl(用于Deflate),并且仅在不存在或不可接受时才发送未压缩版本。

注意:资产不会即时压缩,因为这会导致不必要的服务器负载。

资产指纹识别

生成的资产可以通过在文件名中嵌入版本标识符来进行指纹识别,例如 [文件名].[版本].[扩展名]。每次其内容发生变化时,版本(或 指纹)都会更改,随之文件名也会改变。这些资产可以被视为“不可变”,并以“无限”的最大年龄提供。打包器(如 Webpack 或此库内置的打包器)将与这些资产一起创建一个 资产清单

use web\frontend\{AssetsFrom, AssetsManifest};

$manifest= new AssetsManifest($path->resolve('manifest.json'));
$assets= new AssetsFrom($path)->with(fn($uri) => [
  'Cache-Control' => $manifest->immutable($uri) ?? 'max-age=2419200, must-revalidate'
]);

因为文件名映射是在模板引擎中发生的,所以必须也将清单传递到那里

use web\frontend\Handlebars;
use web\frontend\helpers\Assets;

$templates= new Handlebars($path, [new Assets($manifest)]);

然后 Handlebars 代码使用 asset 辅助函数查找包含指纹的文件名

<link href="/static/{{asset 'vendor.css'}}" rel="stylesheet">

这样,我们不必每次资产更改时都提交 Handlebars 文件的更改,这种情况可能会经常发生!

内置的打包器

从安全角度和减少 HTTP 请求的角度来看,打包资产是有意义的。此库附带一个 bundle 子命令,可以从 package.json 中跟踪的依赖项生成 JavaScript 和 CSS 打包文件。

{
  "dependencies": {
    "simplemde": "^1.11",
    "transliteration": "^2.1"
  },
  "bundles": {
    "vendor": {
      "simplemde": "dist/simplemde.min.js | dist/simplemde.min.css",
      "transliteration": "dist/browser/bundle.umd.min.js"
    }
  }
}

要创建到 src/main/webapp/static 目录的打包文件和资产清单,请运行以下命令

$ xp bundle -m src/main/webapp/manifest.json src/main/webapp/static
# ...

这将创建 vendor.[fingerprint].jsvendor.[fingerprint].css 文件以及压缩版本(如果可用的 zlib 和 brotli PHP 扩展程序)以及资产清单,该清单将不带指纹的文件名映射到带指纹的文件名。

打包器还可以解析本地文件、URL 以及 Google 字体

{
  "bundles": {
    "vendor": {
      "src/main/js": "index.js",
      "https://cdn.amcharts.com/lib/4": "core.js | charts.js | themes/kelly.js",
      "fonts://display=swap": "Overpass"
    }
  }
}

错误处理

默认情况下,错误和异常将生成一个最小化错误页面,显示相应的错误代码(默认为 500 内部服务器错误)。可以通过闭包、状态代码或默认方式处理异常,并决定返回自己的视图。此视图从 errors/ 子目录加载,并传递一个上下文 ['cause' => $exception]

use web\frontend\{HandlersIn, Frontend, Exceptions};
use org\example\{InvalidOrder, LinkExpired};
use lang\Throwable;

$frontend= (new Frontend(new HandlersIn('org.example.web'), $templates))
  ->handling((new Exceptions())
    ->catch(InvalidOrder::class, fn($e) => View::error(503, 'invalid-order')),
    ->catch(LinkExpired::class, 404) // uses template "errors/404"
    ->catch(Throwable::class)        // catch-all, errors/{status} for web.Error, errors/500 for others
  )
;

使用上面提到的 Handlebars 引擎,模板 errors/404.handlebars 可能如下所示

<!DOCTYPE html>
<html lang="en">
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Error 404</title>
</head>
<body>
  <h1>Not found</h1>
  <p>{{cause.message}}</p>

  {{! Log errors !}}
  {{log request.uri "~" cause level="error"}}
</body>
</html>

安全

此库设置了以下安全头默认值

  • X-Content-Type-Options: nosniff - 阻止浏览器进行 MIME 嗅探
  • X-Frame-Options: DENY - 阻止网站被嵌入到 <iframe> 中。
  • Referrer-Policy: no-referrer-when-downgrade - 不会在非加密连接中发送 HTTP 引用者。

要配置框架、引用者和内容安全策略,请使用 security() 流畅接口

use web\frontend\{Frontend, Security};

$frontend= (new Frontend($delegates, $templates))
  ->enacting((new Security())
    ->framing('SAMEORIGIN')
    ->referrers('strict-origin')
    ->csp([
      'default-src' => '"none"',
      'script-src'  => ['"self"', '"nonce-{{nonce}}"', 'https://example.com'],
      // etcetera
    ])
  )
;

有关如何加强响应头的信息,请参阅 https://scotthelme.co.uk/hardening-your-http-response-headers/ 或观看此演讲: https://www.youtube.com/watch?v=mr230uotw-Y

性能

在生产服务器上使用时,应用程序的代码只编译一次,其设置也只运行一次。这使我们能够获得闪电般的响应时间。

Network console screenshot