开发 Magento2 的模块

新建模块的代码

  1. 假设已经安装好 magento2
  2. 新建模块的代码
  3. 启用模块 和 刷新缓存

模块的路径是这样的,开发商名称和模块名称都使用 大驼峰 的形式命名

app/code/开发商名称/模块名称

默认路由是这样的

routeid/controller/action

最简单的例子

  1. 新建模块目录 app/code/LocalDev/HelloModule
  2. 在模块目录下新建 registration.php 并写入以下内容
    <?php
    \Magento\Framework\Component\ComponentRegistrar::register(
        \Magento\Framework\Component\ComponentRegistrar::MODULE,
        'LocalDev_HelloModule',
        __DIR__
    );
    
  3. 在模块目录下新建 etc 文件夹,在 etc 文件夹下新建 module.xml 并写入以下内容
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
        <module name="LocalDev_HelloModule" setup_version="1.0.9"></module>
    </config>
    
  4. 新建路由,在 etc 文件夹下新建 frontend 文件夹,在 frontend 下新建 routes.xml 并写入以下内容
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/routes.xsd">
        <router id="standard">
            <route id="localdev" frontName="localdev">
                <module name="LocalDev_HelloModule" />
            </route>
        </router>
    </config>
    
    • 这是一个前台的路由,如果是后台的路由,那么需要在 etc/adminhtml 下新建 routes.xml
    • frontName 就是 route id
  5. 新建 Controller 和 action
    1. 在模块目录下新建 Controller 文件夹
    2. 在 Controller 文件夹下,新建一个以控制器名称命名的文件名,例如 Hello
    3. 在 控制器 文件夹下,新建一个以方法名命名的文件,例如 World.php
    4. 在方法的文件里写入以下内容
      <?php
      namespace LocalDev\HelloModule\Controller\Hello;
      class World extends \Magento\Framework\App\Action\Action
      {
          public function __construct(
              \Magento\Framework\App\Action\Context $context,
          ) {
              parent::__construct($context);
          }
      
          public function execute()
          {
              /** @var \Magento\Backend\Model\View\Result\Page $result */
              $result = $this->resultFactory->create(\Magento\Framework\Controller\ResultFactory::TYPE_PAGE);
              return $result;
          }
      }
      
  6. 新建视图
    1. 在模块目录下新建 view 文件夹
    2. 在 view 文件夹下,新建一个 frontend 文件夹
    3. 在 frontend 文件夹下,新建一个 layout 文件夹 和 一个 templates 文件夹
    4. 在 layout 文件夹下,新建一个以路由命名的 xml 文件,例如 localdev_hello_world.xml
    5. 在 xml 文件里写入以下内容
      <?xml version="1.0"?>
      <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
          <body>
              <referenceContainer name="content">
                  <block class="Magento\Framework\View\Element\Template" template="LocalDev_HelloModule::container.phtml" name="localdev.container"/>
              </referenceContainer>
          </body>
      </page>
      
    6. 在 templates 文件夹下,新建一个名为 container.phtml 的文件,这个文件名要和 xml 文件里的 template 属性对应
    7. 在 phtml 文件里写入以下内容
      <?php
      /** @var \Magento\Framework\View\Element\Template $block */
      ?>
      <p><?=$block->getBaseUrl()?></p>
      

完整的模块目录结构是这样的

app
    code
        LocalDev
            HelloModule
                Controller
                    Hello
                        World.php
                etc
                    frontend
                        routes.xml
                    module.xml
                view
                    frontend
                        layout
                            localdev_hello_world.xml
                        templates
                            container.phtml
                registration.php

启用模块和刷新缓存后,访问这样的链接 http://localhost-magento/localdev/hello/world ,应该就能看到 hello world 的输出

启用模块 和 刷新缓存

查看启用的模块

php bin/magento module:status

启用模块

php bin/magento module:enable 模块名

禁用模块

php bin/magento module:disable 模块名

刷新缓存

php bin/magento cache:clean 清除缓存
php bin/magento setup:upgrade 更新数据 Upgrades the Magento application, DB data, and schema
php bin/magento setup:di:compile 编译
php bin/magento setup:static-content:deploy -f 部署静态视图文件
php bin/magento indexer:reindex 刷新全部索引
php bin/magento cache:flush 刷新缓存

模块的代码修改后也要刷新缓存

目录结构

app
    code 模块
        metapackage 开发商
            module 模块
                Api
                Block
                Console
                Controller
                Cron
                etc
                    areaCode
                        ... 直接写在 etc 目录下的配置是全局的,写在 areaCode 文件下的配置只在对应的 areaCode 下生效
                    di.xml
                    events.xml
                    view.xml
                    cron_groups.xml
                    crontab.xml
                    logging.xml
                    module.xml
                    acl.xml
                    config.xml
                    routes.xml
                    system.xml
                    db_schema_whitelist.json
                    db_schema.xml
                    menu.xml
                    resources.xml
                    widget.xml
                    schema.graphqls
                Helper
                Model
                    Indexer
                Observer
                Plugin
                Setup
                Test
                Ui
                view
                    areaCode 区域代码 就是 frontend adminhtml 这种
                        layout
                            *.xml
                            这个目录下的 xml 文件是布局配置文件
                            这些 xml 的文件名是对应路由的,也就是和路由名称一样
                        page_layout
                            这个目录下的 xml 文件就是页面布局文件,文件名就是布局id
                        ui_component 也是放 xml 文件,但还不知道有什么用
                            这里的 xml 文件可以在 layout 里引用
                        templates
                            *.phtml
                        web
                            css
                            fonts
                            images
                            js
                                action
                                model
                                view
                                    这个文件夹下的 js 就是前端的 component ,继承自 magento2 的 uiComponent
                                    这个文件夹下的 js 应该实和 template 里的 html 文件一一对应的,
                                    但也可以在 js 里修改模板的路径
                                *.js 直接放在 js 目录下的通常是 jq 的 widget
                            template
                                这里放的是 html 文件
                                这些 html 文件通常是 ko 的模板
                                component 通过 ajax 获取这些模板
                        layouts.xml 用于声明有哪些布局
                        requirejs-config.js 用来声明 requirejs 的配置,例如 js 的加载顺序
                i18n
                其它的文件夹
                    ViewModel
                    CustomerData
                composer.json
                registration.php
    design 主题
        areaCode 区域代码, frontend 是前台, adminhtml 是后台
            开发商
                主题 -> 优先级是高于 模块 里的文件
                    开发商_模块名 -> 和 模块里的 view 文件夹是一样的
                    etc
                    view
                    web
                        css
                        fonts
                        images
                        js
                        template
                    media
                    composer.json
                    registration.php
                    theme.xml
    etc 全局配置
    i18n 语言包
bin
    magento
dev
generated
lib
    internal
    web
phpserver
pub
    static
    cron.php
    get.php
    health_check.php
    index.php
    static.php
setup
var
vendor
composer.json

新建模型

  1. 新建或在 db_schema.xml 文件里添加

    <?xml version="1.0"?>
    <schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
        <table name="test_model" resource="default" engine="innodb" comment="Test Model">
            <column xsi:type="int" name="entity_id" nullable="false" identity="true"/>
            <column xsi:type="int" name="customer_id" nullable="false" comment="customer_id"/>
            <column xsi:type="varchar" name="type" nullable="false" length="64" comment="type"/>
            <constraint xsi:type="primary" referenceId="PRIMARY">
                <column name="entity_id"/>
            </constraint>
        </table>
    </schema>
    
  2. 新建 resource model

    • 在模块目录 model/ResourceModel 文件夹下新建 TestModel.php
    <?php
    namespace Vendor\Extension\Model\ResourceModel;
    
    use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
    
    class TestModel extends AbstractDb
    {
        const TABLE_NAME = 'test_model';
    
        protected function _construct()
        {
            $this->_init(self::TABLE_NAME, 'entity_id');
        }
    }
    
  3. 新建 model

    • 在模块目录 model 文件夹下新建 TestModel.php
    <?php
    namespace Vendor\Extension\Model;
    
    use Magento\Framework\Model\AbstractModel;
    
    class TestModel extends AbstractModel
    {
        protected function _construct()
        {
            $this->_init(Vendor\Extension\Model\ResourceModel\TestModel::class);
        }
    }
    
  4. 新建 collection

    • 在模块目录 model/ResourceModel/TestModel 文件夹(这里的 TestModel 对应的是模型名)下新建 Collection.php
    <?php
    namespace Vendor\Extension\Model\ResourceModel\TestModel;
    
    use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
    
    class Collection extends AbstractCollection
    {
        protected function _construct()
        {
            $this->_init(Vendor\Extension\Model\TestModel::class, Vendor\Extension\Model\ResourceModel\TestModel::class);
        }
    }
    
  5. 然后运行这句新建 db_schema_whitelist.json

    php bin/magento setup:db-declaration:generate-whitelist --module-name=Extension
    
  6. 最后运行这句就能新建一个对应的表了

    php bin/magento setup:upgrade
    

