基本介绍

博客最基本的功能就是让作者能够自由发布自己的文章,分享自己观点,记录学习的过程。Halo 为用户提供了发布文章和展示自定义页面的功能,下面我们分析一下这些功能的实现过程。

管理员发布文章

Halo 项目中,文章和页面的实体类分别为 Post 和 Sheet,二者都是 BasePost 的子类。BasePost 对应数据库中的 posts 表,posts 表既存储了文章的数据,又存储了页面的数据,那么项目中是如何区分文章和页面的呢?下面是 BasePost 类的源码(仅展示部分代码):

@Data
@Entity(name = "BasePost")
@Table(name = "posts", indexes = {
@Index(name = "posts_type_status", columnList = "type, status"),
@Index(name = "posts_create_time", columnList = "create_time")})
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.INTEGER,
columnDefinition = "int default 0")
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class BasePost extends BaseEntity { @Id
@GeneratedValue(strategy = GenerationType.IDENTITY, generator = "custom-id")
@GenericGenerator(name = "custom-id", strategy = "run.halo.app.model.entity.support"
+ ".CustomIdGenerator")
private Integer id; /**
* Post title.
*/
@Column(name = "title", nullable = false)
private String title; /**
* Post status.
*/
@Column(name = "status")
@ColumnDefault("1")
private PostStatus status;
// 此处省略部分代码
}

我们知道,Halo 使用 JPA 来创建数据表、存储和获取表中的信息。上述代码中,注解 @DiscriminatorColumn 是之前文章中没有介绍过的,@DiscriminatorColumn 属于 JPA 注解,它的作用是当多个实体类对应同一个数据表时,可使用一个字段进行区分。name 指定该字段的名称,discriminatorType 是该字段的类型,columnDefinition 设置该字段的默认值。由此可知,字段 type 是区分文章和页面的依据,下面是 Post 类和 Sheet 类的源码:

// Post
@Entity(name = "Post")
@DiscriminatorValue(value = "0")
public class Post extends BasePost { } // Sheet
@Entity(name = "Sheet")
@DiscriminatorValue("1")
public class Sheet extends BasePost { }

Post 和 Sheet 都没有定义额外的属性,二者的区别仅在于注解 @DiscriminatorValue 设置的 value 不同。上文提到,@DiscriminatorColumn 指明了用作区分的字段,而 @DiscriminatorValue 的作用就是指明该字段的具体值。也就是说,type 为 0 时表示文章,为 1 时表示页面。另外,这里也简单介绍一下 @Index 注解,该注解用于声明表中的索引,例如 @Index(name = "posts_type_status", columnList = "type, status") 表示在 posts 表中创建 type 和 status 的复合索引,索引名称为 posts_type_status。

下面我们继续分析文章是如何发布的,首先进入到管理员界面,然后点击 "文章" -> "写文章",之后就可以填写内容了:

为了全面了解文章发布的过程,我们尽量将能填的信息都填上。点击 "发布",触发 /api/admin/posts 请求:

api/admin/posts 请求在 PostController 中定义:

@PostMapping
@ApiOperation("Creates a post")
public PostDetailVO createBy(@Valid @RequestBody PostParam postParam,
@RequestParam(value = "autoSave", required = false, defaultValue = "false") Boolean autoSave
) {
// 将 PostParam 对象转化为 Post 对象
// Convert to
Post post = postParam.convertTo();
// 根据参数创建文章
return postService.createBy(post, postParam.getTagIds(), postParam.getCategoryIds(),
postParam.getPostMetas(), autoSave);
}

该请求接收两个参数,即 postParam 和 autoSave。postParam 存储我们填写的文章信息,autoSave 是与系统日志有关的参数,默认为 false。服务器收到请求后,首先将接收到的 PostParam 对象转化为 Post 对象,然后从 PostParam 中提取出文章标签、分类、元数据等,接着调用 createBy 方法创建文章,createBy 方法的处理逻辑为:

