构建组件的工具 - 3:创建组件!
最后我们将为我们的示例活动时间表构建一个真正的 Joomla 组件!我们将探讨如何实现实体之间的关系。
小插曲
关于实体之间关系实现的讨论比预期的占用空间更多。这就是为什么我 将这一集分成两部分。下个月,在杂志的十月期中,我们将看看 Joomla 内置的一些可以用来增强我们示例组件的小工具,并展示一些有用的较小工具。组件创建器将比原计划晚一个月,将在十一月的期中成为焦点。
我们到目前为止的旅程
在第一集中,我们介绍了一个示例:一个活动时间表,展示了例如会议、节日、学校等每天的时间表。我们列出了我们示例的实体
- 一个包含时间表/节目的页面:一个 容器。
- 一个或多个用于安排活动的“轨道”:一个 部分(多个部分可以同时安排活动,在相同的时间)。
- 发生的事情,例如一个演讲:一个 事件。
- 执行活动的人,例如演讲者或乐队:一个 演员。
我们使用核心功能实现了这个示例,特别是使用额外的字段 (1)。
在第二集中,我们进一步研究了这个示例,并提到了事件和演员之间的 多对多关系。我们还介绍了除了实体之外的 值对象,具体是
- 一个定义事件在哪里和何时发生的对象:一个 定位器。
我们示例活动时间表的实体关系图如下所示
我们在第二集中将这个示例作为 Seblod 和 Fabrik 中的“嵌入应用程序”实现了。
如何构建组件?
现在我们将为示例构建一个Joomla组件。您需要了解一些PHP。如果您是PHP新手,可以查看这里: https://w3schools.org.cn/php/。然后阅读一些Joomla特定的信息。
- Joomla! 程序员文档,我们不断扩大的在线信息源。
- 书籍:《Joomla! 5 开发扩展》作者 Carlos Cámara。
更多资源,请查看本文下的链接。
这里不会重复所有内容,这篇文章太短,不能作为一个完整的教程,但会总结我构建这个组件的主要步骤。在本文中,我将详细阐述实体之间关系的实现,因为目前文档中关于这方面的内容并不多。我们将“手动”构建我们的组件。在未来的几期中,我们将探讨一些可以帮助您构建自己的组件的工具,甚至将看到一些可以为您生成完整组件的工具。
1. 纯实体,模型-视图-控制器(MVC)
对于我们的示例中的每个实体,在管理员部分创建(
- 数据库中的一个 表 ,例如 #__eventschedule_events,用于在数据库中存储事件实体。您可以在组件管理员部分的/sql文件夹下看到组件创建的哪些表以及哪些字段。
- 一个 表对象,例如 EventTable 用于事件实体。这是用于创建、读取、更新或删除(CRUD)事件的对象。
- 一个XML文件,用于定义每个实体的输入-表单 ,包括该实体的所有字段,在 /forms 目录中。我们将实现(嵌入的)定位对象作为事件表单的子表单。
- 单一和复数 模型、视图和控制器(MVC)。在我们的示例中:EventModel、EventView 和 EventController 用于与单个事件交互,以及 EventsModel、EventsView 和 EventsController 用于处理事件列表。每个视图还需要一个 模板布局 文件,在 /tmpl 目录下。
我们添加了一些通用文件(一个清单文件、一个服务提供者文件和access.xml),基本的后台就准备好了。现在所有实体都可以输入或编辑,但还不能编辑实体之间的关系(我们将在第二步中完成)。
这个第一阶段设置包含了很多样板代码。其中大部分对于所有实体几乎都是相同的。我们将在未来的几期中展示一些可以帮助处理这些的工具。
2. 实现关系
我们需要注意我们实体之间所有的关系。基本上,对象之间存在三种类型的关系
- 一对一(1:1)。在我们的示例组件中没有1:1关系。在Joomla核心中,我们可以在 # __content_rating 和 # __content_frontpage 表中找到它,两者都以 content_id 作为主键。
- 多对一(n:1)。例如,一个事件只能属于一个事件类型,但一个事件类型可以被多个事件使用。
- 多对多(n:n)。我们在上一期中讨论过。在我们的示例中,例如在事件和演员之间:一个事件可以由多个演员完成,一个演员可以完成多个事件。这种关系需要一个连接表。
一种“一对一”的路线标志
在数据库中,表之间的关系通过“外键”定义,它引用另一个表中的id。例如,当我们有一个事件类型_id列时,该列的值是事件类型表中事件类型的id。
在1:1和n:1的关系中,外键始终位于一个表中;这称为“拥有方”。对于n:1关系,拥有方始终是只需存储一个值而不是集合的表。我们的活动只能有一种事件类型,因此事件表是拥有方,存储事件类型外键。如果您在另一侧存储关系,您将不得不存储事件标识符的集合。在“规范化”数据库中((2)),您不会在一个字段中存储集合,因此我们始终在拥有方存储id。在n:n关系中,连接表是拥有方。
关系的“反向方”是外键未存储的一方。在n:1和n:n关系中,反向方是一个集合。必须使用查询从数据库检索该集合。
“ 一个查询走进了一家酒吧,看到了两张桌子。
他走上前去说:“我可以加入你们吗?”
在核心Joomla中,我们不使用对象关系映射器(ORM)((3))来映射数据库表和程序中的对象,因此我们必须自己定义对象之间的关系。在Joomla中,创建、读取、更新和删除(CRUD)实体基本上是通过表对象完成的。这也是Joomla核心处理关系的地方((4)),例如在com_users中将用户组映射到用户。这被称为“活动记录”模式:表对象的属性与底层数据库表中的列大致直接对应,同时检索、保存和删除这些记录在数据库中也是通过该表对象完成的。
在Joomla中,列表视图的数据由直接查询数据库的模型提供。这些列表视图不使用活动记录模式,因为它们不使用表对象(实体),而是直接使用getListQuery()方法从数据库查询列表数据。
在Joomla中,我们可以使用子表字段,它可以重复使用。这不是以规范化的方式存储在数据库中,即使用字段中的一个值,而是压缩在JSON字符串中。我们将使用它来存储定位对象。我建议您尽可能多地将实体(具有单独的id)放入规范化的数据库表中,并且仅使用子表来存储不用于所属实体的值。
Joomla中的关系
当我们使用规范化的数据库时,可以使用表对象在Joomla中以下列方式实现实体之间的关系。
一对一(1:1)关系
“添加”实体的数据库表包含一个指向“主要”实体的外键。该外键在添加的表中是唯一的(应作为添加表的唯一键使用)。例如,在内容相关表中有一个content_id。最佳的一对一关系管理是从反向方进行的:从主要实体的一方。
- 不要为添加的实体创建表对象,只需使用主要实体的id将添加的实体存储在模型中。
- 当主要实体被删除时,也应删除添加的实体。在主要实体的Table-object的delete()方法中执行此操作是一个很好的位置。
- 从主要实体获取添加实体的输入表单,这样您就始终知道id。如果您想在内容中显示添加的实体,请向主要实体的模型添加一个get<AddedEntityName>()方法来检索添加的实体(即具有相同的id),例如getRating() (5)。
多对一(n:1)关系
拥有方(=存储一个外键的实体)
- 本实体的数据库表包含对其他实体的外键。例如,在我们的示例中:事件表中的event_type_id是对事件类型表的外键。
- 在表单中创建一个选择字段,以选择其他实体中的一个项目。您可以使用sql字段(从其他表中选择id和一些名称或标题)或为它创建一个专门的表单字段,该字段从ListField扩展,包含相同的查询。
- 如果您想在内容中显示其他实体,请在模型中添加一个get<OtherEntityName>()方法来检索主实体。例如,在我们的示例中EventModel的getEventType()。
反向面(=拥有实体项集合的实体)
- 如果您想在内容中显示相关实体的项集合,请在模型中添加一个get<OwningEntityName>s()方法来检索拥有实体的项集合(=具有此实体的id作为外键)。由于我们可能多次调用该集合,我将它们放在一个变量中,这样我只需在模型的整个生命周期中从数据库检索一次。
多对多(n:n)关系
在多对多关系中,两个实体都在反向面,因为关系存储在连接表中。但是,连接表不是由Table对象表示的,所以在Joomla中没有固定的地方来处理这些n:n关系的CRUD操作。Joomla核心在User Table-object中处理用户和用户组的数据库操作。我们可以遵循Joomla的方式,在两个相关实体的Table-object中处理数据库操作。从两个方向都可以编辑集合。在Table对象的store()-方法中存储集合(在连接表中)。如果实体被删除,则删除集合。
- 在两个模型中,如果您想在内容中显示相关实体的集合,请添加一个方法来检索来自另一边的实体集合。这可以通过连接连接表和另一个实体的表来完成。例如,在事件和演员之间创建多对多关系时,我们将在事件模型中创建一个getActors()方法,在演员模型中创建一个getEvents()方法,以返回此事件或此演员的事件集合。我们可以使用此集合来显示其他实体的字段。我们只需在想要显示我们的实体时调用此方法。
- 在Table对象的store()方法中存储其他实体的集合到连接表中。您必须将表单数据绑定到Table对象,以便在store()方法中使用它。
- 在delete()方法中,如果实体本身被删除,则删除它们。
- 在模型中创建一个getter方法,以从其他实体获取id数组,以在表单中添加、删除或编辑它们。在模型的loadFormData()方法中使用此方法。
- 在表单中放置一个多选下拉字段。这可以是简单的sql字段,就像我们用于n:1关系一样,只是现在可以进行多选。或者,就像n:1关系一样,我们可以为它创建一个专门的表单字段,从ListField扩展,包含相同的查询。
要编辑事件中的演员,我们向EventTable添加一个actor-ids字段。
/**
* @var null|array of integers: the ids of the actors for this event
* (only used when editing an event)
*/
private $actor_ids = null;
绑定从传入的表单数据中的$actor_ids。
/**
* Method to bind the event and actors data.
*
* @param array $array The data to bind.
* @param mixed $ignore An array or space separated list of fields to ignore.
*
* @return boolean True on success, false on failure.
*/
public function bind($array, $ignore = ''):bool
{
// Attempt to bind the data.
$return = parent::bind($array, $ignore);
// Set this->actor_ids
if ($return && array_key_exists('actor_ids', $array)) {
$this->actor_ids = $array[actor_ids'];
}
return $return;
}
在连接表中存储演员。这是在核心Joomla中用User Table-object处理用户组的方式(以下是对该代码的副本,仅对演员而不是用户组进行了调整):
- 存储事件对象
- 获取存储在数据库连接表中的演员,并与输入表单中的演员选择进行比较。
- 删除表单中不再存在的演员。
- 插入新的actor_ids。
/**
* Method to store a row in the database from the event Table instance properties.
*
* If a primary key value is set the row with that primary key value will be updated with the instance property values.
* If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
*
* The actors for this event will be updated (via a junction table)
*
* @param boolean $updateNulls True to update fields even if they are null.
*
* @return boolean True on success.
*
* @see \Joomla\CMS\Table\User handling of user groups
*/
public function store($updateNulls = true):bool
{
// -- Initialisation --
// Get the table key and key value.
$k = $this->_tbl_key;
$key = $this->$k;
// Joomla core comment:
// @todo: This is a dumb way to handle the groups.
// Store actorIds locally so as to not update directly.
$actorIds = $this->actor_ids;
unset($this->actor_ids);
// -- 1. Store the event --
// Insert or update the object based on the primary key value > 0.
if ($key) {
// Already have a table key, update the row.
$this->_db->updateObject($this->_tbl, $this, $this->_tbl_key, $updateNulls);
} else {
// Don't have a table key, insert the row.
$this->_db->insertObject($this->_tbl, $this, $this->_tbl_key);
}
// Reset actor_ids to the local object.
$this->actor_ids = $actorIds;
// -- 2a. Get the actors as stored in the database junction table --
$query = $this->_db->getQuery(true);
// Store the actorId data if the event data was saved.
if (\is_array($this->actor_ids) && \count($this->actor_ids)) {
$eventId = (int) $this->id;
// Grab all actor_ids for the event, as is stored in the junction table
$query->clear()
->select($this->_db->quoteName('actor_id'))
->from($this->_db->quoteName('#__eventschedule_actor_event'))
->where($this->_db->quoteName('event_id') . ' = :eventid')
->order($this->_db->quoteName('actor_id') . ' ASC')
->bind(':eventid', $eventId, ParameterType::INTEGER);
$this->_db->setQuery($query);
$actorIdsInDb = $this->_db->loadColumn();
// -- 2b. compare with the selection of actors from the input-form --
// Loop through them and check if database contains something $this->actor_ids does not
if (\count($actorIdsInDb)) {
$deleteActorIds = [];
foreach ($actorIdsInDb as $storedActorId) {
if (\in_array($storedActorId, $this->actor_ids)) {
// It already exists, no action required
unset($actorIds[$storedActorId]);
} else {
$deleteActorIds[] = (int) $storedActorId;
}
}
// -- 3. Delete what is not in the form anymore --
if (\count($deleteActorIds)) {
$query->clear()
->delete($this->_db->quoteName('#__eventschedule_actor_event'))
->where($this->_db->quoteName('event_id') . ' = :eventId')
->whereIn($this->_db->quoteName('actor_id'), $deleteActorIds)
->bind(':eventId', $eventId, ParameterType::INTEGER);
$this->_db->setQuery($query);
$this->_db->execute();
}
unset($deleteActorIds);
}
// -- 4. Insert the new actor_ ids --
// If there is anything left in $actorIds it needs to be inserted
if (\count($actorIds)) {
// Set the new event actorIds in the db junction table.
$query->clear()
->insert($this->_db->quoteName('#__eventschedule_actor_event'))
->columns([$this->_db->quoteName('event_id'), $this->_db->quoteName('actor_id')]);
foreach ($actorIds as $actorId) {
$query->values(
implode(
',',
$query->bindArray(
[$this->id , $actorId],
[ParameterType::INTEGER, ParameterType::INTEGER]
)
)
);
}
$this->_db->setQuery($query);
$this->_db->execute();
}
unset($actorIds);
}
return true;
}
当事件被删除时,从连接表中删除actor-ids。
/**
* Method to delete an event (and mappings of that event to actors) from the database.
*
* @param integer $eventId An optional event id.
*
* @return boolean True on success, false on failure.
*
* @see \Joomla\CMS\Table\User handling of user groups
*/
public function delete($eventId = null):bool
{
// Set the primary key to delete.
$k = $this->_tbl_key;
if ($eventId) {
$this->$k = (int) $eventId;
}
$key = (int) $this->$k;
// Delete the corresponding actors from the actor-event junction table.
$query = $this->_db->getQuery(true)
->delete($this->_db->quoteName('#__eventschedule_actor_event'))
->where($this->_db->quoteName('event_id') . ' = :key')
->bind(':key', $key, ParameterType::INTEGER);
$this->_db->setQuery($query);
$this->_db->execute();
// Delete the event.
$query->clear()
->delete($this->_db->quoteName($this->_tbl))
->where($this->_db->quoteName($this->_tbl_key) . ' = :key')
->bind(':key', $key, ParameterType::INTEGER);
$this->_db->setQuery($query);
$this->_db->execute();
return true;
}
在 EventModel 中,我们添加了一个方法来从连接表中检索那些 actor-ids。
/**
* Get the actor_ids for this event.
* @param int|null $event_id
* @return array
*/
public function getActorIds(int $event_id = null):array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('actor_id'))
->from($db->quoteName('#__eventschedule_actor_event', 'junction'))
->where($db->quoteName('event_id') . ' = :thisId')
->order($db->quoteName('actor_id') . ' ASC')
->bind(':thisId', $event_id, ParameterType::INTEGER);
$actor_ids = $db->setQuery($query)->loadColumn() ?: [];
return $actor_ids;
}
在 EventModel 中,我们在 loadFormData() 中添加了 actor_ids。
/**
* Method to get the data that should be injected in the form.
*
* @return mixed The data for the form.
*/
protected function loadFormData()
{
$app = Factory::getApplication();
// Check the session for previously entered form data.
$data = $app->getUserState('com_eventschedule.edit.event.data', []);
if (empty($data))
{
$data = $this->getItem();
// Add foreign key ids
$data->actor_ids = $this->getActorIds($data->id);
}
return $data;
}
这是 事件表单 中的 sql 字段,用于获取所有演员(id + 名称)以供选择。
<field
name="actor_ids"
type="sql"
query="SELECT id, `actor_name` FROM `#__eventschedule_actors`"
multiple="multiple"
header="COM_EVENTSCHEDULE_EVENT_FIELD_ACTORS_SELECT_HEADER"
key_field="id"
value_field="name"
label="COM_EVENTSCHEDULE_EVENT_FIELD_ACTORS_LABEL"
description="COM_EVENTSCHEDULE_EVENT_FIELD_ACTORS_DESC"
/>
3. 级联动态选择
在我们的组件中,我们有两个多对多关系。
- 演员和事件之间。
- 容器和部分之间。
设置好之后,我们将通过将事件放置在容器和部分中并指定开始时间(结束时间可以从开始时间加持续时间自动计算)来安排事件。在 JoomlaDagen 的例子中,我们有两个相同房间(部分)在两天(容器)中可用。但是,这样设置是为了确保这一点:一些部分可能只能在特定的容器中可用。
在选择容器后,我们只想看到可用的部分。因此,容器的选择将筛选部分的下拉列表。这被称为“级联选择”。
这可以通过几种方式实现
- 为每个容器的选择准备一个部分下拉列表,并且只显示与所选容器相对应的部分下拉列表。这可以通过使用“showon”属性轻松完成。
- 当选择容器时,向服务器发出 Ajax 调用并将正确的选项动态放入部分下拉列表中。对我来说,这是最明显的解决方案。
- 或者在选择容器后刷新整个页面,保留该选择并调整部分下拉列表。因为最后一个选项在 sql 表单字段开发者文档 中有很好的文档说明,在“链接字段作为过滤器”部分,所以我这次选择了这个。您可以下载 示例 com_sqlfield 来查看它的工作情况。
与我们的事件调度示例的不同之处在于,我们容器和部分之间的关系是 n:n。这就是为什么 sql 字段中的查询必须与连接表连接。我的定位子表单中的部分下拉字段看起来像这样(注意 sql_join 属性)
<field
name="section_id"
type="sql"
sql_select="section.id, section.section_name"
sql_from="#__eventschedule_container_section AS junction"
sql_join="#__eventschedule_sections AS section ON section.id=junction.section_id"
sql_order="section.ordering ASC"
sql_filter="container_id"
sql_default_container_id="0"
header="COM_EVENTSCHEDULE_LOCATOR_FIELD_SECTION_SELECT_HEADER"
key_field="id"
value_field="section_name"
label="COM_EVENTSCHEDULE_LOCATOR_FIELD_SECTION_LABEL"
description="COM_EVENTSCHEDULE_LOCATOR_FIELD_SECTION_DESC"
context="eventschedule"
>
在这里,我们选择特定容器中的所有部分(如 sql_filter 属性所指示)。在选择了容器后,表单被提交,页面使用容器下拉列表的值重新渲染以设置要选择的部分。
4. 前端:日程表
在第一集,我们展示了基于文章+附加字段的事件日程表。我们现在在自身组件中使用的布局模板基本上与第一集中的布局覆盖相同。但现在我们可以在模型中准备所有变量,避免在模板中编写编程逻辑。
在前端,我们主要需要我们的示例中的日程视图,这是一种特殊的事件列表视图。此视图还需要控制器、模型和模板布局文件(包括一个 xml 文件,用于在菜单项中引用此视图)。我们还创建了一个包含单个事件所有信息的 frontend 视图和作者列表视图。
最后但同样重要的是:我们为前端样式创建一个 css 文件,并将其放在 /media 目录下的组件中。如果我们需要为组件的任何javascript或通用图像,我们也将其放在这里。
您可以在 本系列的存储库 中查看我们迄今为止创建的完整组件。下一集,我们将更详细地查看前端。现在没有空间了,因为关于关系的绕道占据了我们的大部分空间。
下个月
添加内置的铃声和哨声
Joomla 具有很多内置功能,我们可以相对容易地在自定义组件中使用它们。在本集中,我们在基本组件中添加了语言字符串。但还有更多,比如通用参数、项目排序、别名处理、锁定、筛选和搜索、列表表格列排序、回收站、发布和工作流程、批量处理、分类、标签、分页、表单验证、访问控制列表(ACL)、附加字段、多语言关联、隐藏表格列、版本控制、添加自定义工具栏按钮、点击量、评分、路由和SEF URL、元数据、电子邮件隐藏、查找器、带有模块的额外视图、组件的插件扩展、API/网络服务和CLI。我们将通过一些有用的功能来扩展我们的示例组件。
可以帮助的工具
在下一集中,我们还将探讨一些在开发扩展时可能有所帮助的小工具,例如 create-joomla-extension, Jext 和 JoRobo。最初我们计划在下一个月的杂志中展示组件创建器,但已推迟一个月至11月的版本。请继续关注!
注释
- 在 第一集 中解释了为什么我使用“附加字段”而不是“自定义字段”。
- 有关关系数据库规范的介绍,请参阅例如 https://www.datacamp.com/tutorial/normalization-in-sql 和 https://en.wikipedia.org/wiki/Database_normalization。
- 在PHP中,Doctrine ORM 是使用 数据映射模式 的最详尽的ORM框架。它是“透明的”,这意味着你不会看到你的实体与数据库持久化之间的任何直接连接。在2013年,我关于在Joomla中使用 Doctrine ORM 的演讲。在Laravel中,你有基于 活动记录模式 的 Eloquent 作为ORM。这里有一个关于数据映射器和活动记录之间区别的 简要介绍。
-
核心n:1和n:n关系的主要持久性处理在表对象中。Joomla的表对象是实体。它们仅用于单个对象,而不是集合。在Laravel中则不同,那里的模型是实体。Laravel中的集合由集合对象处理。
然而,在Joomla中,内容相关表的1:1关系(文章的评分和特色)在文章模型中管理(特色在管理员端,评分在网站端)。
在Joomla中,表对象并没有完全用于所有CRUD活动。它是在表对象和模型之间的一种混合的角色分工。对于本文中事件日历示例的实现,我首先尽可能使用表对象。然而,在未来,我将在模型中做更多的事情;这更简单,因为它更符合Joomla中表对象有限的使用。Akeeba探索了表对象的有趣扩展使用,以在多个扩展中表示实体,也用于处理实体之间的关系。
为 Joomla 框架的实体包正在开发中,使用实体(而不是表对象)。它是一个活动记录实现,具有基本的ORM功能,灵感来自Laravel的Eloquent。它还使用与HasMany和BelongsTo等关系对象相同的名称。 - Joomla 核心中,评分是通过文章模型检索的(不是通过表对象),并通过投票内容插件进行样式化。
资源
构建一个组件
- Joomla! 程序员文档,我们不断扩大的在线信息来源。
- 书籍:《Joomla! 5 开发扩展》作者 Carlos Cámara。
- 在线书籍:“Joomla 扩展开发”,作者为Nicholas Dionysopoulos。
- Robbie Jackson 的视频。Robbie 的视频伴随着文档。还有很多关于 Joomla 3 扩展开发的内容仍然很有用。
- 查看核心组件。它们应该成为你自己的代码的示例。
- 书籍:“开发扩展:一步步创建可工作的 Joomla 扩展” by Astrid Günther。目前已售罄;希望会有新版本发布。
关于构建组件工具的文章
- 第 1 集:介绍和核心解决方案
- 第 2 集:Joomla 中的嵌入式应用程序
- 第 3 集:创建组件(本文)
示例事件日历组件的代码等额外材料可以在本系列的存储库中找到。
在 Joomla 社区杂志上发布的一些文章代表了作者对特定主题的个人观点或经验,可能与 Joomla 项目的官方立场不一致
接受后将访问由https://magazine.joomla.net.cn/之外的第三方提供的服务
评论