EAV

EAV(实体 - 属性 - 值) entity attribute value

保存 eav 属性的表

eav_attribute 保存 eav 的属性
eav_entity_type 保存 eav 的类

输出某个 eav 类的全部 eav 属性
SELECT * FROM eav_attribute
WHERE entity_type_id  = (
    SELECT entity_type_id FROM eav_entity_type
    WHERE entity_table = 'catalog_product_entity' LIMIT 1
)

eav 的五种属性

varchar
int
text
datetime
decimal

常见的 eav 类,可以在这个表里看到 eav_entity_type

catalog_category_entity
catalog_product_entity
customer_entity
customer_address_entity

eav 的值保存在这类表中

类名_entity
类名_varchar
类名_int
类名_text
类名_datetime
类名_decimal

一次输出 eav 对象全部属性的 sql ,用于一般的 eav 对象

$entityId = '3893';
$entityTabel = 'customer_entity';
$eavTable = [
    'varchar',
    'int',
    'text',
    'datetime',
    'decimal',
];

$eavTpl = <<<'EOF'
(SELECT `t`.`value_id`,
         `t`.`value`,
         `t`.`attribute_id`,
         `a`.`attribute_code`,
         '%s' as `type`
FROM `%s` AS `t`
INNER JOIN `eav_attribute` AS `a`
    ON a.attribute_id = t.attribute_id
WHERE (entity_id = @entity_id))
EOF;
$eavSql = join('UNION ALL', array_map(function($item) use ($eavTpl, $entityTabel) {
    return sprintf($eavTpl, $item, $entityTabel . '_' . $item);
}, $eavTable));

$entityTpl = <<<'EOF'
select
    *,
    @entity_id := entity_id
from %s
where entity_id = %s
limit 1;
EOF;
$entitySql = sprintf($entityTpl, $entityTabel, $entityId);

$retSql = $entitySql . PHP_EOL . $eavSql . ';';

echo $retSql;

一次输出 eav 对象全部属性的 sql ,用于 product 和 category 的

$entityId = '3893';
$entityTabel = 'catalog_product_entity'; // catalog_category_entity
$eavTable = [
    'varchar',
    'int',
    'text',
    'datetime',
    'decimal',
];

$eavTpl = <<<'EOF'
(SELECT `t`.`value_id`,
         `t`.`value`,
         `t`.`store_id`,
         `t`.`attribute_id`,
         `a`.`attribute_code`,
         '%s' as `type`
FROM `%s` AS `t`
INNER JOIN `eav_attribute` AS `a`
    ON a.attribute_id = t.attribute_id
WHERE (row_id = @row_id))
EOF;
$eavSql = join('UNION ALL', array_map(function($item) use ($eavTpl, $entityTabel) {
    return sprintf($eavTpl, $item, $entityTabel . '_' . $item);
}, $eavTable));

$entityTpl = <<<'EOF'
select
    *,
    @row_id := row_id
from %s
where entity_id = %s and UNIX_TIMESTAMP(NOW()) >= created_in AND UNIX_TIMESTAMP(NOW()) < updated_in
order by row_id desc;
EOF;
$entitySql = sprintf($entityTpl, $entityTabel, $entityId);

$retSql = $entitySql . PHP_EOL . $eavSql . ';';

echo $retSql;

新建命令

  1. 在模块目录下 etc/di.xml 加上以下内容

    <type name="Magento\Framework\Console\CommandList">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="exampleSayHello" xsi:type="object">Vendor\Extension\Console\SayHello</item>
            </argument>
        </arguments>
    </type>
    
    • 如果是多条命令, item 可以多写几条
    • item 的值是运行命令的类的命名空间
  2. 在模块目录里新建一个文件夹 Console ,在这个新建的文件夹里新建一个文件 SayHello.php 并写入以下内容

    <?php
    namespace Vendor\Extension\Console;
    
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\Console\Input\InputOption;
    
    class SayHello extends Command
    {
        const NAME = "name";
    
        protected function configure()
        {
            $options = [
                new InputOption(self::NAME, null, InputOption::VALUE_REQUIRED, 'a description text')
            ];
    
            $this->setName("example:sayhello") // 命令的名字
                ->setDescription('example description') // 命令的描述
                ->setDefinition($options);
            parent::configure();
        }
    
        protected function execute(InputInterface $input, OutputInterface $output)
        {
            if ($name = $input->getOption(self::NAME)) {
                $output->writeln('hello ' . $name);
            } else {
                $output->writeln('hello world');
            }
        }
    }
    
    • configure 方法里的 setName 就是设置命令的运行名称,例如上面的例子,的运行命令就是 php bin/magento example:sayhello
  3. 运行这句命令 php bin/magento setup:upgrade 更新数据

  4. 可以尝试运行这条命令 php bin/magento list ,看看能不能找到新加的命令

  5. 最后运行上面新加的命令 php bin/magento example:sayhello

参考 https://developer.adobe.com/commerce/php/development/cli-commands/custom/

新建 rest 的接口

新建 etc\webapi.xml

<route url="/V1/gtm-layer/mine/quote-item-data" method="POST">
    <service class="Vendor\Extension\Api\GtmCartRepositoryInterface" method="getQuoteItemData"/>
    <resources>
        <resource ref="self" />
    </resources>
    <data>
        <parameter name="itemId">%item_id%</parameter>
        <parameter name="qty">%qty%</parameter>
    </data>
</route>

新建 app\code\Vendor\Extension\Api\GtmCartRepositoryInterface.php

<?php
namespace Vendor\Extension\Api;

interface GtmCartRepositoryInterface
{
    /**
     * @param string $itemId
     * @param int $qty
     * @return array
     * @throws \Magento\Framework\Webapi\Exception
     */
    public function getQuoteItemData($itemId, $qty = 0);
}

新建 app\code\Vendor\Extension\Model\GtmCartRepository.php

<?php
namespace Vendor\Extension\Model;

class GtmCartRepository implements GtmCartRepositoryInterface
{
    public function getQuoteItemData($itemId, $qty = 0)
    {
        return [];
    }
}

如果是新模块则需要运行一次 setup:upgrade 才能生效。 如果是旧模块则需要运行一次 cache:clear 就能生效。

调用的例子

curl -X POST https://dev.magento.com/rest/en_US/V1/gtm-layer/mine/quote-item-data -k -H "Content-Type: application/json" -d '{"productIds":["3893"]}'

curl -X POST https://dev.magento.com/rest/en_US/V1/gtm-layer/mine/quote-item-data -k -H "Content-Type: application/json" -d '{"itemId":3893,qty:1}'