public PostDetailVO createBy(Post postToCreate, Set<Integer> tagIds, Set<Integer> categoryIds,
Set<PostMeta> metas, boolean autoSave) {
// 创建或更新文章
PostDetailVO createdPost = createOrUpdate(postToCreate, tagIds, categoryIds, metas);
if (!autoSave) {
// 记录系统日志
// Log the creation
LogEvent logEvent = new LogEvent(this, createdPost.getId().toString(),
LogType.POST_PUBLISHED, createdPost.getTitle());
eventPublisher.publishEvent(logEvent);
}
return createdPost;
}

该方法会调用 createOrUpdate 方法创建文章,由于 autoSave 为 false,所以文章创建完成后会记录一条关于文章发布的系统日志。继续进入 createOrUpdate 方法,查看创建文章的具体过程:

private PostDetailVO createOrUpdate(@NonNull Post post, Set<Integer> tagIds,
Set<Integer> categoryIds, Set<PostMeta> metas) {
Assert.notNull(post, "Post param must not be null"); // 查看创建或更新的文章是否为私密文章
// Create or update post
Boolean needEncrypt = Optional.ofNullable(categoryIds)
.filter(HaloUtils::isNotEmpty)
.map(categoryIdSet -> {
for (Integer categoryId : categoryIdSet) {
// 文章分类是否设有密码
if (categoryService.categoryHasEncrypt(categoryId)) {
return true;
}
}
return false;
}).orElse(Boolean.FALSE); // 如果文章的密码不为空或者所属分类设有密码, 那么该文章就属于私密文章
// if password is not empty or parent category has encrypt, change status to intimate
if (post.getStatus() != PostStatus.DRAFT
&& (StringUtils.isNotEmpty(post.getPassword()) || needEncrypt)
) {
post.setStatus(PostStatus.INTIMATE);
} // 格式化文章的内容, 检查文章的别名是否有重复, 都没问题就创建文章
post = super.createOrUpdateBy(post); // 移除文章原先绑定的标签
postTagService.removeByPostId(post.getId()); // 移除文章原先绑定的分类
postCategoryService.removeByPostId(post.getId()); // 新设置的标签
// List all tags
List<Tag> tags = tagService.listAllByIds(tagIds); // 新设置的分类
// List all categories
List<Category> categories = categoryService.listAllByIds(categoryIds, true); // Create post tags
List<PostTag> postTags = postTagService.mergeOrCreateByIfAbsent(post.getId(),
ServiceUtils.fetchProperty(tags, Tag::getId)); log.debug("Created post tags: [{}]", postTags); // Create post categories
List<PostCategory> postCategories =
postCategoryService.mergeOrCreateByIfAbsent(post.getId(),
ServiceUtils.fetchProperty(categories, Category::getId)); log.debug("Created post categories: [{}]", postCategories); // 移除文章原有的元数据并将新设置的元数据绑定到该文章
// Create post meta data
List<PostMeta> postMetaList = postMetaService
.createOrUpdateByPostId(post.getId(), metas);
log.debug("Created post metas: [{}]", postMetaList); // 当文章创建或更新时清除对所有客户端的授权
// Remove authorization every time an post is created or updated.
authorizationService.deletePostAuthorization(post.getId()); // 返回文章的信息, 便于管理员界面展示
// Convert to post detail vo
return convertTo(post, tags, categories, postMetaList);
}
  1. 检查当前创建或更新的文章是否为私密文章,如果文章设有密码,或者文章所属的分类设有密码,那么就将文章的状态改为 INTIMATE,表示该文章属于私密文章。

  2. 对文章的内容进行格式化,也就是将原始内容转化为能够在前端展示的带有 HTML 标签的内容。然后检查文章的别名是否有重复,检查无误后在 posts 表中创建该文章。

  3. 移除文章原先绑定的所有标签和分类。

  4. 为文章重新绑定标签和分类,以标签为例,绑定的逻辑为:首先查询出文章原先绑定的标签,记为集合 A,然后将新设置的标签记为集合 B,之后在 post_tags 表中删除集合 A 中存在但集合 B 中不存在的记录,并创建集合 B 中不存在而集合 A 中存在的记录。步骤 4 其实和步骤 3 是有冲突的,因为步骤 3 将文章原先绑定的标签删除了,所以集合 A 中的元素总是为 0,实际上步骤 3 中的操作是多余的,可以将其注释掉。

  5. 移除文章原有的元数据,将新设置的元数据绑定到该文章。

  6. 删除对所有客户端的文章授权,文章授权是针对私密文章设置的,在下节中我们会分析一下文章授权的作用。

  7. 返回文章的具体信息,供管理员页面展示。

