forrest79/deploy-php

PHP项目的简单资源构建和部署应用程序助手。

v1.3.0 2024-05-10 20:17 UTC

README

Latest Stable Version Monthly Downloads License Build

PHP项目的简单资源构建和应用程序部署助手。

要求

Forrest79/DeployPhp 需要 PHP 8.0 或更高版本。

安装

推荐通过 Composer 安装 Forrest79/DeployPhp

composer require --dev forrest79/deploy-php

文档

资源

这是一个简单的资源构建器。目前,它支持复制文件、编译和压缩 less 文件、sass 文件和 JavaScript(简单的压缩器 UglifyJS 或复杂的 rollup.js + 推荐的 Babel)文件,并在调试环境中生成映射文件。

编译和压缩需要安装 node.js 以及已安装的 npmlessnode-sassuglify-jsrollup(《babel》)。在 Debian 或 Ubuntu 上,你可以这样做(-g 选项将在系统中全局安装包,而不是在你的仓库中)

curl -sL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs

# LESS compiler
npm install less
#sudo npm install -g less

# SASS compiler
npm install node-sass
#sudo npm install -g node-sass

# UglifyJS compiler
npm install uglify-js
#sudo npm install -g uglify-js

# Babel and Rollup (prefer not to install this globally)
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-terser @rollup/plugin-babel @babel/core @babel/preset-env @babel/plugin-transform-runtime core-js

使用方法简单。以下示例展示了它是如何与 Nette 框架 一起工作的。只需创建新的 Forrest79\DeployPhp\Assets 类实例,并将临时目录、资源源目录和配置数组传递给构造函数。`key` 是要处理的目录(对于 DeployPhp\Assets::COPY)或目标文件(对于 DeployPhp\Assets::UGLIFYJSDeployPhp\Assets::ROLLUPDeployPhp\Assets::LESS)或目录(对于 DeployPhp\Assets::SASS)的源数据,而 `value` 可以是简单的 DeployPhp\Assets::COPY,表示将此文件/目录从源复制到目标或另一个 `array`,包含项

  • 必需的 type - 值为 DeployPhp\Assets::COPY 以复制文件/目录或 DeployPhp\Assets::LESS 以编译和压缩 less 到 CSS 或 DeployPhp\Assets::UGLIFYJS 以连接和压缩 JavaScript 或 DeployPhp\Assets::ROLLUP 以使用现代 JavaScript 环境
  • 可选的 env - 如果缺失,此项目将用于调试和生产环境,或者您可以指定具体的环境 DeployPhp\Assets::DEBUGDeployPhp\Assets::PRODUCTION
  • 对于 type => DeployPhp\Assets::LESS 必需的 file - 要编译和压缩的源文件
  • 对于 type => DeployPhp\Assets::SASS 必需的 filefiles - 要编译和压缩的源文件或文件
  • 对于 type => DeployPhp\Assets::UGLIFYJS 必需的 files - 要连接和压缩的源文件
  • 对于 type => DeployPhp\Assets::ROLLUP 必需的 file - 要处理的源文件(以下是一个示例配置)

下两个参数是可调用的函数,第一个用于从文件中读取哈希,第二个用于将哈希写入文件。在示例中展示了如何将其写入 neon 并与 Nette DI 一起使用。

最后一个(第四个)参数是可选的,定义一个包含可选设置的数组。更多关于此的内容请参阅示例。

要构建资源,首先需要调用 buildDebug($configNeon, $destinationDirectory)buildProduction($configNeon, $destinationDirectory) 方法。

  • $configFile 文件,其中将存储实际的资源哈希,您可以在应用程序中使用它
  • $destinationDirectory 目录,其中将构建资源

第一次仅当有更改的文件时才构建资源,并从所有文件的最后修改时间创建新的哈希(同时创建映射文件),第二次每次都构建资源并从每个文件的内容创建哈希。

带有 Babel 的 rollup.js 环境

这是现代JavaScript构建配置。您必须在您的资产目录中准备rollup配置文件。

创建文件assets\rollup.config.js

import { babel } from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';