新建 GraphQl 的接口

  1. 在模块目录 etc 下新建一个文件 schema.graphqls 并写入以下内容

        type Query
        {
            CustomGraphql (
                username: String @doc(description: "Email Address/Mobile Number")
                password: String @doc(description: "Password")
                websiteId: Int = 1 @doc (description: "Website Id")
            ): CustomGraphqlOutput @resolver(class: "Vendor\\Extension\\Model\\Resolver\\CustomGraphql") @doc(description:"Custom Module Datapassing")
        }
        type CustomGraphqlOutput
        {
            customer_id: Int
            type: String
            type_id: Int
        }
    
    • CustomGraphql 是请求的参数
    • CustomGraphqlOutput 是返回的参数
    • @resolver 是处理请求的类
  2. 在模块目录 Model 下新建一个文件夹 Resolver ,然后再在这个文件夹里新建一个类文件 CustomGraphql.php 并写入以下内容

    <?php
    namespace Vendor\Extension\Model\Resolver;
    
    use Magento\Framework\GraphQl\Config\Element\Field;
    use Magento\Framework\GraphQl\Exception\GraphQlInputException;
    use Magento\Framework\GraphQl\Query\ResolverInterface;
    use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
    
    class CustomGraphql implements ResolverInterface
    {
        /**
        * @param Field $field
        * @param \Magento\Framework\GraphQl\Query\Resolver\ContextInterface $context
        * @param ResolveInfo $info
        * @param array|null $value
        * @param array|null $args
        * @return array|\Magento\Framework\GraphQl\Query\Resolver\Value|mixed
        * @throws GraphQlInputException
        */
        public function resolve(
            Field $field,
            $context,
            ResolveInfo $info,
            array $value = null,
            array $args = null)
        {
            if (!isset($args['username']) || !isset($args['password']) || !isset($args['websiteId'])||
                empty($args['username']) || empty($args['password']) || empty($args['websiteId']))
            {
                throw new GraphQlInputException(__('Invalid parameter list.'));
            }
            $output = [];
            $output['customer_id'] = 123;
            $output['type'] = 'type';
            $output['type_id'] = 321;
        
            return $output ;
        }
    }
    
    • $output 的内容需要和 schema.graphqls 里定义的返回参数一致
  3. 运行这句命令 php bin/magento setup:upgrade 更新数据

    • 在开发模式下 php bin/magento c:c 就能使 schema.graphqls 的修改生效
  4. 用这句 curl 命令尝试请求

    graphqlquery=$(cat <<- EOF
    query {
        CustomGraphql(username: 123, password: "asd", websiteId: 321) {
            customer_id
            type
            type_id
        }
    }
    EOF
    );
    graphqlquery=$(echo -n $graphqlquery | php -r '$data=file_get_contents("php://stdin");print(json_encode($data));');
    graphqlquery='{"query":'$graphqlquery',"variables":{},"operationName":null}';
    curl 'http://localhost-magento/graphql' \
    -H 'accept: application/json' \
    -H 'content-type: application/json' \
    --data-raw "$graphqlquery" \
    --compressed \
    --insecure -s -k
    
    • 如无意外应该能返回类似这样的数据
    {
        "data": {
            "CustomGraphqlOutput": {
                "customer_id": 123,
                "type": "asd",
                "type_id": 321
            }
        }
    }
    
  5. 可以用这决 curl 命令来查看当前 magento 项目的 graphql 文档

    graphqlquery=$(cat <<- EOF
    query IntrospectionQuery {
        __schema {
            queryType {
                name
            }
            mutationType {
                name
            }
            subscriptionType {
                name
            }
            types {
                ...FullType
            }
            directives {
                name
                description
                locations
                args {
                    ...InputValue
                }
            }
        }
    }
    fragment FullType on __Type {
        kind
        name
        description
        fields(includeDeprecated: true) {
            name
            description
            args {
                ...InputValue
            }
            type {
                ...TypeRef
            }
            isDeprecated
            deprecationReason
        }
        inputFields {
            ...InputValue
        }
        interfaces {
            ...TypeRef
        }
        enumValues(includeDeprecated: true) {
            name
            description
            isDeprecated
            deprecationReason
        }
        possibleTypes {
            ...TypeRef
        }
    }
    fragment InputValue on __InputValue {
        name
        description
        type {
            ...TypeRef
        }
        defaultValue
    }
    fragment TypeRef on __Type {
        kind
        name
        ofType {
            kind
            name
            ofType {
                kind
                name
                ofType {
                    kind
                    name
                    ofType {
                        kind
                        name
                        ofType {
                            kind
                            name
                            ofType {
                                kind
                                name
                                ofType {
                                    kind
                                    name
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    EOF
    );
    graphqlquery=$(echo -n $graphqlquery | php -r '$data=file_get_contents("php://stdin");print(json_encode($data));');
    graphqlquery='{"query":'$graphqlquery',"variables":{},"operationName":null}';
    curl 'http://localhost-magento/graphql' \
    -H 'accept: application/json' \
    -H 'content-type: application/json' \
    --data-raw "$graphqlquery" \
    --compressed \
    --insecure -s -k
    
  6. graphql 里只有这个文件夹下的异常能显示出来,其它的异常都是显示 server error

    • vendor\magento\framework\GraphQl\Exception
  7. 如果要自定义异常,最好继承 grapqhl 里原本的异常,或实现这个接口 \GraphQL\Error\ClientAware

    • 关键还是这个接口 \GraphQL\Error\ClientAware
  8. graphql 接口大概的执行位置

    • vendor\magento\module-graph-ql\Controller\GraphQl.php
    • vendor\webonyx\graphql-php\src\Executor\ReferenceExecutor.php doExecute
    • vendor\webonyx\graphql-php\src\Executor\ReferenceExecutor.php executeOperation
    • vendor\webonyx\graphql-php\src\Executor\ReferenceExecutor.php resolveField
    • vendor\webonyx\graphql-php\src\Executor\ReferenceExecutor.php resolveOrError
sitemap | rss | atom

Copyright ©2022 f2h2h1 | All Rights Reserved

{"operationName":"Login","variables":{"email":"test.test.test@test.com","password":"passw!1234"},"query":"mutation Login($email: String\u0021, $password: String\u0021) {\\n generateCustomerToken(email: $email, password: $password) {\\n token\\n __typename\\n }\\n}\\n"}' \ --compressed -k --no-progress-meter 在数据库里运行这三句,就能直接生成 token 了 SELECT @customer_id := entity_id FROM `customer_entity` WHERE (email = 'test.test.test@test.com'); INSERT INTO oauth_token (consumer_id,admin_id,customer_id,`type`,token,secret,verifier,callback_url,revoked,authorized,user_type,created_at,partner_id) VALUES (NULL,NULL,@customer_id,'access',REPLACE(UUID(), '-', ''),REPLACE(UUID(), '-', ''),NULL,'',0,0,3,now(),NULL); select * from oauth_token where customer_id = @customer_id order by created_at desc limit 1; 可以用这句curl验证生成的token curl 'https://magento2.localhost.com/graphql?query=%20%20%20%20%20%20%20%20query%20\{%20%20%20%20%20%20%20%20%20%20customer%20\{%20%20%20%20%20%20%20%20%20%20%20%20email%20%20%20%20%20%20%20%20%20%20%20%20firstname%20%20%20%20%20%20%20%20%20%20%20%20lastname%20%20%20%20%20%20%20%20%20%20\}%20%20%20%20%20%20%20%20\}%20%20%20%20%20%20%20%20' \ -H 'authorization: Bearer 10ed30a93cdc11ee9d3b0e4ba1986f92' \ -H 'accept: */*' \ -H 'cache-control: no-cache' \ -H 'content-type: application/json' \ --cookie "XDEBUG_SESSION=vscode" \ --compressed -k --no-progress-meter 可以用 json_pp 来格式化 curl 的输出,前提是 curl 里的输出是 json 字符串 像这样 curl 'https://magento2.localhost.com/graphql?query=%20%20%20%20%20%20%20%20query%20\{%20%20%20%20%20%20%20%20%20%20customer%20\{%20%20%20%20%20%20%20%20%20%20%20%20email%20%20%20%20%20%20%20%20%20%20%20%20firstname%20%20%20%20%20%20%20%20%20%20%20%20lastname%20%20%20%20%20%20%20%20%20%20\}%20%20%20%20%20%20%20%20\}%20%20%20%20%20%20%20%20' \ -H 'authorization: Bearer 10ed30a93cdc11ee9d3b0e4ba1986f92' \ -H 'accept: */*' \ -H 'cache-control: no-cache' \ -H 'content-type: application/json' \ --cookie "XDEBUG_SESSION=vscode" \ --compressed -k --no-progress-meter | json_pp 如果在网页端已经登录的前提下,可以在网页的 console 里用这样的代码发送 graphql 请求 这一种是查询 (function(){ require(['jquery'], function($) { const query = ` query { customer { email firstname lastname } } `; // 改用 post 效果是一样的 $.ajax({ url: `${window.location.origin}/graphql`, type: "POST", contentType: "application/json", data: `{"query":${JSON.stringify(query)}}`, success: function(data){ console.log(data); } }); // $.ajax({ // url: `${window.location.origin}/graphql`, // type: "GET", // contentType: "application/json", // data: `query=`+query, // success: function(data){ // console.log(data); // } // }); }); })(); 这一种是修改 (function(){ const graphqlEndpoint = `${window.location.origin}/graphql`; const query = ` mutation CheckoutScreenReorder { reorder(increment_id: "123456789") { cartID: cart_id } } `; return fetch(`${graphqlEndpoint}`, { headers: { 'Content-Type': 'application/json', store: 'en_US' }, method: 'POST', body: `{"query":${JSON.stringify(query)}}`, }).then(response => { console.log(response); if (response.status == 200) { console.log(response.text()); } else { console.log(response.status); } }); })(); graphqlquery=$(cat <<- EOF query { customer { email firstname lastname } } EOF ); graphqlquery=$(echo -n $graphqlquery | php -r 'print(http_build_query(["query"=>file_get_contents("php://stdin")]));'); curl -v -L -k 'https://magento2.localhost.com/graphql?'$graphqlquery \ -H 'authorization: Bearer 214dee33a45a11eeae4800e04c947949' \ -H 'accept: */*' \ -H 'cache-control: no-cache' \ -H 'accept: application/json' \ --cookie "XDEBUG_SESSION=vscode" \ --resolve magento2.localhost.com:80:127.0.0.1 \ --resolve magento2.localhost.com:443:127.0.0.1 \ --no-progress-meter -->

浏览器可以安装这个拓展 https://github.com/altair-graphql/altair

这是 graphql 的中文文档 https://graphql.cn/

参考 https://devdocs.magento.com/guides/v2.4/graphql/index.html

新建索引器

magento 索引的运行原理

magento 的索引器有两种类型

两个和索引器相关的表

新建索引的步骤

  1. 在模块目录 etc 新建 inderx.xml

    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
        <indexer id="test_indexer" 
            view_id="test_indexer"
            class="Vendor\Extension\Model\Indexer\Test"
            >
            <title translate="true">test_indexer</title>
            <description translate="true">Test Indexer</description>
        </indexer>
    </config>
    
  2. 在模块目录 etc 新建 mview.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:framework:Mview/etc/mview.xsd">
        <view id="test_indexer"
            class="Vendor\Extension\Model\Indexer\Test"
            group="indexer" >
            <subscriptions>
                <table name="sales_order" entity_column="entity_id"/>
            </subscriptions>
        </view>
    </config>
    
    • view 节点的 id 对应 indexer.xml 里 indexer 节点的 view_id
    • view 节点的 class 和 indexer.xml 里 indexer 节点的 class 是一致的
    • subscriptions 是传递给 indexer class 的参数,是某一个表的某一列,可以是多个表
    • mview 是 materialized view 的缩写
  3. 在模块目录 model/indexer 新建 TestIndexer.php

    <?php
    namespace Vendor\Extension\Model\Indexer;
    
    class Test implements \Magento\Framework\Indexer\ActionInterface, \Magento\Framework\Mview\ActionInterface
    {
        /**
         * @inheritdoc
         */
        public function executeFull()
        {
            $this->reindex();
        }
    
        /**
         * @inheritdoc
         */
        public function executeList(array $ids)
        {
            $this->execute($ids);
        }
    
        /**
         * @inheritdoc
         */
        public function executeRow($id)
        {
            $this->execute([$id]);
        }
    
        /**
         * @inheritdoc
         */
        public function execute($ids)
        {
            $this->reindex($ids);
        }
    
        /**
         * @param int[] $ids
         * @return void
         */
        protected function reindex($ids = null)
        {
            if ($ids === null) { // 更新全部索引
    
            } else { // 根据传入的 id 更新索引
    
            }
        }
    }
    
  4. 运行这句命令重建索引

    php bin/magento indexer:reindex test_indexer
    
  5. 可以在后台里查看索引的状态

    后台 -> SYSTEM -> Index Management
    

相关命令

在定时任务中运行的 indexer

多数情况下 indexer 是以定时任务的形式运行的 (虽然也可以使用其它方式运行,但文档里的里的例子就是用定时任务的)

* * * * * php bin/magento cron:run --group=index

定时任务的配置文件在这个位置

vendor\magento\module-indexer\etc\crontab.xml

这个 crontab.xml 文件里有三个任务

因为是定时任务,所以可以用这样的 sql 观察到 indexer 的运行记录

SELECT * from cron_schedule
WHERE job_code in ('indexer_reindex_all_invalid', 'indexer_update_all_views', 'indexer_clean_all_changelogs')
order by schedule_id desc;

也可以往 cron_schedule 插入记录,让定时任务中的 indexer 尽快运行。定时任务有可能会 miss ,所以可以多插入几条记录。

INSERT INTO cron_schedule (job_code,status,created_at,scheduled_at)
VALUES
('indexer_update_all_views','pending',CURRENT_TIMESTAMP(), date_add(CURRENT_TIMESTAMP(), interval 1 minute)),
('indexer_update_all_views','pending',CURRENT_TIMESTAMP(), date_add(CURRENT_TIMESTAMP(), interval 2 minute)),
('indexer_update_all_views','pending',CURRENT_TIMESTAMP(), date_add(CURRENT_TIMESTAMP(), interval 3 minute)),
('indexer_reindex_all_invalid','pending',CURRENT_TIMESTAMP(), date_add(CURRENT_TIMESTAMP(), interval 5 minute)),
('indexer_clean_all_changelogs','pending',CURRENT_TIMESTAMP(), date_add(CURRENT_TIMESTAMP(), interval 10 minute));

可以用这样的 sql 来观察 indexer 的状态。直接运行 sql 语句比运行 命令行会快不少

select * from indexer_state where indexer_id = 'example_indexer';
select * from mview_state where view_id = 'example_indexer';
select * from view_id_cl; -- view_id 就是 mview.xml 中的 id

笔者在本地开发时,会用这样的命令确保定时任务一直在运行, 然后再往 cron_schedule 插入记录,让对应的 indexer 尽快执行。

php -r "while(true){exec('php bin/magento cron:run --group=index');sleep(3);}"

参考

https://developer.adobe.com/commerce/php/development/components/indexing/custom-indexer/

http://aqrun.oicnp.com/2019/11/10/12.magento2-indexing-reindex.html

新建定时任务

新建定时任务的步骤

  1. 在模块目录 etc 新建 crontab.xml
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
        <group id="default">
            <job name="order_complete_fulfillment_end_date_expire" instance="Vendor\Extension\Cron\Order\FulfillmentEndDateExpireCron" method="execute">
                <schedule>0 2 * * *</schedule>
            </job>
        </group>
    </config>
    
  2. 在模块目录里新建文件夹 cron
  3. 在 cron 文件夹里新建一个普通的类,并在这个类里实现一个没有参数的 execute 方法

任务组

在模块目录 etc 新建 cron_groups.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
    <group id="token_expired">
        <schedule_generate_every>1</schedule_generate_every>
        <schedule_ahead_for>4</schedule_ahead_for>
        <schedule_lifetime>15</schedule_lifetime>
        <history_cleanup_every>1440</history_cleanup_every>
        <history_success_lifetime>60</history_success_lifetime>
        <history_failure_lifetime>600</history_failure_lifetime>
        <use_separate_process>1</use_separate_process>
    </group>
</config>

group 节点的 id 对应 crontab.xml 里 config group 的 id

在后台的这个位置可以查看任务组

Stores > Settings > Configuration > ADVANCED > System -> Cron (Scheduled Tasks)

运行定时任务

修改过 cron 和 cron_groups 需要重新编译并清空缓存才会生效

php bin/magento setup:di:compile
php bin/magento cache:clean

运行全部任务组

php bin/magento cron:run

运行 default 任务组,一般的定时任务都在 default

php bin/magento cron:run --group=default

运行 index 任务组,这是索引器的任务组,就是 by schedule 类型的索引器

php bin/magento cron:run --group=index

运行其他任务组修改 --group 参数就可以了

然后让 cron:run 一直运行就可以的了,官方文档提供了使用 crontab 的例子,默认情况下队列好像也是用 crontab 运行。

* * * * * php bin/magento cron:run

magneto 还提供了自动生成 crontab 配置的命令

php bin/magento cron:install # 加上 magento 的 cron ,不影响其他配置
php bin/magento cron:install --force # 加上 magento 的 cron ,清除其他配置
php bin/magento cron:remove # 移除 magento 的 cron

运行了 cron:install 后,可以用 crontab -l 来查看

crontab -l
#~ MAGENTO START c5f9e5ed71cceaabc4d4fd9b3e827a2b
* * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/magento2/var/log/magento.cron.log
#~ MAGENTO END c5f9e5ed71cceaabc4d4fd9b3e827a2b

不同的 group 可以使用不同的 cron 表达式

* * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run --group=default 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/magento2/var/log/magento.cron.log
*/10 * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run --group=index 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/magento2/var/log/magento.cron.log

这是 crontab 配置的解释

自己写 crontab 配置或用其它方式(例如 supervisor )让 cron:run 一直运行也是可以的了

队列

新建一个插件 Plugins (Interceptors)

  1. 新建 Plugins 类

    • 通常在模块里的 Plugins 文件下新建
    • 拦截器就是一个普通的类
    • 拦截器的方法就是被拦截的方法前面加上 before around after 这三个关键词
      • 拦截器的方法名始终以小驼峰命名
    • 在原本的类里,只有 public 方法才可以被拦截
  2. 修改模块的 etc 文件夹下的 di.xml

    • 例子
      <config>
          <type name="需要拦截的类名(要填完整的类名)">
              <plugin name="拦截器名称" type="拦截器的类名(要填完整的类名)" sortOrder="排序" disabled="false" />
          </type>
      </config>
      
    • 如果要禁用拦截器 disabled 填 true 就可以了
    • sortOrder 和 disabled 都不是必填的
    • sortOrder 是升序排序
    • sortOrder 未指定时会按加载顺序排序,先加载的在前面执行
  3. 运行 php bin/magento setup:di:compile 或 php bin/magento setup:upgrade

    1. 拦截器必须通过编译才能生效
    2. 编译后的拦截器会在 generate 文件夹里生成一个对应的 Interceptor 类
    3. 在开发者模式时可以不通过编译,拦截器在运行时生成
    4. 生成的 Interceptor 类,通过 use 的方式继承 \Magento\Framework\Interception\Interceptor
    5. \Magento\Framework\Interception\Interceptor 的 ___callPlugins 方法是拦截器实现的核心
  4. 拦截器的运行顺序

    1. before -> around -> after
    2. 会先统一执行完一类拦截器再执行下一类拦截器
    3. 拦截器的顺序,就是配置文件里的那个 sortOrder 参数是用在同类拦截器的排序的
  5. 三种方法的入参和出参

    • before
      • 入参
        • 原本的对象 $subject
        • 原本的入参(这是一个可变长参数 ...array_values($arguments))
      • 出参
        • null 或 一个数组
        • 如果是 null 那么 原本的入参不会变
        • 如果是一个数组,那么数组会替代原本的入参
    • around
      • 入参
        • 原本的对象 $subject
        • 匿名函数proceed(拦截器运行的匿名方法)
          • proceed 在拦截器的around方法里运行
        • 原本的入参(这是一个可变长参数 ...array_values($arguments))
      • 出参
        • 和执行结果类型一样的$result
    • after
      • 入参
        • 原本的对象 $subject
        • 执行的结果 $result
        • 原本的入参(这是一个可变长参数 ...array_values($arguments))
      • 出参
        • 和执行结果类型一样的 $result
  6. 参考 https://developer.adobe.com/commerce/php/development/components/plugins/

替换其它模块里的类

事件和观察者 (Events and Observers)

  1. 在配置文件里声明一个事件
    • 在模块的 etc 文件夹下的 events.xml ,加上类似于这样的一段
      <?xml version="1.0"?>
      <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
          <event name="customer_save_after_data_object">
              <observer name="upgrade_order_customer_email" instance="Magento\Customer\Observer\UpgradeOrderCustomerEmailObserver"/>
              <observer name="upgrade_quote_customer_email" instance="Magento\Customer\Observer\UpgradeQuoteCustomerEmailObserver"/>
          </event>
      </config>
      
  2. 新建一个观察者类
    • 新建的观察者类的完整类名需要和配置文件里的对应
    • 需要实现这个接口 Magento\Framework\Event\ObserverInterface
  3. 在需要的位置触发事件,类似于这样
    // 第一个参数是事件名;第二个参数是一个数组,用于传递参数给观察者
    // $this->_eventManager 的类型 \Magento\Framework\Event\ManagerInterface
    $this->_eventManager->dispatch(
        'admin_user_authenticate_after',
        ['username' => $username, 'password' => $password, 'user' => $this, 'result' => $result]
    );
    
  4. 参考 https://developer.adobe.com/commerce/php/development/components/events-and-observers/

新建一个后台视图

  1. 新建路由
    • 在 etc 文件夹下新建 adminhtml 文件夹,在 adminhtml 下新建 routes.xml 并写入以下内容
      <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
          <router id="admin">
              <route id="partnercode" frontName="partnercode">
                  <module name="LocalDev_HelloModule" />
              </route>
          </router>
      </config>
      
  2. 视图是一个 xml 文件
    • 视图的命名是根据路由来的
    • 例如
      • 视图名是 partnercode_couponquota_index.xml 对应的路由是 partnercode/couponquota/index
      • 路由是 partnercode/couponquota/index 对应的控制器是 partnercode/Controller/Adminhtml/Couponquota/Index.php
    • 视图文件一般放在这几个位置
      • 后台的视图 模块/view/adminhtml/layout/视图名.xml
      • 前台的视图 模块/view/frontend/layout/视图名.xml
      • 通用的视图 模块/view/base/layout/视图名.xml
    • 这是一个视图的例子
      <?xml version="1.0"?>
      <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
          <body>
              <referenceContainer name="content">
                  <block class="Magento\Backend\Block\Template" name="extension.coupon_quota.grid.container" template="Vendor_Extension::coupon_quota/index.phtml"/>
              </referenceContainer>
          </body>
      </page>
      
    • block 节点参数的解释
      • block 要填完整的类名,如果不是自定义的 block ,就填 \Magento\Backend\Block\Template 或 \Magento\Framework\View\Element\Template
      • name 可以随便填,但最好全局唯一
      • template 填模板的路径,是 模块名::模板的相对路径,可以参考下面的例子
        • 如果视图的绝对路径是 app\code\Vendor\Extension\view\adminhtml\layout\partnercode_couponquota_index.xml
        • 如果模板的绝对路径是 app\code\Vendor\Extension\view\adminhtml\templates\coupon_quota\index.phtml
        • 那么在 template 里的值就填 Vendor_Extension::coupon_quota/index.phtml
  3. 视图由 block 组成
    • blcok 是 php 对象
    • 自定义的 block 一般放在 模块/block 这个文件夹里
      • 后台的 block 就要继承这个类 \Magento\Backend\Block\Template
      • 前台的 block 就要继承这个类 \Magento\Framework\View\Element\Template
  4. 每个 block 会有一个模板对应,也就是 phtml 后缀的文件。
    • 这是一个模板的例子
      <?php
      /** @var \Magento\Framework\View\Element\Template $block */
      ?>
      <p><?=$block->getBaseUrl()?></p>
      

在后台视图里新建一个表格

添加后台日志

在模块的 etc 文件夹下的 logging.xml 里加上类似这样的一段

<group name="order_retrievepayment">
    <label translate="true">Order Retrieve Payment</label>
    <expected_models>
        <expected_model class="Magento\Sales\Model\Order"></expected_model>
    </expected_models>
    <events>
        <event controller_action="adminportal_order_retrievepayment" action_alias="save" />
    </events>
</group>

如果是 post 请求,那么需要在 event 节点里再加一个属性 post_dispatch="postDispatchSimpleSave"

<event controller_action="adminportal_order_retrievepayment" action_alias="save" post_dispatch="postDispatchSimpleSave"/>

controller_action 是 模块名_控制器名_方法名 可以在这两个位置加断点,然后再运行一次请求,就知道具体的 controller_action 是什么了

vendor\magento\module-logging\Observer\ControllerPostdispatchObserver.php:52
vendor\magento\module-logging\Model\Processor.php:363

然后在后台里勾选对应的选项,按着这样的路径寻找

Stores
    Settings
        Configuration
            Advanced
                Admin
                    Admin Actions Logging
                        在配置文件里的 label

可以在后台里的这个位置查看日志

system -> action logs -> report

日志会插入到这个表里 magento_logging_event

后台 acl

  1. 修改在模块的 etc 文件夹下的 acl.xml
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
        <acl>
            <resources>
                <resource id="Magento_Backend::admin">
                    <resource id="Magento_Sales::sales">
                        <resource id="Magento_Sales::sales_operation">
                            <resource id="Magento_Sales::sales_order">
                                <resource id="Vendor_Extension_AdminPortal::cs_portal" title="CS Portal" sortOrder="10" />
                                <resource id="Magento_Sales::create_new_order" title="Create New Order" sortOrder="20" />
                                <resource id="Magento_Sales::view_order" title="View Order" sortOrder="30" />
                                <resource id="Magento_Sales::order_actions" title="Order Actions" sortOrder="40" />
                                <resource id="Magento_Sales::go_to_archive" title="Go To Order Archive" sortOrder="50" />
                            </resource>
                        </resource>
                    </resource>
            </resources>
        </acl>
    </config>
    
    • resource 可以嵌套
    • resource id(模块::操作),这个 resource id 要和控制器定义的 ADMIN_RESOURCE 一致
    • 控制器里有一个常量
      class Save extends Action
      {
          public const ADMIN_RESOURCE = 'Magento_Customer::save';
      }
      
  2. 修改完后要清除缓存才能生效 php bin/magento cache:clean
  3. 权限的调整在这个位置 System > Permissions > User Roles
  4. 参考 https://developer.adobe.com/commerce/php/best-practices/tutorials/create-access-control-list-rule/

新建一个后台菜单

  1. 在模块目录下的 etc 文件里新建一个文件
    module/etc/adminhtml/menu.xml
    
  2. 在 menu.xml 里加入一段
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
        <menu>
            <add id="Silk_Test::job_head" title="Test" module="Silk_Test" sortOrder="100" parent="Magento_Backend::stores" resource="Silk_Test::job_head" />
            <add id="Silk_Test::job" title="Test" module="Silk_Test" sortOrder="20" parent="Silk_Test::job_head" action="test/job" resource="Silk_Test::job" />
        </menu>
    </config>
    
  3. 一些参数的解释
    • parent 上级的id
    • title 菜单名称
    • id 唯一识别的id
    • action 转跳的 action ,不填这个就是菜单里的一个分类
    • resource 用于 acl 的
    • module 模块名
  4. 参考 https://developer.adobe.com/commerce/php/best-practices/tutorials/create-access-control-list-rule/

一些自定义配置

写在模块的 etc/config.xml 文件里

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <general>
            <file>
                <bunch_size>1000</bunch_size>
            </file>
        </general>
    </default>
</config>

写在 core_config_data 表里

INSERT INTO core_config_data (`scope`,scope_id,`path`,value,updated_at) VALUES ('default',0,'general/file/bunch_size','1000', NOW());

上面两种写法效果是一样的, 可以这样获取配置的值

/** @var \Magento\Framework\App\Config\ScopeConfigInterface */
$scopeConfig = \Magento\Framework\App\ObjectManager::getInstance()->get(Magento\Framework\App\Config\ScopeConfigInterface::class);
$scopeConfig->getValue('general/file/bunch_size');

还可以用命令行来修改配置,这种修改会保存在数据库里 https://experienceleague.adobe.com/docs/commerce-operations/configuration-guide/cli/configuration-management/set-configuration-values.html

# 设置某个配置
php bin/magento config:set path value
# 查看某个配置
php bin/magento config:show path

数据库的优先级会更高。

修改过配置项的值后,需要清空或刷新缓存才会生效(不论是 config.xml 的配置还是数据库里的配置)。

在后台加上配置项

通常是写在模块的 etc/adminhtml/system.xml 文件里

后台的配置也是用上面额方法获取配置的值,后台配置的默认值也是写在 etc/config.xml 文件里

一个例子

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="test_section" showInDefault="1" showInWebsite="1" showInStore="1">
            <group id="test_group" translate="label" showInDefault="1" showInWebsite="1" showInStore="1" sortOrder="11">
                <label>test group</label>
                <field id="test_field" translate="label" type="textarea" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>test field</label>
                    <comment>test comment</comment>
                </field>
            </group>
        </section>
    </system>
</config>

参考 https://experienceleague.adobe.com/docs/commerce-operations/configuration-guide/files/config-reference-systemxml.html

前端

缓存

发送邮件

使用模板

  1. 在 etc 下新建 email_templages.xml 文件
    <?xml version="1.0"?>
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd">
        <template id="self_check_order_confirmation_email" label="Self Check Order Confirmation Eamil" file="self_check_order_confirmation_email.html" type="html" module="LocalDev_HelloModule" area="adminhtml"/>
    </config>
    
  2. 在 view/area代码/email 下新建模板文件,文件名和area代码要和 email_templages.xml 里的对应
    <!--@subject {{var subject}}  @-->
    <p>{{var mail_content}}</p>
    
  3. 在 php 的代码里这样调用
    try {
        require __DIR__ . '/app/bootstrap.php';
        $bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
        $objectManager = $bootstrap->getObjectManager();
        $state = $objectManager->get(\Magento\Framework\App\State::class);
        $state->setAreaCode(\Magento\Framework\App\Area::AREA_CRONTAB);
    
        $appConfig = $objectManager->get(\Magento\Framework\App\Config\ScopeConfigInterface::class);
        $transportBuilder = $objectManager->get(\Magento\Framework\Mail\Template\TransportBuilder::class);
    
        $templateIdentifier = 'self_check_order_confirmation_email'; // 要和 email_templages.xml 里的 id 对应
        $templateVars = [
            'subject'   => '123',
            'mail_content' => '321'
        ];
        $templateOptions = [
            'area' => \Magento\Backend\App\Area\FrontNameResolver::AREA_CODE,  // 要和 email_templages.xml 里的 area代码 对应
            'store' => \Magento\Store\Model\Store::DEFAULT_STORE_ID,
        ];
        $sender = [
            'name' => $appConfig->getValue('trans_email/ident_general/name'),
            'email' => $appConfig->getValue('trans_email/ident_general/email'),
        ];
        $transportBuilder
            ->setTemplateIdentifier($templateIdentifier)
            ->setTemplateVars($templateVars)
            ->setTemplateOptions($templateOptions)
            ->setFrom($sender);
        $to = [
            '001@example.com',
            '002@example.com',
            '003@example.com',
        ];
        foreach ($to as $item) {
            $transportBuilder->addTo($item);
        }
        $transportBuilder->getTransport()->sendMessage();
    } catch (\Throwable $e) {
        echo $e->getFile() . ':' . $e->getLine() . PHP_EOL;
        echo $e->getMessage() . PHP_EOL . $e->getTraceAsString();
    }
    

不使用模板

try {
    require __DIR__ . '/app/bootstrap.php';
    $bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
    $objectManager = $bootstrap->getObjectManager();
    $state = $objectManager->get(\Magento\Framework\App\State::class);
    $state->setAreaCode(\Magento\Framework\App\Area::AREA_CRONTAB);

    $message = $objectManager->get(\Magento\Framework\Mail\Message::class);
    $appConfig = $objectManager->get(\Magento\Framework\App\Config\ScopeConfigInterface::class);

    $message->setSubject('Hello from Bing');
    $message->setBodyHtml('<p>This is a test email sent by Bing using PHP mail function.</p>');
    // $message->setBodyText('This is a test email sent by Bing using PHP mail function.');
    $message->setFromAddress(
        $appConfig->getValue('trans_email/ident_general/email'),
        $appConfig->getValue('trans_email/ident_general/name')
    );

    $to = [
        '001@example.com',
        '002@example.com',
        '003@example.com',
    ];
    foreach ($to as $item) {
        $message->addTo($item);
    }

    (new \Laminas\Mail\Transport\Sendmail())->send(
        \Laminas\Mail\Message::fromString($message->getRawMessage())
    );
} catch (\Throwable $e) {
    echo $e->getFile() . ':' . $e->getLine() . PHP_EOL;
    echo $e->getMessage() . PHP_EOL . $e->getTraceAsString();
}

Transport

magento2 在默认情况下 使用 PHP 的 Sendmail 函数来发送邮件,就是调用系统里的 sendmail ,只能设置 host 和 port

magento2.3 之后也支持 smtp 了,在这个位置设置,可以选择 sendmail 和 smtp

Stores > Settings > Configuration > Advanced > System > Mail Sending Settings > Transport

magento2.3 之前的版本可以用这个模块来实现 SMTP 发送邮件 https://www.mageplaza.com/magento-2-smtp/

magento2 使用这个库来发送邮件的 https://github.com/laminas/laminas-mail

在本地测试邮件可以参考这篇文章《在Windows下配置PHP服务器》的这个章节 mailpit

sendmail 和 smtp 两种方式都可以用 mailpit 来测试, mailpit 可以忽略 smtp 的账号密码

一些调试技巧

获取某一个对象

// 从已存在的对象中获取
$logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class);
// 新建一个
$logger = \Magento\Framework\App\ObjectManager::getInstance()->create(\Psr\Log\LoggerInterface::class);
// 获取一个普通的对象
/** @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory */
$orderCollectionFactory = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Sales\Model\ResourceModel\Order\CollectionFactory::class);
$orderId = 3068;
$orderCollection = $orderCollectionFactory->create();
$orderCollection->addFieldToFilter('entity_id', $orderId); // 可以修改条件
/** @var \Magento\Sales\Model\Order */
$order = $orderCollection->getFirstItem(); // $orderCollection->getItems(); // 获取集合
$objectManager = \Magento\Framework\App\ObjectManager::getInstance();

// 根据 customer id 或 email 获取 customer 对象
/** @var \Magento\Customer\Model\CustomerFactory */
$customerFactory = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Customer\Model\CustomerFactory::class);
$customer = $customerFactory->create()->load($customerID);
$customer = $customerFactory->create()->loadByEmail($email);

// 获取某个 customer 的购物车
$quote = $customer->getQuote();

// 获取某个 customer 最近成功支付的订单
/** @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory */
$orderCollectionFactory = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Sales\Model\ResourceModel\Order\CollectionFactory::class);
$orderCollection = $orderCollectionFactory->create();
$orderCollection->addFieldToFilter('customer_id', $customer->getId());
$orderCollection->addFieldToFilter('state', ['in' => [
    \Magento\Sales\Model\Order::STATE_PROCESSING,
    \Magento\Sales\Model\Order::STATE_COMPLETE
]]);
$orderCollection->setOrder('created_at');
$orderCollection->setPageSize(1);
$order = $orderCollection->getFirstItem();

// 根据 productId 获取 product 对象
/** @var \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory */
$productCollectionFactory = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Catalog\Model\ResourceModel\Product\CollectionFactory::class);
$productCollection = $productCollectionFactory->create();
$productCollection->addFieldToFilter(
    'entity_id', ['in' => $productId]
    // 'sku', ['eq' => $sku]
);
$productCollection->setPageSize(1);
$product = $productCollection->getFirstItem();

在某一个位置写日志

/** @var \Psr\Log\LoggerInterface */
$logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class);
$logger->warning('=======flg debug=======', ['trace' => $a]);
$logger->warning('=======flg debug=======', ['trace' => $exception->getTrace(), 'msg' => $exception->getMessage()]);
$logger->warning('=======flg debug=======', ['trace' => debug_backtrace()]);

$logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class);
$logger->warning('=======flg debug=======' . PHP_EOL . __FILE__ . ':' . __LINE__ . PHP_EOL, ['trace' => $a]);

在某一个位置通过拼接的 sql 查询数据库

/**
 * @var \Magento\Framework\App\ResourceConnection
 */
$conn = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\App\ResourceConnection::class);
$conn = $conn->getConnection();
$select = $conn->select()
    ->from(['so' => $conn->getTableName('sales_order')], [
        'so.entity_id',
        'so.customer_id',
        'soi.fulfilment_end_at',
    ])
    ->joinLeft(
        ['soi' => $conn->getTableName('sales_order_item')],
        'so.entity_id=soi.order_id',
    );