执行完以上步骤,一篇文章就创建或更新完成了:

用户端访问文章

本节介绍用户(普通用户,非管理员)访问文章的具体过程,上文中,我们在创建文章时为文章设置了密码,因此该文章属于私密文章。Halo 为私密文章设置了的 "授权" 机制,授权指的是当用户首次访问私密文章时,需要填写访问密码,服务器收到请求后,会检查用户的密码是否正确。如果正确,那么服务端会对客户端进行授权,这样当用户在短时间内再次访问该文章时可以不用重复输入密码。

默认情况下私密文章是不会在博客首页展示的,为了测试,我们修改 PostModel 中的 list 方法,首先将下面的代码注释掉:

Page<Post> postPage = postService.pageBy(PostStatus.PUBLISHED, pageable);

然后新增如下代码:

PostQuery query = new PostQuery();
query.setStatuses(new HashSet<>(Arrays.asList(PostStatus.PUBLISHED, PostStatus.INTIMATE)));
Page<Post> postPage = postService.pageBy(query, pageable);

这样,博客主页就可以展示状态为 "已发布" 和 "私密" 的文章了:

点击 "我的第一篇文章",触发 /archives/first 请求,first 为文章的别名 slug,该请求由 ContentContentController 的 content 方法处理(仅展示部分代码):

@GetMapping("{prefix}/{slug}")
public String content(@PathVariable("prefix") String prefix,
@PathVariable("slug") String slug,
@RequestParam(value = "token", required = false) String token,
Model model) {
PostPermalinkType postPermalinkType = optionService.getPostPermalinkType();
if (optionService.getArchivesPrefix().equals(prefix)) {
if (postPermalinkType.equals(PostPermalinkType.DEFAULT)) {
// 根据 slug 查询出文章的
Post post = postService.getBySlug(slug);
return postModel.content(post, token, model);
}
// 省略部分代码
}
}

上述方法首先根据 slug 查询出 title 为 "我的第一篇文章" 的文章,然后调用 postModel.content 方法封装文章的信息,postModel.content 方法的处理逻辑如下:

public String content(Post post, String token, Model model) {
// 文章在回收站
if (PostStatus.RECYCLE.equals(post.getStatus())) {
// Articles in the recycle bin are not allowed to be accessed.
throw new NotFoundException("查询不到该文章的信息");
} else if (StringUtils.isNotBlank(token)) {
// If the token is not empty, it means it is an admin request,
// then verify the token. // verify token
String cachedToken = cacheStore.getAny(token, String.class)
.orElseThrow(() -> new ForbiddenException("您没有该文章的访问权限"));
if (!cachedToken.equals(token)) {
throw new ForbiddenException("您没有该文章的访问权限");
}
// 手稿
} else if (PostStatus.DRAFT.equals(post.getStatus())) {
// Drafts are not allowed bo be accessed by outsiders.
throw new NotFoundException("查询不到该文章的信息");
//
} else if (PostStatus.INTIMATE.equals(post.getStatus())
&& !authenticationService.postAuthentication(post, null)
) {
// Encrypted articles must has the correct password before they can be accessed. model.addAttribute("slug", post.getSlug());
model.addAttribute("type", EncryptTypeEnum.POST.getName());
// 如果激活的主题定义了输入密码页面
if (themeService.templateExists(POST_PASSWORD_TEMPLATE + SUFFIX_FTL)) {
return themeService.render(POST_PASSWORD_TEMPLATE);
}
// 进入输入密码页面
return "common/template/" + POST_PASSWORD_TEMPLATE;
} post = postService.getById(post.getId()); if (post.getEditorType().equals(PostEditorType.MARKDOWN)) {
post.setFormatContent(MarkdownUtils.renderHtml(post.getOriginalContent()));
} else {
post.setFormatContent(post.getOriginalContent());
} postService.publishVisitEvent(post.getId()); postService.getPrevPost(post).ifPresent(
prevPost -> model.addAttribute("prevPost", postService.convertToDetailVo(prevPost)));
postService.getNextPost(post).ifPresent(
nextPost -> model.addAttribute("nextPost", postService.convertToDetailVo(nextPost))); List<Category> categories = postCategoryService.listCategoriesBy(post.getId(), false);
List<Tag> tags = postTagService.listTagsBy(post.getId());
List<PostMeta> metas = postMetaService.listBy(post.getId()); // Generate meta keywords.
if (StringUtils.isNotEmpty(post.getMetaKeywords())) {
model.addAttribute("meta_keywords", post.getMetaKeywords());
} else {
model.addAttribute("meta_keywords",
tags.stream().map(Tag::getName).collect(Collectors.joining(",")));
} // Generate meta description.
if (StringUtils.isNotEmpty(post.getMetaDescription())) {
model.addAttribute("meta_description", post.getMetaDescription());
} else {
model.addAttribute("meta_description",
postService.generateDescription(post.getFormatContent()));
} model.addAttribute("is_post", true);
model.addAttribute("post", postService.convertToDetailVo(post));
model.addAttribute("categories", categoryService.convertTo(categories));
model.addAttribute("tags", tagService.convertTo(tags));
model.addAttribute("metas", postMetaService.convertToMap(metas)); if (themeService.templateExists(
ThemeService.CUSTOM_POST_PREFIX + post.getTemplate() + SUFFIX_FTL)) {
return themeService.render(ThemeService.CUSTOM_POST_PREFIX + post.getTemplate());
} return themeService.render("post");
}
  1. 如果文章状态为 "草稿" 或 "位于回收站",那么向前端反馈无文章信息。如果请求的 Query 中存在 token,那么该请求为一个 admin 请求(与管理员在后台浏览文章时发送的请求是类似的,只不过在管理员界面访问文章时 token 存储在请求的 Header 中,这里的 token 存储在请求的 Query 参数中),此时检查 token 是否有效。如果文章状态为 "私密" 且客户端并未获得 "授权",那么重定向到密码输入页面,否则执行如下步骤。

  2. 对文章内容进行格式化,记录文章被访问的系统日志。

  3. 在 model 中封装文章的内容、标签、分类、元数据、前一篇文章、后一篇文章等信息,然后利用 FreeMaker 基于 post.ftl 文件(已激活主题的)生成 HTML 页面。

对于 "已发布" 和 "已获得授权" 的文章,请求处理完成后用户可直接看到文章的内容。由于 "我的第一篇文章" 属于私密文章且并未对用户进行授权,因此页面发生了重定向:

输入密码后,点击 "验证",触发 content/post/first/authentication 请求,该请求由 ContentContentController 的 password 方法处理:

@PostMapping(value = "content/{type}/{slug:.*}/authentication")
@CacheLock(traceRequest = true, expired = 2)
public String password(@PathVariable("type") String type,
@PathVariable("slug") String slug,
@RequestParam(value = "password") String password) throws UnsupportedEncodingException { String redirectUrl;
// 如果 type 为 post
if (EncryptTypeEnum.POST.getName().equals(type)) {
// 授权操作
redirectUrl = doAuthenticationPost(slug, password);
} else if (EncryptTypeEnum.CATEGORY.getName().equals(type)) {
redirectUrl = doAuthenticationCategory(slug, password);
} else {
throw new UnsupportedException("未知的加密类型");
}
return "redirect:" + redirectUrl;
}

