Help us learn about your current experience with the documentation. Take the survey.

---
stage: none
group: unassigned
info: 拥有至少Maintainer角色的用户可以合并此内容的更新。详情请参阅 https://docs.gitlab.com/development/development_processes/#development-guidelines-review。
title: 企业版功能实施指南
---

- **将代码放在`ee/`目录下**:将所有企业版(EE)代码放入顶级`ee/`目录。其余代码必须尽可能接近社区版(CE)文件。

- **编写测试**:与任何代码一样,EE功能必须有良好的测试覆盖率以防止回归。所有`ee/`代码必须在`ee/`中有对应的测试。

- **编写文档**:在`doc/`目录中添加文档。描述该功能并包含截图(如果适用)。注明[适用的版本](documentation/styleguide/availability_details.md)。

<!-- markdownlint-disable MD044 -->
- **向[`www-gitlab-com`](https://gitlab.com/gitlab-com/www-gitlab-com)项目提交合并请求(MR)**:将该新功能添加到[EE功能列表](https://about.gitlab.com/features/)中。
<!-- markdownlint-enable MD044 -->

## 开发中的运行时模式

1. **未授权的企业版**:这是从普通GDK安装中获得的状态,如果你从[主仓库](https://gitlab.com/gitlab-org/gitlab)安装
1. **已授权的企业版**:当你[向GDK添加有效许可证](https://gitlab.com/gitlab-org/customers-gitlab-com/-/blob/main/doc/setup/gitlab.md#adding-a-license-from-staging-customers-portal-to-your-gdk)时
1. **GitLab.com SaaS**:当你[模拟SaaS实例](#simulate-a-saas-instance)时
1. **社区版**:在上述任何状态下,当你[模拟社区版实例](#simulate-a-ce-instance-with-a-licensed-gdk)时

## 仅适用于SaaS的功能

开发仅适用于SaaS的功能(例如CustomersDot集成)时,请遵循以下指南。

一般来说,功能应同时提供给[ both SaaS and self-managed deployments ](https://handbook.gitlab.com/handbook/product/product-principles/#parity-between-saas-and-self-managed-deployments)。然而,有些情况下功能应仅在SaaS上可用,本指南将帮助说明如何实现这一点。

建议使用`Gitlab::Saas.feature_available?`。这能围绕功能仅为SaaS的原因提供丰富的上下文定义。

### 使用`Gitlab::Saas.feature_available?`实现仅SaaS功能

#### 添加到FEATURES常量中

1. 参见[命名空间概念指南](software_design.md#use-namespaces-to-define-bounded-contexts),帮助为新SaaS功能命名。
1.`ee/lib/ee/gitlab/saas.rb`中将新功能添加到`FEATURE`中:

   ```ruby
   FEATURES = %i[purchases_additional_minutes some_domain_new_feature_name].freeze
  1. 在代码中使用新功能:Gitlab::Saas.feature_available?(:some_domain_new_feature_name)

仅SaaS功能的定义与验证

此过程旨在确保代码库中SaaS功能的一致性使用。所有SaaS功能必须

  • 已知。仅使用明确声明的SaaS功能。
  • 有负责人。

所有SaaS功能都在YAML文件中自我记录,存储于:

每个SaaS功能在一个单独的YAML文件中定义,包含多个字段:

字段 必需 描述
name SaaS功能的名称。
introduced_by_url 引入该SaaS功能的合并请求URL。
milestone 创建该SaaS功能的里程碑。
group 拥有该功能标志的

#### 创建新的SaaS功能文件定义

GitLab代码库提供了[`bin/saas-feature.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/bin/saas-feature.rb),这是一个专用工具,用于创建新的SaaS功能定义。
该工具会询问有关新SaaS功能的各类问题,随后在`ee/config/saas_features`中生成YAML定义文件。

只有具备YAML定义文件的SaaS功能,才可在开发或测试环境中使用。

```shell
❯ bin/saas-feature my_saas_feature
您选择了组 'group::acquisition'

>> 引入SaaS功能的MR URL(按回车键跳过,让Danger直接在MR中提供建议):
?> https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
创建 ee/config/saas_features/my_saas_feature.yml
---
name: my_saas_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38602
milestone: '16.8'
group: group::acquisition

在其他SaaS实例(极狐)上选择退出仅限SaaS的功能

ee/lib/ee/gitlab/saas.rb模块前添加代码,并重写Gitlab::Saas.feature_available?方法。

JH_DISABLED_FEATURES = %i[some_domain_new_feature_name].freeze

override :feature_available?
def feature_available?(feature)
  super && JH_DISABLED_FEATURES.exclude?(feature)
end

不要在CE中使用仅限SaaS的功能

Gitlab::Saas.feature_available?不得出现在CE代码中。 请参阅通过EE后端代码扩展CE功能指南

测试中的仅限SaaS功能

在代码库中引入仅限SaaS的功能会新增代码路径,需进行测试。 为受仅限SaaS功能影响的全部代码编写自动化测试,涵盖功能开启关闭状态,确保功能正常运行。

使用stub_saas_features助手

若要在测试中启用仅限SaaS的功能,可使用stub_saas_features助手。例如,在测试中全局禁用purchases_additional_minutes功能标识:

stub_saas_features(purchases_additional_minutes: false)

::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => false

测试双分支逻辑的常见模式如下:

it 'purchases/additional_minutes不可用' do
  # 基于purchases_additional_minutes默认未开启的测试
  ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => false
end

context '当purchases_additional_minutes可用时' do
  before do
    stub_saas_features(purchases_additional_minutes: true)
  end

  it '返回true' do
    ::Gitlab::Saas.feature_available?(:purchases_additional_minutes) # => true
  end
end

使用:saas元数据助手

依据测试类型不同,stub_saas_features方法可能无法满足SaaS场景需求。 此时可使用:saas RSpec元数据助手。

关于测试的更多细节,请参考 依赖SaaS的测试

借助元数据助手测试双分支逻辑示例如下:

it '显示自定义项目模板选项卡' do
  page.within '.project-template .custom-instance-project-templates-tab' do
    expect(page).to have_content 'Instance'
  end
end

context '当SaaS时', :saas do
  it '不显示Instance选项卡' do
    page.within '.project-template' do
      expect(page).not_to have_content 'Instance'
    end
  end
end

模拟SaaS实例

若您在本地开发,需让实例模拟产品(GitLab.com)版本的SaaS:

  1. 导出以下环境变量:

    export GITLAB_SIMULATE_SAAS=1

    向本地GitLab实例传递环境变量的方式有多种。 例如,您可在GDK根目录下创建env.runit文件,写入上述片段。

  2. 启用允许使用授权EE功能,使授权EE功能仅在项目命名空间计划包含该功能时对项目生效。

    1. 左侧边栏底部选择Admin
    2. 左侧边栏选择Settings > General
    3. 展开Account and limit
    4. 勾选Allow use of licensed EE features复选框。
    5. 点击Save changes
  3. 确保待测EE功能的组实际使用EE计划:

    1. 左侧边栏底部选择Admin
    2. 左侧边栏选择Overview > Groups
    3. 定位目标组并点击Edit
    4. 向下滚动至Permissions and group features。在Plan中选择Ultimate
    5. 点击Save changes

这里有📺 视频演示上述步骤。

实现新的企业版功能

如果你正在开发 GitLab Premium 或 GitLab Ultimate 许可的功能,使用以下步骤添加新功能或扩展它。

GitLab 许可功能被添加到 ee/app/models/gitlab_subscriptions/features.rb。要确定如何修改此文件,首先与你的产品经理讨论你的功能如何融入我们的许可体系。

使用以下问题指导你:

  1. 这是新功能,还是正在扩展现有许可功能?
    • 如果你的功能已存在,则不必修改 features.rb,但必须找到现有功能标识符以保护它
    • 如果这是新功能,决定一个标识符(如 my_feature_name),将其添加到 features.rb 文件中。
  2. 这是 GitLab Premium 还是 GitLab Ultimate 功能?
    • 根据你选择使用的计划,将功能标识符添加到 PREMIUM_FEATURESULTIMATE_FEATURES 中。
  3. 此功能是否会全局可用(整个 GitLab 实例系统级)?
    • Geo数据库负载均衡 这样的功能由整个实例使用,无法限制到单个用户命名空间。这些功能在实例许可证中定义。将这些功能添加到 GLOBAL_FEATURES

保护你的企业版功能

许可功能仅对许可用户可用。你必须添加检查或保护逻辑来确定用户是否有权访问该功能。

要保护你的许可功能:

  1. ee/app/models/gitlab_subscriptions/features.rb 中找到你的功能标识符。

  2. 使用以下方法(其中 my_feature_name 是你的功能标识符):

    • 在项目上下文中:

      my_project.licensed_feature_available?(:my_feature_name) # 如果 my_project 可用则为 true
    • 在组或用户命名空间上下文中:

      my_group.licensed_feature_available?(:my_feature_name) # 如果 my_group 可用则为 true
    • 对于全局(系统级)功能:

      License.feature_available?(:my_feature_name)  # 如果在此实例中可用则为 true
  3. 可选。如果你的全局功能也对付费计划的命名空间可用,结合两个功能标识符以允许管理员和组成员都能看到此企业版功能。例如:

    License.feature_available?(:my_feature_name) || group.licensed_feature_available?(:my_feature_name_for_namespace) # 管理员和组成员都可以看到此 EE 功能

未授权时模拟社区版实例

在实现了 让 GitLab 社区版功能在无许可的企业版实例上工作 后,当没有活跃许可证时,GitLab 企业版的行为就像 GitLab 社区版一样。

社区版规范应尽可能保持不变,并为企业版添加额外规范。可以使用 EE::LicenseHelpers 中的规范助手 stub_licensed_features 来存根许可功能。

你可以通过删除 ee/ 目录或将 FOSS_ONLY 环境变量 设置为求值为 true 的值来强制 GitLab 表现为社区版。运行测试时也是如此(例如 FOSS_ONLY=1 yarn jest)。

使用许可的 GDK 模拟社区版实例

要在不删除许可证的 GDK 中模拟社区版:

  1. 在 GDK 根目录创建一个 env.runit 文件,包含以下行:

    export FOSS_ONLY=1
  2. 然后重启 GDK:

    gdk restart rails && gdk restart webpack

如果想要恢复为企业版安装,移除 env.runit 中的行并重复第 2 步。

作为社区版运行功能规范

运行 功能规范 时,应确保前后端版本匹配。为此:

  1. 设置 FOSS_ONLY=1 环境变量:

    export FOSS_ONLY=1
  2. 启动 GDK:

    gdk start
  3. 运行功能规范:

    bin/rspec spec/features/<your_spec_path>

在开源背景下运行 CI 管道

默认情况下,用于开发的合并请求管道仅在 EE 上下文中运行。如果你正在开发 FOSS 与 EE 有差异的功能,你可能希望也在 FOSS 上下文中运行管道。

若要在两种上下文中运行管道,向合并请求添加 ~"pipeline:run-as-if-foss" 标签。

有关更多信息,请参阅 As-if-FOSS 作业和跨项目下游管道 管道文档。

后端的企业版代码分离

仅企业版(EE)功能

如果正在开发的功能在任何形式的CE中都不存在,我们不需要将代码放在EE命名空间下。例如,一个EE模型可以放入:ee/app/models/awesome.rb,使用Awesome作为类名。这不仅适用于模型。以下是其他示例:

  • ee/app/controllers/foos_controller.rb
  • ee/app/finders/foos_finder.rb
  • ee/app/helpers/foos_helper.rb
  • ee/app/mailers/foos_mailer.rb
  • ee/app/models/foo.rb
  • ee/app/policies/foo_policy.rb
  • ee/app/serializers/foo_entity.rb
  • ee/app/serializers/foo_serializer.rb
  • ee/app/services/foo/create_service.rb
  • ee/app/validators/foo_attr_validator.rb
  • ee/app/workers/foo_worker.rb
  • ee/app/views/foo.html.haml
  • ee/app/views/foo/_bar.html.haml
  • ee/config/initializers/foo_bar.rb

之所以这样是因为,对于CE中eager-load/auto-load路径下的每条路径,我们在config/application.rb中添加了相同的以ee/开头的路径。这也适用于视图。

测试仅企业版的(EE)后端功能

要测试在CE中不存在的EE类,像往常一样在ee/spec目录中创建spec文件,但不包含第二个ee/子目录。例如,类ee/app/models/vulnerability.rb的测试位于ee/spec/models/vulnerability_spec.rb

默认情况下,许可功能在specs/目录中的spec中被禁用。ee/spec目录中的spec默认已初始化Starter许可证。

为了有效测试您的功能,您必须使用stub_licensed_features辅助方法显式启用该功能,例如:

  stub_licensed_features(my_awesome_feature_name: true)

使用EE后端代码扩展CE功能

对于构建于现有CE功能之上的功能,请在EE命名空间中编写一个模块,并将其注入到CE类中,注入位置在该类所在的文件的最后一行。这使得在CE到EE的合并过程中发生冲突的可能性降低,因为仅在CE类中添加了一行——即注入模块的那一行。例如,要将模块前置到User类中,您可以采用以下方式:

class User < ActiveRecord::Base
  # ... 这里有很多代码 ...
end

User.prepend_mod

不要使用诸如prependextendinclude之类的方法。相反,请使用prepend_modextend_modinclude_mod。这些方法会尝试根据接收者模块的名称找到相关的EE模块,例如:

module Vulnerabilities
  class Finding
    #...
  end
end

Vulnerabilities::Finding.prepend_mod

会将名为::EE::Vulnerabilities::Finding的模块前置。

如果扩展模块不遵循此命名约定,您也可以通过使用prepend_mod_withextend_mod_withinclude_mod_with来提供模块名称。这些方法接受一个包含完整模块名称的String作为参数,而不是模块本身,示例如下:

class User
  #...
end

User.prepend_mod_with('UserExtension')

由于模块需要EE命名空间,因此文件也应放在ee/子目录中。例如,我们要在EE中扩展用户模型,所以我们有一个名为::EE::User的模块,放在ee/app/models/ee/user.rb中。

这不仅适用于模型。以下是其他示例:

  • ee/app/controllers/ee/foos_controller.rb
  • ee/app/finders/ee/foos_finder.rb
  • ee/app/helpers/ee/foos_helper.rb
  • ee/app/mailers/ee/foos_mailer.rb
  • ee/app/models/ee/foo.rb
  • ee/app/policies/ee/foo_policy.rb
  • ee/app/serializers/ee/foo_entity.rb
  • ee/app/serializers/ee/foo_serializer.rb
  • ee/app/services/ee/foo/create_service.rb
  • ee/app/validators/ee/foo_attr_validator.rb
  • ee/app/workers/ee/foo_worker.rb

测试基于CE功能的EE功能

要测试扩展CE类以添加EE功能的EE命名空间模块,像往常一样在ee/spec目录中创建spec文件,包括第二个ee/子目录。例如,扩展ee/app/models/ee/user.rb的测试位于ee/spec/models/ee/user_spec.rb

RSpec.describe调用中,使用EE模块将被使用的CE类名。例如,在ee/spec/models/ee/user_spec.rb中,测试将从以下开始:

RSpec.describe User do
  describe '通过扩展添加的EE功能'
end

重写CE方法

要覆盖CE代码库中存在的方法,请使用prepend。它允许你用一个模块中的方法覆盖类中的方法,同时仍然可以通过super访问类的实现。

使用它时有几个需要注意的地方:

  • 你应该始终使用extend ::Gitlab::Utils::Override,并使用override来保护overrider方法,以确保如果该方法在CE中被重命名,EE的覆盖不会被默默地遗忘。

  • overrider需要在CE实现的中间添加一行时,你应该重构CE方法并将其拆分为更小的方法。或者创建一个在CE中为空但在EE中有特定实现的“钩子”方法。

  • 当原始实现包含守卫条款(例如,return unless condition)时,我们无法通过覆盖方法轻松扩展行为,因为我们不知道被覆盖的方法(即在覆盖方法中调用super)何时会提前停止。在这种情况下,我们不应该只是覆盖它,而是更新原始方法,使其调用我们想要扩展的其他方法,就像模板方法模式。例如,给定这个基类:

      class Base
        def execute
          return unless enabled?
    
          # ...
          # ...
        end
      end

    而不是仅仅覆盖Base#execute,我们应该更新它并将行为提取到另一个方法中:

      class Base
        def execute
          return unless enabled?
    
          do_something
        end
    
        private
    
        def do_something
          # ...
          # ...
        end
      end

    然后,我们可以自由地覆盖那个do_something而不用担心守卫条款:

      module EE::Base
        extend ::Gitlab::Utils::Override
    
        override :do_something
        def do_something
          # 遵循上述模式调用super并扩展它
        end
      end

使用prepend时,将其放在ee/特定的子目录中,并用module EE包裹类或模块以避免命名冲突。

例如,要覆盖CE中ApplicationController#after_sign_out_path_for的实现:

def after_sign_out_path_for(resource)
  current_application_settings.after_sign_out_path.presence || new_user_session_path
end

而不是直接修改该方法,你应该向现有文件添加prepend

class ApplicationController < ActionController::Base
  # ...

  def after_sign_out_path_for(resource)
    current_application_settings.after_sign_out_path.presence || new_user_session_path
  end

  # ...
end

ApplicationController.prepend_mod_with('ApplicationController')

并在ee/子目录中创建一个新文件,包含修改后的实现:

module EE
  module ApplicationController
    extend ::Gitlab::Utils::Override

    override :after_sign_out_path_for
    def after_sign_out_path_for(resource)
      if Gitlab::Geo.secondary?
        Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
      else
        super
      end
    end
  end
end
重写CE类方法

同样的规则适用于类方法,只不过我们要使用ActiveSupport::Concern,并在class_methods块中使用extend ::Gitlab::Utils::Override。示例如下:

module EE
  module Groups
    module GroupMembersController
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        override :admin_not_required_endpoints
        def admin_not_required_endpoints
          super.concat(%i[update override])
        end
      end
    end
  end
end

使用自描述的包装方法

当无法或不合逻辑地修改方法的实现时,将其包装在一个自描述的方法中并使用该方法。

例如,在GitLab-FOSS中,系统创建的唯一用户是Users::Internal.ghost,但在EE中有几种类型的机器人用户,它们并不是真正的用户。覆盖User#ghost?的实现是不正确的,因此我们在app/models/user.rb中添加了一个#internal?方法。其实现如下:

def internal?
  ghost?
end

在EE中,实现位于ee/app/models/ee/users.rb

override :internal?
def internal?
  super || bot?
end

配置文件中的代码 (config/initializers)

Rails初始化代码位于:

  • config/initializers 用于CE-only功能
  • ee/config/initializers 用于EE功能

仅在无法分割时才在config/initializers中使用Gitlab.ee { ... }/Gitlab.ee?。例如:

SomeGem.configure do |config|
  config.base = 'https://example.com'

  config.encryption = true if Gitlab.ee?
end

配置文件 config/routes 中的代码

当我们在 config/routes.rb 中添加 draw :admin 时,应用会尝试加载位于 config/routes/admin.rb 的文件,同时也尝试加载位于 ee/config/routes/admin.rb 的文件。

在 EE(企业版)中,应该至少加载一个文件,最多两个文件。如果找不到任何文件,就会抛出错误。在 CE(社区版)中,由于我们不知道是否存在 EE 路由,即使找不到任何东西也不会抛出错误。

这意味着如果我们想扩展某个特定的 CE 路由文件,只需在 ee/config/routes 中添加同名文件即可。如果我们想添加仅限 EE 的路由,仍然可以在 CE 和 EE 中都添加 draw :ee_only,并在 EE 中添加 ee/config/routes/ee_only.rb,类似于 render_if_exists 的做法。

控制器 app/controllers/ 中的代码

在控制器中,最常见的冲突类型是 before_action,它在 CE 中有一系列操作,但 EE 会向该列表添加一些操作。

同样的问题经常出现在 params.require / params.permit 调用中。

缓解措施

分离 CE 和 EE 的操作/关键字。例如在 ProjectsController 中的 params.require

def project_params
  params.require(:project).permit(project_params_attributes)
end

# 始终返回符号数组,具体如何生成取决于实际使用场景。

# 应按字母顺序排序。
def project_params_attributes
  %i[
    description
    name
    path
  ]
end

EE::ProjectsController 模块中:

def project_params_attributes
  super + project_params_attributes_ee
end

def project_params_attributes_ee
  %i[
    approvals_before_merge
    approver_group_ids
    approver_ids
    ...
  ]
end

模型 app/models/ 中的代码

EE 特定的模型应在 ee/app/models/ 中定义。

若要覆盖 CE 模型,请在 ee/app/models/ee/ 中创建文件,并将新代码添加到 prepended 块中。

ActiveRecord 枚举应完全 在 FOSS 中定义

视图 app/views/ 中的代码

一个非常常见的问题是 EE 在 CE 视图中添加了一些特定的视图代码。例如项目设置页面中的审批代码。

缓解措施

EE 特定的代码块应移动到局部模板(partials)中。这可以避免与大段 HAML 代码发生冲突,而这些代码在考虑缩进时很难解决。

EE 特定的视图应放置在 ee/app/views/ 中,如果合适可以使用额外的子目录。

使用 render_if_exists

我们不应使用常规的 render,而是使用 render_if_exists,如果找不到特定的局部模板,则不会渲染任何内容。我们这样做是为了可以在 CE 中使用 render_if_exists,同时保持 CE 和 EE 之间的代码一致。

其优势如下:

  • 在阅读 CE 代码时,能非常清晰地提示我们在何处扩展了 EE 视图。

其劣势如下:

  • 如果局部模板名称有拼写错误,将会被静默忽略。
注意事项

render_if_exists 的视图路径参数必须相对于 app/views/ee/app/views。解析相对于 CE 视图路径的 EE 模板路径是无效的。

- # app/views/projects/index.html.haml

= render_if_exists 'button' # 不会渲染 ee/app/views/projects/_button,并且会静默失败
= render_if_exists 'projects/button' # 将会渲染 ee/app/views/projects/_button

使用 render_ce

对于 renderrender_if_exists,它们会先搜索 EE 局部模板,然后再搜索 CE 局部模板。它们只会渲染特定的局部模板,而不是所有同名局部模板。我们可以利用这一点,使得相同的局部模板路径(例如 projects/settings/archive)可以指向 CE 中的 CE 局部模板(即 app/views/projects/settings/_archive.html.haml),而在 EE 中则是 EE 局部模板(即 ee/app/views/projects/settings/_archive.html.haml)。这样,我们就可以在 CE 和 EE 之间显示不同的内容。

然而,有时我们也希望在 EE 局部模板中重用 CE 局部模板,因为我们可能只是想在现有的 CE 局部模板中添加一些内容。我们可以通过添加另一个不同名称的局部模板来解决这个问题,但这会很繁琐。

在这种情况下,我们可以使用 render_ce,它会忽略任何 EE 局部模板。示例如下(ee/app/views/projects/settings/_archive.html.haml):

- return if @project.self_deletion_scheduled?
= render_ce 'projects/settings/archive'

在上面的示例中,我们不能使用 render 'projects/settings/archive',因为这会找到同一个 EE 局部模板,导致无限递归。相反,我们可以使用 render_ce,它会忽略 ee/ 中的任何局部模板,然后渲染相同路径(即 projects/settings/archive)下的 CE 局部模板(即 app/views/projects/settings/_archive.html.haml)。这样我们就可以轻松地包装 CE 局部模板。

lib/gitlab/background_migration/ 中的代码

当你创建 EE 专属的后台迁移时,必须考虑到用户将 GitLab EE 降级到 CE 的情况。换句话说,每个 EE 专属迁移都必须存在于 CE 代码中,但不提供具体实现;相反,你需要在 EE 侧对其进行扩展。

GitLab CE:

# lib/gitlab/background_migration/prune_orphaned_geo_events.rb

module Gitlab
  module BackgroundMigration
    class PruneOrphanedGeoEvents
      def perform(table_name)
      end
    end
  end
end

Gitlab::BackgroundMigration::PruneOrphanedGeoEvents.prepend_mod_with('Gitlab::BackgroundMigration::PruneOrphanedGeoEvents')

GitLab EE:

# ee/lib/ee/gitlab/background_migration/prune_orphaned_geo_events.rb

module EE
  module Gitlab
    module BackgroundMigration
      module PruneOrphanedGeoEvents
        extend ::Gitlab::Utils::Override

        override :perform
        def perform(table_name = EVENT_TABLES.first)
          return if ::Gitlab::Database.read_only?

          deleted_rows = prune_orphaned_rows(table_name)
          table_name   = next_table(table_name) if deleted_rows.zero?

          ::BackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, self.class.name.demodulize, table_name) if table_name
        end
      end
    end
  end
end

app/graphql/ 中的代码

EE 特定的 mutation、resolver 和 type 应该添加到 ee/app/graphql/{mutations,resolvers,types} 中。

若要覆盖 CE 的 mutation、resolver 或 type,请在 ee/app/graphql/ee/{mutations,resolvers,types} 中创建文件,并将新代码添加到 prepended 块中。

例如,如果 CE 有一个名为 Mutations::Tanukis::Create 的 mutation,而你想要添加一个新的参数,则将 EE 覆盖代码放在 ee/app/graphql/ee/mutations/tanukis/create.rb 中:

module EE
  module Mutations
    module Tanukis
      module Create
        extend ActiveSupport::Concern

        prepended do
          argument :name,
                   GraphQL::Types::String,
                   required: false,
                   description: 'Tanuki 名称'
        end
      end
    end
  end
end

lib/ 中的代码

将覆盖 CE 的 EE 逻辑放置在顶层的 EE 模块命名空间下。按照常规方式,将类置于 EE 模块之下。

例如,如果 CE 在 lib/gitlab/ldap/ 中有 LDAP 类,那么你应该将 EE 特定的覆盖代码放在 ee/lib/ee/gitlab/ldap 中。

对于没有 CE 对应项的 EE 专属类,应将其放置在 ee/lib/gitlab/ldap 中。

lib/api/ 中的代码

通过单行 prepend_mod_with 来扩展 EE 功能可能会非常棘手,并且对于每个不同的 Grape 特性,我们可能需要不同的策略来扩展它。为了轻松应用不同的策略,我们会在 EE 模块中使用 extend ActiveSupport::Concern

请将 EE 模块文件按照 使用 EE 后端代码扩展 CE 功能 的要求放置。

EE API 路由

对于 EE API 路由,我们将它们放在 prepended 块中:

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        params do
          requires :id, types: [String, Integer], desc: '项目的 ID 或 URL 编码路径'
        end
        resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
          # ...
        end
      end
    end
  end
end

由于命名空间的不同,我们需要对某些常量使用完整限定符。

企业版参数

我们可以定义 params 并在另一个 params 定义中使用 use 来包含企业版(EE)中定义的参数。但是,我们需要先在社区版(CE)中定义“接口”,以便企业版(EE)可以覆盖它。由于 prepend_mod_with,我们在其他地方不需要这样做,但Grape内部很复杂,我们无法轻易做到这一点,因此我们在这里遵循常规的面向对象实践来定义接口。

例如,假设我们有更多可选的企业版参数。我们可以将这些参数从 Grape::API::Instance 类移到一个辅助模块中,这样我们就可以在使用它的类之前注入它。

module API
  class Projects < Grape::API::Instance
    helpers Helpers::ProjectsHelpers
  end
end

给定这个社区版(CE)的API params

module API
  module Helpers
    module ProjectsHelpers
      extend ActiveSupport::Concern
      extend Grape::API::Helpers

      params :optional_project_params_ce do
        # 社区版特定的参数放在这里...
      end

      params :optional_project_params_ee do
      end

      params :optional_project_params do
        use :optional_project_params_ce
        use :optional_project_params_ee
      end
    end
  end
end

API::Helpers::ProjectsHelpers.prepend_mod_with('API::Helpers::ProjectsHelpers')

我们可以在企业版(EE)模块中覆盖它:

module EE
  module API
    module Helpers
      module ProjectsHelpers
        extend ActiveSupport::Concern

        prepended do
          params :optional_project_params_ee do
            # 企业版特定的参数放在这里...
          end
        end
      end
    end
  end
end

企业版帮助方法

为了方便企业版(EE)模块覆盖社区版(CE)的帮助方法,我们需要先定义那些我们想要扩展的帮助方法。尽量在类定义之后立即做这件事,使其简单明了:

module API
  module Ci
    class JobArtifacts < Grape::API::Instance
      # EE::API::Ci::JobArtifacts 会覆盖以下帮助方法
      helpers do
        def authorize_download_artifacts!
          authorize_read_builds!
        end
      end
    end
  end
end

API::Ci::JobArtifacts.prepend_mod_with('API::Ci::JobArtifacts')

然后我们可以遵循常规的面向对象实践来覆盖它:

module EE
  module API
    module Ci
      module JobArtifacts
        extend ActiveSupport::Concern

        prepended do
          helpers do
            def authorize_download_artifacts!
              super
              check_cross_project_pipelines_feature!
            end
          end
        end
      end
    end
  end
end

企业版特定行为

有时我们需要在某些API中加入企业版(EE)特有的行为。通常我们可以使用企业版的方法覆盖社区版的方法,然而API路由不是方法,因此无法被覆盖。我们需要将它们提取成一个独立的方法,或者在社区版的路线中引入一些“钩子”,让我们可以在那里注入行为。类似这样:

module API
  class MergeRequests < Grape::API::Instance
    helpers do
      # EE::API::MergeRequests 会覆盖以下帮助方法
      def update_merge_request_ee(merge_request)
      end
    end

    put ':id/merge_requests/:merge_request_iid/merge' do
      merge_request = find_project_merge_request(params[:merge_request_iid])

      # ...

      update_merge_request_ee(merge_request)

      # ...
    end
  end
end

API::MergeRequests.prepend_mod_with('API::MergeRequests')

update_merge_request_ee 在社区版中不做任何事情,但我们可以在企业版中覆盖它:

module EE
  module API
    module MergeRequests
      extend ActiveSupport::Concern

      prepended do
        helpers do
          def update_merge_request_ee(merge_request)
            # ...
          end
        end
      end
    end
  end
end

企业版 route_setting

这很难在企业版(EE)模块中扩展,而且这是为特定路由存储元数据的。鉴于此,我们可以将企业版的 route_setting 留在社区版中,因为它不会造成伤害,并且我们在社区版中不使用这些元数据。

当我们更多地使用 route_setting 并且确实需要从企业版中扩展它时,我们可以重新审视这一策略。目前我们使用不多。

利用类方法设置企业版特定数据

有时我们需要为一个特定的API路由使用不同的参数,但由于Grape在不同块中有不同的上下文,我们无法轻松地通过企业版模块来扩展它。为了克服这个问题,我们需要将这些数据移动到一个单独的模块或类中的类方法中。这使得我们可以在其数据被使用之前扩展该模块或类,而不必在社区版代码中间放置一个 prepend_mod_with

例如,在一个地方,我们需要向 at_least_one_of 传递一个额外的参数,以便API可以将仅限企业版的参数视为最少的参数。我们会采用如下方式:

api/merge_requests/parameters.rb

module API class MergeRequests < Grape::API::Instance module Parameters def self.update_params_at_least_one_of %i[ assignee_id description ] end end end end

API::MergeRequests::Parameters.prepend_mod_with(‘API::MergeRequests::Parameters’)

api/merge_requests.rb

module API class MergeRequests < Grape::API::Instance params do at_least_one_of(*Parameters.update_params_at_least_one_of) end end end


之后我们可以轻松地在EE类方法中扩展该参数:

```ruby
module EE
  module API
    module MergeRequests
      module Parameters
        extend ActiveSupport::Concern

        class_methods do
          extend ::Gitlab::Utils::Override

          override :update_params_at_least_one_of
          def update_params_at_least_one_of
            super.push(*%i[
              squash
            ])
          end
        end
      end
    end
  end
end

如果很多路由都需要这个,可能会很麻烦,但现在这可能是最简单的解决方案。

当模型定义依赖于类方法的验证时,也可以使用这种方法。例如:

# app/models/identity.rb
class Identity < ActiveRecord::Base
  def self.uniqueness_scope
    [:provider]
  end

  prepend_mod_with('Identity')

  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: uniqueness_scope, case_sensitive: false }
end

# ee/app/models/ee/identity.rb
module EE
  module Identity
    extend ActiveSupport::Concern

    class_methods do
      extend ::Gitlab::Utils::Override

      def uniqueness_scope
        [*super, :saml_provider_id]
      end
    end
  end
end

与其采用这种方法,我们不如将代码重构如下:

# ee/app/models/ee/identity/uniqueness_scopes.rb
module EE
  module Identity
    module UniquenessScopes
      extend ActiveSupport::Concern

      class_methods do
        extend ::Gitlab::Utils::Override

        def uniqueness_scope
          [*super, :saml_provider_id]
        end
      end
    end
  end
end

# app/models/identity/uniqueness_scopes.rb
class Identity < ActiveRecord::Base
  module UniquenessScopes
    def self.uniqueness_scope
      [:provider]
    end
  end
end

Identity::UniquenessScopes.prepend_mod_with('Identity::UniquenessScopes')

# app/models/identity.rb
class Identity < ActiveRecord::Base
  validates :extern_uid,
    allow_blank: true,
    uniqueness: { scope: Identity::UniquenessScopes.scopes, case_sensitive: false }
end

spec/ 中的代码

当你测试仅限EE的功能时,避免向现有的CE规范添加示例。相反,将EE规范放在 ee/spec 文件夹中。

默认情况下,CE规范会加载EE代码运行,因为当EE在没有许可证的情况下运行时,它们应保持原样工作。

当移除EE代码时,这些规范也需要通过。你可以通过模拟CE实例在不加载EE代码的情况下运行测试。

spec/factories 中的代码

使用 FactoryBot.modify 来扩展CE中已定义的工厂。

你不能在 FactoryBot.modify 块内定义新工厂(即使是嵌套的)。你可以在单独的 FactoryBot.define 块中这样做,示例如下:

# ee/spec/factories/notes.rb
FactoryBot.modify do
  factory :note do
    trait :on_epic do
      noteable { create(:epic) }
      project nil
    end
  end
end

FactoryBot.define do
  factory :note_on_epic, parent: :note, traits: [:on_epic]
end

前端中EE代码的分离

为了分离仅限EE的JS文件,将这些文件移动到 ee 文件夹中。

例如,可以有一个 app/assets/javascripts/protected_branches/protected_branches_bundle.js 和一个EE对应的 ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js。相应的导入语句看起来像这样:

// app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from '~/protected_branches/protected_branches_bundle.js';

// ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
// (仅适用于EE)
import bundle from 'ee/protected_branches/protected_branches_bundle.js';

// 在CE中: app/assets/javascripts/protected_branches/protected_branches_bundle.js
// 在EE中: ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js
import bundle from 'ee_else_ce/protected_branches/protected_branches_bundle.js';

在前端添加新的仅限EE的功能

如果正在开发的功能在CE中不存在,请在 ee/ 中添加入口点。例如:

# 添加用于挂载的HTML元素
ee/app/views/admin/geo/designs/index.html.haml

# 初始化应用程序
ee/app/assets/javascripts/pages/ee_only_feature/index.js

挂载功能

ee/app/assets/javascripts/ee_only_feature/index.js \n\n对 `licensed_feature_available?` 和 `License.feature_available?` 进行特性保护通常发生在控制器中,如 [后端指南](#ee-only-features) 中所述。\n\n#### 测试仅 EE 的前端功能\n\n将您的 EE 测试添加到 `ee/spec/frontend/`,遵循您用于 CE 的相同目录结构。\n\n查看 [测试仅 EE 的后端功能](#testing-ee-only-backend-features) 下方的说明,了解启用许可功能的相关内容。\n\n### 使用 EE 前端代码扩展 CE 功能\n\n使用 [`push_licensed_feature`](#guard-your-ee-feature) 来保护扩展现有视图的前端功能:\n\nruby\n\n# ee/app/controllers/ee/admin/my_controller.rb\nbefore_action do\n push_licensed_feature(:my_feature_name) # 用于全局功能\nend\n\n\nruby\n\n# ee/app/controllers/ee/group/my_controller.rb\nbefore_action do\n push_licensed_feature(:my_feature_name, @group) # 用于群组页面\nend\n\n\nruby\n\n# ee/app/controllers/ee/project/my_controller.rb\nbefore_action do\n push_licensed_feature(:my_feature_name, @group) # 用于群组页面\n push_licensed_feature(:my_feature_name, @project) # 用于项目页面\nend\n\n\n在浏览器控制台中验证您的功能是否出现在 `gon.licensed_features` 中。\n\n#### 使用 EE Vue 组件扩展 Vue 应用程序\n\n增强现有 UI 功能的 EE 许可功能会将新元素或交互作为组件添加到您的 Vue 应用程序中。\n\n您可以在 CE 组件内导入 EE 组件以添加 EE 功能。\n\n使用 `ee_component` 别名来导入 EE 组件。在 EE 中,`ee_component` 导入别名指向 `ee/app/assets/javascripts` 目录。而在 CE 中,此别名将被解析为一个不渲染任何内容的空组件。\n\n以下是将 EE 组件导入到 CE 组件中的示例:\n\nvue\n\n\n\n\n\n

\n\n如果 EE 组件在其呈现依赖于某些检查(例如功能标志检查)的情况下渲染 CE 代码库,则可以异步导入该组件。\n\n

\n\n检查 `glFeatures` 以确保 Vue 组件受到保护。只有当许可证存在时,这些组件才会渲染。\n\nvue\n\n\n\n```\n\n

\n\n除非绝对必要,否则不要使用 mixin。尝试寻找替代模式。\n\n

推荐替代方法(命名/作用域插槽)
  • 我们可以使用插槽和/或作用域插槽来实现与混入相同的效果。如果只需要 EE 组件,则无需创建 CE 组件。
  1. 首先,我们有一个 CE 组件,可以在需要时渲染插槽,以便在 CE 基础上装饰 EE 模板和功能。
// ./ce/my_component.vue

<script>
export default {
  props: {
    tooltipDefaultText: {
      type: String,
    },
  },
  computed: {
    tooltipText() {
      return this.tooltipDefaultText || "5 issues please";
    }
  },
}
</script>

<template>
  <span v-gl-tooltip :title="tooltipText" class="ce-text">Community Edition Only Text</span>
  <slot name="ee-specific-component">
</template>
  1. 接下来,我们渲染 EE 组件,并在 EE 组件内部渲染 CE 组件,并在插槽中添加额外内容。
// ./ee/my_component.vue

<script>
export default {
  computed: {
    tooltipText() {
      if (this.weight) {
        return "5 issues with weight 10";
      }
    }
  },
  methods: {
    submit() {
      // 执行某些操作。
    }
  },
}
</script>

<template>
  <my-component :tooltipDefaultText="tooltipText">
    <template #ee-specific-component>
      <span class="some-ee-specific">EE 特定值</span>
      <button @click="submit">点击我</button>
    </template>
  </my-component>
</template>
  1. 最后,在任何需要组件的地方,我们可以像这样引入它:

import MyComponent from 'ee_else_ce/path/my_component'.vue

  • 这样无论 CE 还是 EE 实现,都会包含正确的组件

对于需要相同计算值产生不同结果的 EE 组件,我们可以向 CE 包装器传递 props,如示例所示。

  • EE 额外 HTML
    • 对于 EE 中包含额外 HTML 的模板,我们应该将其移至新组件并使用 ee_else_ce 导入别名

扩展其他 JS 代码

要扩展 JS 文件,完成以下步骤:

  1. 使用 ee_else_ce 助手,其中仅 EE 的代码必须位于 ee/ 文件夹内。
    1. 创建一个仅包含 EE 的 EE 文件,并扩展 CE 对应文件。
    2. 对于无法扩展的函数内的代码,将代码移至新文件并使用 ee_else_ce 助手:
  import eeCode from 'ee_else_ce/ee_code';

  function test() {
    const test = 'a';

    eeCode();

    return test;
  }

在某些情况下,您需要扩展应用程序中的其他逻辑。要扩展您的 JS 模块,请创建文件的 EE 版本,并用自定义逻辑扩展它:

// app/assets/javascripts/feature/utils.js

export const myFunction = () => {
  // ...
};

// ... 其他 CE 函数 ...
// ee/app/assets/javascripts/feature/utils.js
import {
  myFunction as ceMyFunction,
} from '~/feature/utils';

/* eslint-disable import/export */

// 导出与 CE 相同的工具函数
export * from '~/feature/utils';

// 仅覆盖 `myFunction`
export const myFunction = () => {
  const result = ceMyFunction();
  // 添加 EE 功能逻辑
  return result;
};

/* eslint-enable import/export */

使用 EE/CE 别名测试模块

当编写前端测试时,如果被测模块通过 ee_else_ce/... 导入其他模块,并且这些模块也被相关测试所需,那么相关测试必须通过 ee_else_ce/... 导入这些模块。这可以避免意外的 EE 或 FOSS 失败,并有助于确保未授权时 EE 行为与 CE 一致。

例如:

<script>
// ~/foo/component_under_test.vue

import FriendComponent from 'ee_else_ce/components/friend.vue;'

export default {
  name: 'ComponentUnderTest',
  components: { FriendComponent }.
}
</script>

<template>
  <friend-component />
</template>
// spec/frontend/foo/component_under_test_spec.js

// ...
// 因为我们使用了 ee_else_ce 引用组件,所以在测试中也必须这样做。
import Friend from 'ee_else_ce/components/friend.vue;'

describe('ComponentUnderTest', () => {
  const findFriend = () => wrapper.find(Friend);

  it('渲染朋友组件', () => {
    // 如果我们在 CE 中使用 `ee/component...` 会失败
    // 如果我们在 EE 中使用 `~/component...` 会失败
    expect(findFriend().exists()).toBe(true);
  });
});

运行 EE 与 CE 测试

当你为 CE 和 EE 环境创建测试时,需要采取一些步骤确保本地和流水线运行时两者都能通过。

  • 默认情况下,测试在 EE 环境中运行,同时执行 EE 和 CE 测试。
  • 如果你想在 FOSS 环境中仅测试 CE 文件,需运行以下命令:
FOSS_ONLY=1 yarn jest path/to/spec/file.spec.js

对于 CE 测试,我们只添加 CE 功能,若缺少 EE 特定的模拟数据,可能在 EE 环境中失败。为确保 CE 测试在两种环境中都正常工作:

  • 导入模拟数据时使用 ee_else_ce_jest 别名。例如:
import { sidebarDataCountResponse } from 'ee_else_ce_jest/super_sidebar/mock_data';
  • 确保 CE 和 EE 的 mock_data 文件中都包含对应数据的对象(如上例中的 sidebarDataCountResponse)。一个文件仅包含 CE 功能的数据(用于 CE 文件),另一个则包含 CE 和 EE 功能的数据。

  • 在 CE 文件的 expect 块中,若需比较对象,请使用 toMatchObject 而非 toEqual,避免期望 CE 数据中存在 EE 数据。例如:

expect(findPinnedSection().props('asyncCount')).toMatchObject(asyncCountData);

assets/stylesheets 中的 SCSS 代码

如果你正在添加样式的组件仅限 EE,最好在 app/assets/stylesheets 内的合适目录下有单独的 SCSS 文件。

在某些情况下,这并非完全可行或创建专用 SCSS 文件过于繁琐(例如某组件的文本样式在 EE 中不同)。此时,样式通常保存在 CE 和 EE 共用的样式表中,明智的做法是将这类规则集与其他 CE 规则隔离(同时添加注释说明),以避免 CE 到 EE 合并时的冲突。

// 不良示例
.section-body {
  .section-title {
    background: $gl-header-color;
  }

  &.ee-section-body {
    .section-title {
      background: $gl-header-color-cyan;
    }
  }
}
// 良好示例
.section-body {
  .section-title {
    background: $gl-header-color;
  }
}

// EE 特定规则开始
.section-body.ee-section-body {
  .section-title {
    background: $gl-header-color-cyan;
  }
}
// EE 特定规则结束

GitLab-svgs

app/assets/images/icons.jsonapp/assets/images/icons.svg 中的冲突可通过使用 yarn run svg 重新生成这些资源来解决。