$select->where("so.status = ?", \Magento\Sales\Model\Order::STATE_PROCESSING)
    ->where("soi.qty_fulfilled + soi.qty_disabled + soi.qty_markoff < soi.qty_invoiced")
    ->where("soi.fulfilment_start_at <= ? ", time());
$result = $conn->fetchAll($select);

// 直接运行 sql 语句
$conn = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\App\ResourceConnection::class);
$result = $conn->getConnection()->query('SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP);')->fetchAll();
$result = $conn->getConnection()->query("update sales_order set status = 'complete', state = 'complete' where entity_id = 123456;")->execute();

通过某一个模型的 collection 对象

/** @var \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection */
$collection = $collectionFactory->create();
$collection->addFieldToSelect(
    '*'
)->addFieldToFilter('customer_id', $customer->getId());

输出原始的 sql 语句

/** @var \Magento\Framework\DB\Select $select */
echo $select->__toString();

/** @var \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection $collection */
echo $collection->getSelect()->__toString();
echo $collection->getSelectSql(true);

sql 的执行记录

加在这个文件里 app/etc/env.php 加上这段

'db_logger' => [
    'output' => 'file',
    'log_everything' => 1,
    'query_time_threshold' => '0.001',
    'include_stacktrace' => 0 // 改成1可以记录代码调用栈
],