因为 URL 中的 type 为 post(我们访问的是文章),因此由 doAuthenticationPost 方法为客户端授权:

private String doAuthenticationPost(
String slug, String password) throws UnsupportedEncodingException {
Post post = postService.getBy(PostStatus.INTIMATE, slug); post.setSlug(URLEncoder.encode(post.getSlug(), StandardCharsets.UTF_8.name())); authenticationService.postAuthentication(post, password); BasePostMinimalDTO postMinimalDTO = postService.convertToMinimal(post); StringBuilder redirectUrl = new StringBuilder(); if (!optionService.isEnabledAbsolutePath()) {
redirectUrl.append(optionService.getBlogBaseUrl());
} redirectUrl.append(postMinimalDTO.getFullPath()); return redirectUrl.toString();
}

上述代码中,authenticationService.postAuthentication(post, password); 是为客户端进行授权操作的,授权完成后服务器会将请求重定向到 /archives/first,如果授权成功那么用户就可以看到文章的内容,如果授权失败那么仍然处于 "密码输入" 页面。进入到 postAuthentication 方法查看授权的具体过程(省略部分代码):

public boolean postAuthentication(Post post, String password) {
// 从 cacheStore 中查询出当前客户端已获得授权的文章 id
Set<String> accessPermissionStore = authorizationService.getAccessPermissionStore(); // 如果文章的密码不为空
if (StringUtils.isNotBlank(post.getPassword())) {
// 如果已经受过权
if (accessPermissionStore.contains(AuthorizationService.buildPostToken(post.getId()))) {
return true;
}
// 如果密码正确就为客户端授权
if (post.getPassword().equals(password)) {
authorizationService.postAuthorization(post.getId());
return true;
}
return false;
}
// 省略部分代码
}
  1. 首先利用 cacheStore 查询出当前客户端已获得授权的文章 id,cacheStore 是一个以 ConcurrentHashMap 为容器的内部缓存,该操作指的是从缓存中查询出 key 为 "ACCESS_PERMISSION: sessionId" 的 value(一个 Set 集合),其中 sessionId 是当前 session 的 id。我们在前一篇文章中介绍过 Halo 中的 3 个过滤器,用户端(非管理员)发送的浏览文章的请求会被 ContentFilter 拦截,且每次拦截后都会执行 doAuthenticate 方法,该方法中的 request.getSession(true); 保证了服务端一定会创建一个 session。session 创建完成后存储在服务端,默认情况下服务端会为客户端分配一个特殊的 cookie,名称为 "JSESSIONID",其存储的值就是 session 的 id。之后客户端发送请求时,服务端可以通过请求中的 cookie 查询出存储的 session,并根据 session 确定客户端的身份。因此可以使用 "ACCESS_PERMISSION: sessionId" 作为 key 来保存当前客户端已获得授权的文章 id。

  2. 判断文章的密码是否为空,不为空就表示文章本身属于私密文章,为空表示文章属于设有密码的分类。因为文章设有密码,所以继续执行下面的步骤。

  3. 查看 accessPermissionStore 中(步骤 1 查询出的 Set 集合)是否包含正在访问的文章的 id。如果包含,那么就表示已为当前客户端授权过,不包含的话继续执行下面的步骤。

  4. 判断用户输入的密码与文章的密码是否相同,相同的话就为客户端授权,也就是在 accessPermissionStore 中存储当前文章的 id。