const config = {
	input: process.env.INPUT_FILE, // source file from PHP settings
	output: [
		{ // this compile file for old browsers
			file: process.env.OUTPUT_FILE.replace('{format}', 'iife'), // output file from PHP settings - string {format} is replaced with iife
			format: 'iife',
			name: 'app',  // you can change this, it's some your identificator
			sourcemap: !!parseInt(process.env.SOURCE_MAP, 10), // this provide source map for DEVEL and not for production
		},
		{ // this complie modules JS for modern browsers
			file: process.env.OUTPUT_FILE.replace('{format}', 'esm'),
			format: 'esm',
			sourcemap: !!parseInt(process.env.SOURCE_MAP, 10),
		}
	],
	plugins: [
		nodeResolve(), // with this, you can import from node_modules
		commonjs(), // this resolve require() function
		babel({ // babel settings
			babelHelpers: 'runtime',
			presets: [
				[
					'@babel/preset-env',
					{
						'bugfixes': true,
						'corejs': '3.9',
						'targets': '>0.25%',
						'useBuiltIns': 'usage',
					}
				]
			],
			plugins: ['@babel/plugin-transform-runtime'],
			exclude: /\/node_modules\/core-js\//, // we must exclude core-js from being transpiled
		}),
		terser(), // minification
	]
};

export default config;

在您的HTML中,您可以像这样使用这两个文件

<script type="text/javascript" src="/js/scripts.iife.js" nomodule defer></script>
<script type="module" src="/js/scripts.esm.js"></script>

示例

deploy/assets.php

use Forrest79\DeployPhp;

require __DIR__ . '/vendor/autoload.php';