日志会输出到这个文件里 var/debug/db.log

sql 语句最终的执行位置

通过 composer 安装的
vendor\magento\zendframework1\library\Zend\Db\Adapter\Abstract.php query
通过 github 安装的
vendor\magento\zend-db\library\Zend\Db\Adapter\Abstract.php query

写日志,并记录调用栈堆

# region logsql
$logOpen = false;
// $logOpen = true;
$trace = debug_backtrace();
$basePath = BP . DIRECTORY_SEPARATOR;
if (!defined('DEBUG_TRACE_LOG')) {
    $logpath = $basePath . 'var' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR . 'debug_trace_sql';
    if (!is_dir($logpath)) {
        mkdir($logpath, 0755, true);
    }
    define('DEBUG_TRACE_LOG', $logpath . DIRECTORY_SEPARATOR . date('ymdHis') . '.log');
    $data = [
        '_POST' => $_POST ?? null,
        '_GET' => $_GET ?? null,
        '_FILES' => $_FILES ?? null,
        '_SERVER' => $_SERVER ?? null,
        '_SESSION' => $_SESSION ?? null,
        '_input' => file_get_contents("php://input"),
        // '_stdin' => file_get_contents("php://stdin") // 这一句在命令行里会等待输入
    ];
    $msg = print_r($data, true) . '========' . PHP_EOL;
    if ($logOpen) {
        file_put_contents(
            DEBUG_TRACE_LOG,
            $msg,
            FILE_APPEND
        );
    }
}
$ignore = [ // 忽略 ObjectManager 的文件, Interceptor 的文件, Factory 的文件, Event 的文件
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'Interception' . DIRECTORY_SEPARATOR . 'Interceptor.php',
    'generated',
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'ObjectManager' . DIRECTORY_SEPARATOR . 'Factory',
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'ObjectManager' . DIRECTORY_SEPARATOR . 'ObjectManager.php',
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'Event' . DIRECTORY_SEPARATOR . 'Manager.php',
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR . 'Event' . DIRECTORY_SEPARATOR . 'Invoker' . DIRECTORY_SEPARATOR . 'InvokerDefault.php',
    'vendor' . DIRECTORY_SEPARATOR . 'magento' . DIRECTORY_SEPARATOR . 'module-staging' . DIRECTORY_SEPARATOR . 'Model' . DIRECTORY_SEPARATOR . 'Event' . DIRECTORY_SEPARATOR . 'Manager.php',
];
$pattern = array_map(function($item) use ($basePath) {
    return '(' . preg_quote($basePath . $item, '/') . ')';
}, $ignore);
$pattern = '/' . implode('|', $pattern) . '/im';
$max = 200;
$traceRecord = [];
// $traceRecord[] = __FILE__ . ':' . __LINE__;
for ($i = 0, $len = count($trace); $i < $max && $i < $len; $i++) {
    if (isset($trace[$i]['file'])) {
        if (!preg_match($pattern, $trace[$i]['file'])) {
            $file = $trace[$i]['file'];
            $line = $trace[$i]['line'] ?? '1';
            $class = $trace[$i]['class'] ?? '';
            $func = $trace[$i]['function'] ?? '';
            $record = $file . ':' . $line . ' ' . $class . ' ' . $func;
            $traceRecord[] = $record;
        }
    }
}
$msg = print_r([
    $sql,
    count($bind) < 1 ? null : $bind,
    $traceRecord,
], true) . '========' . PHP_EOL;
if ($logOpen) {
    $filer = [ // 通过正则表达式只记录某些语句
        // '`customer_entity`',
        // '`customer_address_entity`',
        // '`quote_address`',
        // '`salesrule`',
        // '`salesrule_coupon`',
        // '`salesrule_customer`',
        // '^SELECT'
        // 'customer_is_guest',
    ];
    $regexp = '';
    if (is_array($filer) && count($filer) > 0) {
        $filer = implode('|', $filer);
        $regexp = '/' . $filer . '/';
    }
    if (empty($regexp) || filter_var($sql, FILTER_VALIDATE_REGEXP, array("options" => array("regexp" => $regexp)))) {
        file_put_contents(
            DEBUG_TRACE_LOG,
            $msg,
            FILE_APPEND
        );
    }
}
# endregion logsql