授权成功后,客户端再次访问该私密文章时,服务器可以根据 cookie 得到 sessionId,然后从 cacheStore 中查询出 key 为 "ACCESS_PERMISSION: sessionId" 的 value,判断 value 中是否包含当前文章的 id。如果包含,那么就表示客户端已经获得了文章的访问权限,服务器可向前端返回文章内容,这个过程对应的是前文中 postModel.content 方法的处理逻辑。

了解了 Halo 中的 "文章授权" 机制后,我们就能明白为什么服务器在创建或更新文章时会删除对所有客户端的授权。因为此时文章的信息发生了变化,密码也可能重置,因此客户端需要重新输入密码。结合前一篇文章我们可以得出结论:管理员访问管理员界面上的功能时,服务器根据 Request Headers 中的 token 来确定管理员的身份。普通用户访问私密文章时,服务器根据 cookie 判断用户是否具有文章的访问权限,cookie 和 token 是两种非常重要的身份认证方式。

客户端(浏览器)保存的cookie:

客户端访问文章时请求中携带 cookie:

元数据

元数据指的是描述数据属性的信息,Halo 中可为文章设置元数据。创建或更新文章时,点击 "高级" 选项后即可添加元数据,默认的主题 caicai_anatole 没有提供可使用的元数据,所以我们将更主题更换为 joe2.0(其它主题也可),为文章添加元数据:

上图中,我们为 "我的第一篇文章" 添加了一个元数据,其中 meta_key 为 "enable_like",meta_value 为 "false",表示该文章不允许点赞。前文中介绍过,用户访问文章时,服务器会将文章的信息进行封装,其中就包括文章的元数据,封装完成后,由 FreeMaker 基于 post.ftl 文件生成用于浏览的 HTML 页面。joe2.0 主题的 post.ftl 文件会根据 "enable_like" 的值决定是否显示点赞按钮。

文章 "Hello Halo" 可以点赞:

"我的第一篇文章" 不可以点赞:

除了 "enable_like",还可以设置文章是否支持 mathjax 以及设置文章中图片的宽度等。

自定义页面

Halo 中除文章外,博主还可以对外分享自定义的页面,例如博客主页的 "关于页面" 就是系统在初始化博客时为我们创建的页面。页面和文章的创建、查看等操作是相似的,所以代码的具体执行过程就不再介绍了。页面创建完成后,在管理员界面的依次点击 "外观" -> "菜单" -> "其他" -> "从系统预设链接添加" -> "自定义页面" -> "添加" 即可在博客主页完成页面的展示:

Halo 开源项目学习(四):发布文章与页面的更多相关文章

  1. Halo 开源项目学习(七):缓存机制

    基本介绍 我们知道,频繁操作数据库会降低服务器的系统性能,因此通常需要将频繁访问.更新的数据存入到缓存.Halo 项目也引入了缓存机制,且设置了多种实现方式,如自定义缓存.Redis.LevelDB ...

  2. Halo 开源项目学习(二):实体类与数据表

    基本介绍 Halo 项目中定义了一些实体类,用于存储博客中的关键数据,如用户信息.文章信息等.在深入学习 Halo 的设计理念与实现过程之前,不妨先学习一下一个完整的博客系统都由哪些元素组成. 实体类 ...

  3. Halo 开源项目学习(五):评论与点赞

    基本介绍 博客系统中,用户浏览文章时可以在文章下方发表自己的观点,与博主或其他用户进行互动,也可以为喜欢的文章点赞.下面我们一起分析一下 Halo 项目中评论和点赞功能的实现过程. 发表评论 评论可以 ...

  4. Halo 开源项目学习(六):事件监听机制

    基本介绍 Halo 项目中,当用户或博主执行某些操作时,服务器会发布相应的事件,例如博主登录管理员后台时发布 "日志记录" 事件,用户浏览文章时发布 "访问文章" ...

  5. Halo 开源项目学习(一):项目启动

    项目简介 Halo 是一个优秀的开源博客发布应用,在 GitHub 上广受好评,正好最近在练习写博客,借此记录一下学习 Halo 的过程. 项目下载 从 GitHub 上拉取项目源码,Halo 从 1 ...

  6. Halo 开源项目学习(三):注册与登录

    基本介绍 首次启动 Halo 项目时需要安装博客并注册用户信息,当博客安装完成后用户就可以根据注册的信息登录到管理员界面,下面我们分析一下整个过程中代码是如何执行的. 博客安装 项目启动成功后,我们可 ...

  7. 转:从开源项目学习 C 语言基本的编码规则

    从开源项目学习 C 语言基本的编码规则 每个项目都有自己的风格指南:一组有关怎样为那个项目编码约定.一些经理选择基本的编码规则,另一些经理则更偏好非常高级的规则,对许多项目而言则没有特定的编码规则,项 ...

  8. Spring Boot 项目学习 (四) Spring Boot整合Swagger2自动生成API文档

    0 引言 在做服务端开发的时候,难免会涉及到API 接口文档的编写,可以经历过手写API 文档的过程,就会发现,一个自动生成API文档可以提高多少的效率. 以下列举几个手写API 文档的痛点: 文档需 ...

  9. android开源项目学习

    FBReaderJ FBReaderJ用于Android平台的电子书阅读器,它支持多种电子书籍格式包括:oeb.ePub和fb2.此外还支持直接读取zip.tar和gzip等压缩文档. 项目地址:ht ...