return (new DeployPhp\Assets(
    __DIR__ . '/../temp',
    __DIR__ . '/assets',
    [
        'images' => DeployPhp\Assets::COPY,
        'fonts' => DeployPhp\Assets::COPY,
        'css/styles.css' => [ // target file
            'type' => DeployPhp\Assets::LESS,
            'file' => 'css/main.less',
        ],
        'css/styles' => [ // target directory, main.css will be created here
            'type' => DeployPhp\Assets::SASS,
            'file' => 'css/main.sass',
        ],
        'css/many-styles' => [ // target directory, main.css and print.css will be created here
            'type' => DeployPhp\Assets::SASS,
            'files' => [
                'css/main.sass',
                'css/print.sass',
            ]
        ],
        'js/scripts.js' => [ // target file
            'type' => DeployPhp\Assets::JS,
            'files' => [
                'js/bootstrap.js',
                'js/modernizr-custom.js',
                'js/web.js',
            ],
        ],
        'js/jquery.min.js' => DeployPhp\Assets::COPY,
        'js/jquery.min.map' => [
            'type' => DeployPhp\Assets::COPY,
            'env' => DeployPhp\Assets::DEBUG,
        ],
		'js/scripts.{format}.js' => [ // target file - will be compiled for more formats
			'type' => DeployPhp\Assets::ROLLUP,
			'file' => 'js/index.js',
		],
    ],
    static function (string $configFile): ?string {
        if (!file_exists($configFile)) {
            return NULL;
        }

        $data = Neon\Neon::decode(file_get_contents($configFile));
        if (!isset($data['assets']['hash'])) {
            return NULL;
        }

        return $data['assets']['hash'];
    },
    static function (string $configFile, string $hash): void {
        file_put_contents($configFile, "assets:\n\t\thash: $hash\n");
    },
    ((($localConfig = @include __DIR__ . '/assets.local.php') === FALSE) ? [] : $localConfig)
);

具有哈希的Neon文件具有以下结构

parameters:
    assets:
        hash: c11a678785091b7f1334c24a4123ee75 # md5 hash (32 characters)

deploy/assets.local.php中,您可以定义本地源资产目录,如果您使用的是虚拟服务器,其中路径与您的宿主路径不同。此目录将用于JS和CSS映射文件,以便在浏览器控制台中打开源文件

return [
	'localSourceDirectory' => 'P:/app/assets',
];

或者,您需要在此处指定您本地的服务器bin目录,如果它不同于/usr/bin:/bin(包含node二进制的目录)

return [
	'systemBinPath' => '/opt/usr/bin:/opt/bin',
];

app/bootstrap.php

$configurator->addConfig(__DIR__ . '/config/config.neon');

if (PHP_SAPI !== 'cli') {
    $assetsConfigFile = __DIR__ . '/config/config.assets.neon';
    $configurator->addConfig($assetsConfigFile);
    if ($configurator->isDebugMode()) {
        $assets = @include __DIR__ . '/../assets/assets.php'; // intentionally @ - file may not exists - good when production with production assets is running in debug mode (production preferable doesn't have assets source)
        if ($assets !== FALSE) {
            $assets->buildDebug($assetsConfigFile, __DIR__ . '/../../www/assets');
        }
    }
}

$configurator->addConfig(__DIR__ . '/config/config.local.neon');

$container = $configurator->createContainer();

在调试模式下,哈希是从每个资产文件的最后修改时间戳计算得出的 - 创建哈希速度快(如果您更改文件或添加/删除某些文件,则哈希将更改,并且资产将在请求执行之前自动重建)。

在Nette中,您需要定义自己的资产扩展,该扩展将从assets.hash读取哈希,并通过某种服务,您可以在您的应用程序中使用它。例如,如下所示

// Service to use in application

namespace App\Assets;

class Assets
{
    /** @var string */
    private $hash;


    public function __construct(string $hash)
    {
        $this->hash = $hash;
    }


    public function getHash(): string
    {
        return $this->hash;
    }

}


// Extension that uses neon structure with hash (just register this as extension in config.neon)

namespace App\Assets\DI;

use App\Assets;
use Nette\DI\CompilerExtension;

class Extension extends CompilerExtension
{
    private $defaults = [
        'hash' => NULL,
    ];


    public function loadConfiguration()
    {
        $builder = $this->getContainerBuilder();

        $config = $this->validateConfig($this->defaults, $this->config);

        $builder->addDefinition($this->prefix('assets'))
            ->setFactory(Assets\Assets::class, [$config['hash']]);
    }

}

在您的应用程序中,您可以使用哈希作为查询参数styles.css?hash或在Web服务器中的虚拟路径,例如nginx,在路径/assets/hash/styles.css上加载资产

location /assets/ {
    expires 7d;
    rewrite ^/assets/[a-z0-9]+/(.+)$ /assets/$1 break;
}

当构建应用程序时

/** @var DeployPhp\Assets $assets */
$assets = require __DIR__ . '/assets.php';
$assets->buildProduction($releaseBuildDirectory . '/app/config/config.assets.neon', $releaseBuildDirectory . '/www/assets')

哈希是从所有文件内容计算的,因此只有当某些文件内容更改或添加/删除相同文件时,哈希才会更改(创建哈希速度慢)。

构建和部署

仅包含一些辅助方法,用于从GIT检出、通过SFTP复制文件和通过SSH运行命令。有关文档,请参阅示例。

示例

use Forrest79\DeployPhp;

require __DIR__ . '/../vendor/autoload.php';

//define('SSH_PRIVATE_KEY', 'define-this-in-deploy.local.php');
//define('SSH_AGENT_SOCK', 'define-this-in-deploy.local.php');
//define('DEPLOY_TEMP_DIRECTORY', 'define-this-in-deploy.local.php'); // if you want to change from default repository temp - on VirtualBox is recommended /tmp/... or some local (not shared) directory

require __DIR__ . '/deploy.local.php';

class Deploy extends DeployPhp\Deploy
{
    /** @var array<string, array<string, bool|float|int|string|array<mixed>|NULL>> */
    protected array $config = [
        'vps' => [
            'gitBranch' => 'master',
            'ssh' => [
                'server' => 'ssh.site.com',
                'directory' => '/var/www/site.com',
                'username' => 'forrest79',
                'private_key' => 'C:\\Certificates\\certificate',
                'passphrase' => NULL, // is completed dynamically - if needed (agent is tried at first), can be also callback call when password is needed
				'ssh_agent' => SSH_AGENT_SOCK, // TRUE - try to read from env variable, string - socket file
            ],
            'deployScript' => 'https://www.site.com/deploy.php',
        ]
    ];

    private string $releasesDirectory;

    private string $releaseName;

    private string $releasePackage;

    private string $releaseBuildPackage;


    protected function setup()
    {
        $this->releasesDirectory = defined('DEPLOY_TEMP_DIRECTORY')
            ? DEPLOY_TEMP_DIRECTORY
            : __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'temp' . DIRECTORY_SEPARATOR . 'deploy';

        $this->releaseName = 'release-' . date('Ymd-His') . '-' . uniqid();
        $this->releasePackage = $this->releaseName . '.tar.gz';
        $this->releaseBuildPackage = $this->releasesDirectory . DIRECTORY_SEPARATOR . $this->releasePackage;
    }


    public function run()
    {
        /** when password is get at the begin of the script (the old way)
        if (!$this->validatePrivateKey()) {
            $this->error('Bad passphrase for private key or bad private key.');
        }
        */

        $this->log('=> Creating build...');
        $this->createBuild();
        $this->log('   ...DONE');

        $this->log('=> Deploying build...');
        $this->deployBuild();
        $this->log('   ...DONE');

        $this->log('=> Cleaning up local files');
        $this->delete($this->releaseDirectory);
        $this->log('   ...DONE');
    }


    private function createBuild()
    {
        $releaseBuildDirectory = $this->releasesDirectory . DIRECTORY_SEPARATOR . $this->releaseName;

        $this->log('     -> checkout from GIT', FALSE);
        if (!$this->gitCheckout(__DIR__ . DIRECTORY_SEPARATOR . '..', $releaseBuildDirectory, $this->environment['gitBranch'])) {
            $this->error(' ...cant\'t checkout from GIT');
        }
        $this->log(' ...OK');

        $this->log('     -> building assets', FALSE);

        $assets = require __DIR__ . '/assets.php';
        assert($assets instanceof DeployPhp\Assets);

        $assets
            ->setup($releaseBuildDirectory . '/app/config/config.assets.neon', $releaseBuildDirectory . '/app/assets', $releaseBuildDirectory . '/www/assets')
            ->buildProduction();
        $this->log(' ...OK');

        $this->log('     -> preparing package', FALSE);
        $this->delete($releaseBuildDirectory . '/app/assets');
        $this->delete($releaseBuildDirectory . '/conf');
        $this->delete($releaseBuildDirectory . '/data');
        $this->delete($releaseBuildDirectory . '/db');
        $this->delete($releaseBuildDirectory . '/deploy');
        $this->delete($releaseBuildDirectory . '/download');
        $this->delete($releaseBuildDirectory . '/logs');
        $this->delete($releaseBuildDirectory . '/temp');
        $this->delete($releaseBuildDirectory . '/.gitignore');
        $this->delete($releaseBuildDirectory . '/composer.json');
        $this->delete($releaseBuildDirectory . '/composer.lock');
        $this->log(' ...OK');

        $this->log('     -> compressing package', FALSE);
        $this->gzip($this->releasesDirectory, $this->releaseName, $this->releaseBuildPackage);
        $this->log(' ...OK');
    }


    private function deployBuild()
    {
        $remoteReleaseDirectory = $this->environment['ssh']['directory'] . '/releases';
        $remoteReleaseBudilDirectory = $remoteReleaseDirectory . '/' . $this->releaseName;
        $this->log('     -> uploading build package', FALSE);
        if (!$this->sftpPut($this->releaseBuildPackage, $remoteReleaseDirectory)) {
            $this->error(' ...an error occurred while uploading build package');
        }
        $this->log(' ...OK');

        $this->log('     -> extracting build package, creating temp, symlinks and removing build package', FALSE);
        if (!$this->ssh('cd ' . $remoteReleaseDirectory . ' && tar xfz ' . $this->releasePackage . ' && rm ' . $this->releasePackage . ' && mkdir ' . $remoteReleaseBudilDirectory . '/temp && ln -s ' . $this->environment['ssh']['directory'] . '/logs ' . $remoteReleaseBudilDirectory . '/logs && ln -s ' . $this->environment['ssh']['directory'] . '/data ' . $remoteReleaseBudilDirectory . '/www/data && ln -s ' . $this->environment['ssh']['directory'] . '/config/config.local.neon ' . $remoteReleaseBudilDirectory . '/app/config/config.local.neon')) {
            $this->error(' ...an error occurred while extracting build package, creating temp and symlinks');
        }
        $this->log(' ...OK');

        $this->log('     -> releasing build (replace link to current)', FALSE);
        if (!$this->ssh('ln -sfn ' . $remoteReleaseBudilDirectory . ' ' . $this->environment['ssh']['directory'] . '/current_new && mv -Tf ' . $this->environment['ssh']['directory'] . '/current_new ' . $this->environment['ssh']['directory'] . '/current')) {
            $this->error(' - an error occurred while releasing build');
        }
        $this->log(' ...OK');

        $this->log('     -> running after deploy script', FALSE);
        if (!$this->httpRequest($this->environment['deployScript'] . '?' . $this->releaseName , 'OK')) {
            $this->error(' ...an error occurred while running deploy script');
        }
        $this->log(' ...OK');

        $keepBuilds = 5;
        $this->log('     -> cleaning up old builds', FALSE);
        if (!$this->ssh('ls ' . $remoteReleaseDirectory . '/* -1td | tail -n +' . ($keepBuilds + 1) . ' | grep -v ' . $this->releaseName . ' | xargs rm -rf')) {
            $this->error(' ...an error occurred while cleaning old build');
        }
        $this->log(' ...OK');
    }

}


/**
 * RUN FROM COMMAND LINE *******************************************************
 * *****************************************************************************
 */


if ($argc == 1) {
    echo "Usage: php deploy.php <environment> [git-branch]";
    exit(1);
}

/** when password is get at the begin of the script 
echo 'Enter SSH key password: ';

try {
    $passphrase = Deploy::getHiddenResponse();
    echo PHP_EOL;
} catch (RuntimeException $e) {
    echo '[Can\'t get hidden response, password will be visible]: ';
    $passphrase = Deploy::getResponse();
}

$additionalOptions = ['ssh' => ['passphrase' => $passphrase]];
*/

$additionalOptions = [
	'ssh' => [
		'passphrase' => static function (Deploy $deploy, string $privateKeyFile): string {
			$passphrase = NULL;

			do {
				echo $passphrase === NULL ? PHP_EOL . '          > Enter SSH key password: ' : '  > Bad password, enter again: ';

				try {
					$passphrase = Deploy::getHiddenResponse();
					echo PHP_EOL . '        ';
				} catch (RuntimeException) {
					echo '[Can\'t get hidden response, password will be visible]: ';
					$passphrase = Deploy::getResponse();
				}
			} while (!$deploy->validatePrivateKey($privateKeyFile, $passphrase));

			return $passphrase;
		},
	],
];

if ($argc > 2) {
    $additionalOptions['gitBranch'] = $argv[2];
}

try {
    (new Deploy($argv[1], $additionalOptions))->run();
} catch (Exception $e) {
    echo $e->getMessage() . "\n";
    exit(1);
}

Composer monorepo

如果您为您的应用程序使用monorepo,您需要一个简单的工具来准备正确的composer.lock。这是满足这些要求的一个简单工具

  • 一个共享的全局供应商目录,包含所有库
  • 更多应用程序具有本地供应商,在本地开发时使用共享的供应商,并在生产中安装

请注意,使用此工具始终会在全局composer上执行更新!下一步是将全局composer复制到本地一个,并且在这里也会执行更新。之后,本地供应商将被清理。

只是作为一个提示,全局和本地composer.json之间的差异将显示出来。这可能不是一个错误。

示例

/apps/appA/composer.json
/apps/appA/composer.lock
/apps/appA/vendor (autoload.php -> /vendor/autoload.php)
/apps/appB/composer.json
/apps/appB/composer.lock
/apps/appB/vendor (autoload.php -> /vendor/autoload.php)
/vendor/autoload.php
/vendor/[with all packages]
composer.json
composer.lock
prepare-monocomposer (source is below)
  • 全局供应商已提交到存储库,为了准备生产构建,全局供应商被复制到本地一个,并在应用程序目录中执行composer install,因此这里只保留所需的包
#!/usr/bin/env php
<?php declare(strict_types=1);

(new Forrest79\DeployPhp\ComposerMonorepo(__DIR__ . '/composer.json', '--ignore-platform-reqs'))->updateSynchronize([
	'appA' => __DIR__ . '/apps/appA/composer.json',
	'appB' => __DIR__ . '/apps/appB/composer.json',
]);

ComposeMonorepo构造函数的第二个参数是composer update命令的可选参数。