这一段是硬写在这个方法里的,也可以硬写到其它方法里

vendor\magento\zendframework1\library\Zend\Db\Adapter\Abstract.php query

文件搜索

通过正则表达式搜索某个接口的实现类或某个对象的继承类

implements(?:.*)ObjectManagerInterface\n
extends(?:.*)AbstractResource\n

搜索时的排除选项

.js,.css,.md,.txt,.json,.csv,.html,.less,.phtml,**/tests,**/test,**/Test,**/setup,**/view,**/magento2-functional-testing-framework,.wsdl,**/module-signifyd,**/Block,pub,generated,var,dev
app/code/Vendor/**/*.php
app/**/*Test.php
magento/**/*.php

通过命令行运行一些测试的代码

修改这个文件的 execute 方法,用 exit(0); 来结束

vendor/magento/module-indexer/Console/Command/IndexerInfoCommand.php

例子

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $objectManager = \Magento\Framework\App\ObjectManager::getInstance();

        /** @var \Magento\Framework\App\State */
        $appState = $objectManager->get(\Magento\Framework\App\State::class);
        try { // 没有这句很容易会出现 Area code is not set 的错误
            $appState->setAreaCode(\Magento\Framework\App\Area::AREA_ADMINHTML);
        } catch (\Exception $e) {
        }

        // 可以尝试这样更改 store view
        // /** @var \Magento\Store\Model\StoreManagerInterface */
        // $storeManager =  $objectManager->get(\Magento\Store\Model\StoreManagerInterface::class);
        // $storeManager->setCurrentStore('zh_Hans_CN');

        /** @var \Magento\Framework\App\ResourceConnection */
        $connection = $objectManager->get(\Magento\Framework\App\ResourceConnection::class);
        $conn = $connection->getConnection();

        /** @var \Mageplaza\SocialLogin\Model\Social */
        $social = $objectManager->get(\Mageplaza\SocialLogin\Model\Social::class);
        $customer = $social->getCustomerByEmail('qwe@asd.com');

        /** @var \Magento\Quote\Model\QuoteFactory */
        $quoteFactory = $objectManager->get(\Magento\Quote\Model\QuoteFactory::class);
        $quote = $quoteFactory->create();
        $quote->setCustomer($customer->getDataModel());
        $address = $quote->getShippingAddress();
        var_dump($address->getCity());

        exit(0);

        $indexers = $this->getAllIndexers();
        foreach ($indexers as $indexer) {
            $output->writeln(sprintf('%-40s %s', $indexer->getId(), $indexer->getTitle()));
        }
    }