随机推荐

  1. 组合(n选k问题)

    #include "iostream.h" #include "string.h" int a[100]; void dfs(int n,int k) { if ...

  2. Dubbo 用到哪些设计模式?

    Dubbo 框架在初始化和通信过程中使用了多种设计模式,可灵活控制类加载.权 限控制等功能. 工厂模式 Provider 在 export 服务时,会调用 ServiceConfig 的 export ...

  3. jQuery--内容过滤和可见性过滤

    一.内容过滤 1.内容过滤选择器介绍 :empty 当前元素是否为空(是否有标签体) :contains(text)   标签体是否含有指定的文本 :has(...)                 ...

  4. 在 Spring MVC 应用程序中使用 WebMvcTest 注释有什么用处?

    在测试目标只关注 Spring MVC 组件的情况下,WebMvcTest 注释用于单元测试 Spring MVC 应用程序.在上面显示的快照中,我们只想启动 ToTestController. 执行 ...

  5. labview和matlab区别

    LabVIEW和MATLAB作为本身功能比较完善的软件环境,在各自不同的领域中有着十分广泛的应用.下面小编就详细介绍LabVIEW和MATLA以及它们之间的区别. 一.LabVIEW简介 LabVIE ...

  6. 前馈控制+PID

    参考来源: 北京交通大学 硕士学位论文 基于脉冲串控制的含位置反馈和前馈补偿的位置控制算法的研究  赵旺升

  7. 如何正确的阅读Datasheet?

    不仅仅是芯片,包括工具.设备几乎任何电子产品,都需要去阅读它的datasheet,除了包括最低.最高要求,特点,建议和用途及其兼容的设备等等,更重要的是原厂商以一个成功者的身份去告诉你一些注意事项. ...

  8. Service worker (@nuxtjs/workbox) 采坑记

    PWA(Progressive Web App)是前端的大趋势,它能极大的加快前端页面的加载速度,得到近乎原生 app 的展示效果(其实难说).PWA 其实是多种前端技术的组合,其中最重要的一个技术就 ...

  9. 原生ES6写的Web游戏:ES6-Mario,小美女,小帅哥快来玩啊~~

    ? ES6-Mario 这是一个用原生ES6语法和HTML5新特性写成的Web 游戏. 通过这个项目,你可以在实践中对ES6的主要内容.HTML Canvas 相关API以及Webpack的基础配置有 ...

  10. Element UI table参数中的selectable的使用

    Element UI table参数中的selectable的使用中遇到的坑:页面: <el-table-column :selectable='selectable' type="s ...