运行命令

php bin/magento indexer:info
php -d xdebug.remote_autostart=on bin/magento indexer:info
php -d xdebug.start_with_request=yes bin/magento indexer:info

通过命令行运行测试代码,可以不加载前端资源,反馈的速度更快。 修改原本的命令行是为了不运行构建的命令就能生效。 一些对象可以通过 \Magento\Framework\App\ObjectManager::getInstance()->get() 的方法获得。 indexer:status 的输出就包含了 indexer:info 的输出。

直接运行测试代码,要在项目的根目录里运行,但这种方式无法调试,这种运行方式很容易忽略一些模块的 plugin 或 event

php -a <<- 'EOF'
try {
// 引入 magento2 的引导文件
require __DIR__ . '/app/bootstrap.php';
// 创建一个应用对象
$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
// 获取一个对象管理器
$objectManager = $bootstrap->getObjectManager();

// 如果出现这种错误 area code is not set ,则加上这两句, area 的值可以根据实际场景修改
$state = $objectManager->get(\Magento\Framework\App\State::class);
$state->setAreaCode(\Magento\Framework\App\Area::AREA_FRONTEND);

// 获取一个文件系统对象
$fileSystem = $objectManager->get(\Magento\Framework\Filesystem::class);
// 获取临时目录的路径
$tempDir = $fileSystem->getDirectoryRead(\Magento\Framework\App\Filesystem\DirectoryList::TMP)->getAbsolutePath();
// 输出路径
echo $tempDir;
} catch (\Throwable $e) {
    echo $e->getFile() . ':' . $e->getLine() . PHP_EOL;
    echo $e->getMessage() . PHP_EOL . $e->getTraceAsString();
}
EOF

前端的调试

修改后台的帐号密码

笔者在二次开发 magento2 的过程中,登录后台时总是失败, magento2 似乎有一套很 混乱复杂 的规则来限制后台的登录。

这里记录一下通过修改数据库里对应的表来完成登录。 这些记录可能会随着magento的更新而失效

和后台登录相关的表

admin_passwords
admin_user
admin_user_expiration

顺利登录时各个字段的状态

用于观察的 sql

select
    admin_user.user_id,
    admin_user.firstname,
    admin_user.lastname,
    admin_user.email,
    admin_user.username,
    admin_user.is_active,
    admin_user.lognum,
    admin_user.failures_num,
    admin_user.first_failure,
    admin_user.lock_expires,
    admin_user.password,
    admin_passwords.password_id,
    admin_passwords.password_hash,
    admin_passwords.expires,
    FROM_UNIXTIME(admin_passwords.expires),
    admin_passwords.last_updated,
    FROM_UNIXTIME(admin_passwords.last_updated)
from admin_user
left join admin_passwords on admin_user.user_id = admin_passwords.user_id
WHERE admin_user.email = 'admin@example.com'
order by admin_passwords.password_id desc limit 1;

用于更新的 sql

-- 更新 admin_user
UPDATE admin_user
SET
    is_active=1,
    failures_num=0,
    first_failure=NULL,
    -- lock_expires=NULL,
    lock_expires=date_add(now(), interval -3 day),
    modified=current_timestamp()
where admin_user.email = 'admin@example.com';

-- 更新 admin_passwords
UPDATE admin_passwords
SET
    expires=0,
    last_updated=unix_timestamp(now())
where password_id = (
    select * from (
        select password_id
        from admin_passwords
        left join admin_user on admin_user.user_id = admin_passwords.user_id
        where admin_user.email = 'admin@example.com'
        order by admin_passwords.password_id desc
        limit 1
    ) as t
);

-- 删除 admin_user_expiration 里对应的记录
DELETE FROM admin_user_expiration
WHERE user_id = (
    select user_id
    from admin_user
    where email = 'admin@example.com'
    limit 1
);

生成新的密码

// 直接生成一个密码,在命令行里是用,只运行一次,因为重置了key,可能会使其他逻辑混乱
// 输出的值,填到 admin_user.password 和 admin_passwords.password_hash
/** @var \Magento\Framework\App\ObjectManager */
$objectManager = \Magento\Framework\App\ObjectManager::getInstance();
/** @var \Magento\Framework\Encryption\Encryptor */
$encryptor = $objectManager->get(\Magento\Framework\Encryption\Encryptor::class);
/** @var \Magento\Framework\App\DeploymentConfig */
$deploymentConfig = $objectManager->get(\Magento\Framework\App\DeploymentConfig::class);
$cryptkey = preg_split('/\s+/s', trim((string)$deploymentConfig->get('crypt/key')))[0]; // 本地的 key
$cryptkey = '4oyi2yvpl8kx3sh9e4u05vnql41kn8fa'; // crypt/key ,其它的 key ,可能会在本地生成用于线上环境的 password
$encryptor->setNewKey($cryptkey);
$password = 'password#12345678'; // 新的密码
echo $encryptor->getHash($password, true, $encryptor::HASH_VERSION_ARGON2ID13_AGNOSTIC);
exit(0);

通过命令行新建管理员

php bin/magento admin:user:create --admin-user="360magento" --admin-password="Admin@123" --admin-email="admin@360magento.com" --admin-firstname="MyFirstName" --admin-lastname="MyLastName"

分配角色给刚刚新建的用户

INSERT INTO magento_preprod.authorization_role
(parent_id,tree_level,sort_order,role_type,user_id,user_type,role_name,gws_is_all,gws_websites,gws_store_groups)
select
    1,2,0,'U',user_id,'2',username,1,NULL,NULL
from admin_user
where admin_user.username = '360magento';

通过在数据库里插入记录来新建管理员

其实就是在这三表表插入对应的记录
admin_user
admin_passwords
authorization_role

在后台新建客户(customer)

和权限相关的表

authorization_role
authorization_rule

sales_order 表的两个状态

打补丁

从 marketplace.magento.com 下载和安装拓展

  1. 登录
  2. 购买
  3. 获取包名和版本
  4. 修改 composer.json 加上 仓库地址和帐号密码
  5. 运行 composer require
  6. 修改 app/etc/config.php
  7. 运行 bin/magento setup:upgrade
  8. 参考 https://devdocs.magento.com/extensions/install

常见的 magento 扩展供应商

参考

中文文档 https://experienceleague.adobe.com/docs/commerce.html?lang=zh-Hans

github 里 magento2 的模块例子

https://developer.adobe.com/commerce/php/architecture/

生成 magento 模块 https://cedcommerce.com/magento-2-module-creator/

https://devdocs.magento.com/guides/v2.4/extension-dev-guide/module-development.html

http://www.wps.team/book/magento2/