整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

“整洁架构”和商家前端的重构之路

“整洁架构”和商家前端的重构之路

|陈子煜 得物技术

1. 背景

团队归属于后方业务支撑部门,组内的项目都以pc中后台应用为主。对比移动端应用,代码库比较庞大,业务逻辑也相对复杂。在持续的迭代过程中,我们发现当前的代码仓库仍然有不少可以优化的点:

  • 可以减弱对ui框架的依赖

21年前端平台决定技术栈统一迁移到React生态,后续平台的基础建设也都围绕React展开,这就使得商家使用Vue生态做开发的系统面临技术栈迁移的难题,将业务逻辑和UI框架节藕变得异常重要。

  • 代码风格可以更加统一

随着代码量和团队成员的增加,应用里风格迥异的代码也越来越多。为了能够持续迅速的进行迭代,团队急需一套统一的顶层代码架构设计方案。

  • 可以集成自动化测试用例

随着业务变得越来越复杂,在迅速的迭代过程中团队需要频繁地对功能进行回归,因此我们对于自动化单测用例的诉求也变的越来越强烈。

为了完成以上的优化,四组对现有的应用架构做了一次重构,而重构的核心就是整洁架构。

2. 整洁架构(The Clean Architecture)

整洁架构(The clean architecture)是由 Robert C. Martin (Uncle Bob)在2012年提出的一套代码组织的理念,其核心主要是依据各部分代码作用的不同将其拆分成不同的层次,在各层次间制定了明确的依赖原则,以达到以下目的:

  1. 与框架无关:无论是前端代码还是服务端代码,其逻辑本身都应该是独立的,不应该依赖于某一个第三方框架或工具库。一套独立的代码可以把第三方框架等作为工具使用。
  2. 可测试:代码中的业务逻辑可以在不依赖ui、数据库、服务器的情况下进行测试
  3. 和ui无关:代码中的业务逻辑不应该和ui做强绑定。比如把一个web应用切换成桌面应用,业务逻辑不应该受到影响。
  4. 和数据库无关:无论数据库用的是mysql还是mongodb,无论其怎么变,都不该影响到业务逻辑。
  5. 和外部服务无关:无论外部服务怎么变,都不影响到使用该服务的业务逻辑。

为了实现以上目的,整洁架构把应用划分成了entities、use cases、interface adapters(MVC、MVP等)、Web/DB等至少四层。这套架构除了分层之外,在层与层之间还有一个非常明确的依赖关系,外层的逻辑依赖内层的逻辑

Entity

entities封装了企业级的业务逻辑和规则。entities没有什么固定的形式,无论是一个对象也好,是一堆函数的集合也好,唯一的标准就是能够被企业的各个应用所复用。

Use Case

entities封装了企业里最通用的一部分逻辑,而应用各自的业务逻辑就都封装在use case里面。日常开发中最常见的对于某个模型的crud操作就属于usecase这一层。

Interface Adapter

这一层类似于胶水层,需要负责内圈的entity和use case同外圈的external interfaces之间的数据转化。需要把外层服务的数据转化成内层entity和usecase可以消费的数据,反之亦然。如上面图上画的,这一层有时候可能很简单(一个转化函数), 有时候可能复杂到包含一整个MVC/MVP的架构。

External Interfaces

我们需要依赖的外部服务,第三方框架,以及需要糊的页面UI都归属在这一层。这一层完全不感知内圈的任何逻辑,所以无论这一层怎么变(ui变化),都不应该影响到内圈的应用层逻辑(usecase)和企业级逻辑(entity)。

依赖原则

在整洁架构的原始设计中,并不是强制一定只能写这么四层,根据业务的需要还可以拆分的更细。不过无论怎么拆,都需要遵守前面提到的从外至内的依赖原则。即entity作为企业级的通用逻辑,不能依赖任何模块。而外层的ui等则可以使用usecase、entity。

3. 重构

前面介绍了当前代码库目前的一些具体问题,而整洁架构的理念正好可以帮助我们优化代码可维护性。

作为前端,我们的业务逻辑不应该依赖视图层(ui框架及其生态),同时应当保证业务逻辑的独立性和可复用性(usecase & entity)。最后,作为数据驱动的端应用,要保证应用视图渲染和业务逻辑等不受数据变动的影响(adapter & entity)。

根据以上的思考,我们对“整洁架构”做了如下落地。

Entities

对于前端应用来说,在entity层我们只需要将服务端的生数据做一层简单的抽象,生成一个贫血对象给后续的渲染和交互逻辑使用。


interface IRawOrder {
  amount: number
  barCode: string
  orderNo: string
  orderType: string
  skuId: number
  deliveryTime: number
  orderTime: number
  productImg: string
  status: number
}

export default function buildMakeOrder({
  formatTimestamp,
  formatImageUrl,
}: {
  formatTimestamp: (timestamp: number, format?: string)=> string
  formatImageUrl: (
    image: string,
    config?: { width: number; height: number },
  )=> string
}) {
  return function makeOrder(raw?: IRawOrder) {
    if (!raw || !raw.orderNo) {
       Monitor.warn('脏数据')
       return null;
    }
    return {
      amount: raw.amount,
      barCode: raw.barCode,
      orderNo: raw.orderNo,
      orderType: raw.orderType,
      skuId: raw.skuId,
      status: raw.status,
      statusDescription: selectStatusDescription(raw.status),
      deliveryTime: formatTimestamp(raw.deliveryTime),
      orderTime: formatTimestamp(raw.orderTime),
      productImg: formatImageUrl(raw.productImg),
    }
  }
}

function selectStatusDescription(status: number): string {
  switch (status) {
    case 0:
      return '待支付'
    case 1:
      return '待发货'
    case 2:
      return '待收货'
    case 3:
      return '已完成'
    default:
      return ''
  }
}

以上是商家后台订单模型的entity工厂函数,工厂主要负责对服务端返回的生数据进行加工处理,让其满足渲染层和逻辑层的要求。除了抽象数据之外,可以看到在entity工厂还对数据进行了校验,将脏数据、不符合预期的数据全部处理掉或者进行兜底(具体操作要看业务场景)。

有一点需要注意的是,在设计entity的时候(尤其是基础entity)需要考虑复用性。举个例子,在上面orderEntity的基础上,我们通过简单的组合就可以生成一个虚拟商品订单entity:

import { makeOrder } from '@/entities'

export default function buildMakeVirtualOrder() {
  return function makeVirtualOrder(raw?: IRawPresaleOrder) {
     const order=makeOrder(raw)

     if(! order || !raw.virtualOrderType) {
         Monitor.warn('脏数据')
         return null
     }

     return {
         ...order,
         virtualOrderType: raw.virtualOrderType,
         virtualOrderDesc: selectVirtualOrderDesc(raw.virtualOrderType)
     }
  }
}

如此一来,我们就通过entity层达到了2个目的:

  1. 把前端的逻辑和服务端接口数据隔离开,无论服务端怎么变,前端后续的渲染、业务代码不需要变,我们只需要变更entitiy工厂函数;并且经过entity层处理过后,所有流入后续渲染&交互逻辑的数据都是可靠的;对于部分异常数据,前端应用可以第一时间发现并报警。
  2. 通过对业务模型进行抽象,实现了模块间的组合、复用。另外,抽象出的entity对代码的维护性也有非常大的帮助,开发者可以非常直观的知道所使用的entity所包含的所有字段。

Usecase

usecase这一层即是围绕entity展开的一系列crud操作,以及为了页面渲染做的一些联动(通过ui store实现)。由于当前架构的原因(没有bff层),usecase还可能承担部分微服务串联的工作。

举个例子,商家后台订单页面在渲染前有一堆准备逻辑:

  1. 根据route的query参数以及一些商家类型参数来决定默认选中哪个tab
  1. 根据是国内商家还是境外商家,调用对应的供应商接口来更新供应商下拉框

现在大致的实现是:

{
    mounted() {
        const { subType }=this.$route.query
        /*
            7-15行处理了几种分支链路场景下对subType的赋值问题
        */
        if (Number(subType)===0 || subType) {
          this.subType=subType.toString()
        } else {
          if (this.user.merchant.typeId===4) {
            this.subType=this.tabType.cross
          } else {
            this.subType=this.tabType.ordinarySpot
          }
        }

        /*
            getAllLogisticsCarrier有没有对subType赋值呢?光看这段代码完全不确定
        */
        this.getAllLogisticsCarrier()
        /*
            21-22行又多出来一个分支需要对subType进行再次赋值
        */
        if (this.isPersonPermission && !this.crossUser) {
          this.subType=this.tabType.warehouse
        }
    },

    getAllLogisticsCarrier() {
        let getCarrier=API.getAllLogisticsCarrier
        if (this.crossUser) {
          getCarrier=API.getOrderShipAllLogistics
        }

        getCarrier({}).then(res=> {
          if (res.code===200) {
            const options=[]

            .......... // 给options赋值

            this.options2=options

          }
        })
    },
}

我们能看到7-15、24-125行对this.subType进行了赋值。但由于我们无法确定20行的函数是否也对this.subType进行了赋值,所以光凭mounted函数的代码我们并不能完全确定subType的值究竟是什么,需要跳转到getAllLogisticsCarrier函数确认。这段代码在这里已经做了简化,实际的代码像getAllLogisticsCarrier这样的调用还有好几个,要想搞清楚逻辑就得把所有函数全看一遍,代码的可读性一般。同时,由于函数都封装在ui组件里,因此要想给函数覆盖单测的话也需要一些改造。

为了解决问题,我们将这部分逻辑都拆分到usecase层:

// prepare-order-page.ts
import { tabType } from '@/constants'

interface IParams {
  subType?: number
  merchantType: number
  isCrossUser: boolean
  isPersonPermission: boolean
}

/*
    做依赖倒置主要是为了方便后续的单测和复用
*/
export default function buildPrepareOrderPage({
  queryLogisticsCarriers,
}: {
  queryLogisticsCarriers: ()=> Promise<{ carriers: ICarrires }>
}) {
  return async function prepareOrderPage(params: IParams) {
    const activeTab=selectActiveTab(params)

    const { carriers }=queryLogisticsCarriers(params.isCrossUser)

    return {
      activeTab,
      carriers,
    }
  }
}

function selectActiveTab({
  subType,
  isCrossUser,
  isPersonPermission,
  merchantType,
}: IParams) {
  if (isPersonPermission && !isCrossUser) {
    return tabType.warehouse
  }

  if (Number(subType)===0 || subType) {
    return subType.toString()
  }

  if (merchantType===4) {
    return tabType.cross
  }

  return tabType.ordinarySpot
}
// query-logistics-carriers
export default function buildQueryLogisticsCarriers({
  fetchAllLogisticsCarrier,
  fetchOrderShipAllLogistics,
}: {
  fetchAllLogisticsCarrier: ()=> Promise<{ data: {carriers: ICarrires }}>
  fetchOrderShipAllLogistics: ()=> Promise<{ data: {carriers: ICarrires }}>
}) {
  return async function queryLogisticsCarriers(isCrossUser: boolean) {
    if (isCrossUser) {
      return fetchAllLogisticsCarrier()
    }

    return fetchOrderShipAllLogistics()
  }
}

// index.vue
{
    mounted() {
        const {activeTab, carriers}=prepareOrderPage(params)

        this.subType=activeTab;
        this.options=buildCarrierOptions(carriers) // 将carries转换成下拉框option
    }
}

首先,可以看到所有usecase一定是一个纯函数,不会存在副作用的问题。

其次,prepareOrderPage usecase专门为订单页定制,拆分后一眼就能看出来订单页的准备工作需要干决定选中的tab和拉取供应商列表两件事情。而另一个拆分出来的queryLogisticsCarriers则是封装了商家后台跨境、国内两种逻辑,后续无论跨境还是国内的逻辑如何变更,其影响范围被限制在了queryLogisticsCarriers函数,我们需要对其进行功能回归;而对于prepareOrderPage来说,queryLogisticsCarriers只是()=> Promise<{ carriers: ICarrires }>的一个实现而已,其内部调用queryLogisticsCarriers的逻辑完全不受影响,不需要进行回归。

最后,而由于我们做了依赖倒置,我们可以非常容易的给usecase覆盖单测:


import buildPrepareOrderPage from '@/utils/create-goods';

function init() {
  const queryLogisticsCarriers=jest.fn();

  const prepareOrderPage=buildPrepareOrderPage({ queryLogisticsCarriers });

  return {
    prepareOrderPage,
    queryLogisticsCarriers,
  };
}

describe('订单页准备逻辑', ()=> {
  it('当用户是国内商家且在入仓白名单上,在打开订单页时,默认打开入仓tab', async ()=> {
    const { prepareOrderPage }=init();
    const params={
        merchantType: 2
        isCrossUser: false
        isPersonPermission: true
    }

    const { activeTab }=await prepareOrderPage(params)

    expect(activeTab).toEqual({tabType.warehouse});
  });

   it('当用户是跨境商家,在打开订单页时,默认打开跨境tab', async ()=> {
    const { prepareOrderPage }=init();
    const params={
        merchantType: 4
        isCrossUser: true
        isPersonPermission: true
    }

    const { activeTab }=await prepareOrderPage(params)

    expect(activeTab).toEqual({tabType.cross});
  });

  ......
});

单测除了进行功能回归之外,它的描述(demo里使用了Given-When-Then的格式,由于篇幅的原因,关于单测的细节在后续的文章再进行介绍)对于了解代码的逻辑非常非常非常有帮助。由于单测和代码逻辑强行绑定的缘故,我们甚至可以将单测描述当成一份实时更新的业务文档。

除了方便写单测之外,在通过usecase拆分完成之后,ui组件真正成为了只负责“ui”和监听用户交互行为的组件,这为我们后续的React技术栈迁移奠定了基础;通过usecase我们也实现了很不错的模块化,对于使用比较多的一些entity,他的crud操作可以通过独立的usecase具备了在多个页面甚至应用间复用的能力。

Adapter

上面usecase例子中的fetchAllLogisticsCarrier就是一个adapter,这一层起到的作用是将外部系统返回的数据转化成entity,并以一种统一的数据格式返回回来。

这一层很核心的一点即是可以依赖entity的工厂函数,将接口返回的数据转化成前端自己设计的模型数据,保证流入usecase和ui层的数据都是经过处理的“干净数据”。除此之外,通常在这一层我们会用一种固定的数据格式返回数据,比如例子中的 {success: boolean, data?: any}。这样做主要是为了抹平对接多个系统带来的差异性,同时减少多人协作时的沟通成本。


type Request=(url: string, params: Record<string, any>)=> Promise<any>;
import makeCarrier from '@/entities/makeCarrier'


export default function buildFetchAllLogisticsCarrier({request}: {request: Request}) {
  return async function fetchAllLogisticsCarrier() {
    // TODO: 异常处理
    const response=await request('/fakeapi', info)

    if (!response || !resposne.code===200) {
        return { 
            success: false
        }
    }

    return {
      success: true,
      data: {
          carriers: response.list?.map(makeCarrier)
      }
    }
  }
}

通过Adapter + entity的组合,我们基本形成了前端应用和后端服务之间的防腐层,使得前端可以在完全不清楚接口定义的情况下完成ui渲染、usecase等逻辑的开发。在服务端产出定义后,前端只需要将实际接口返回适配到自己定义的模型(通过entity)即可。这一点对前端的测试周提效非常非常非常重要,因为防腐层的存在,我们可以在测试周完成需求评审之后根据prd的内容设计出业务模型,并以此完成需求开发,在真正进入研发周后只需要和服务端对接完成adapter这一层的适配即可。

在实践过程中,我们发现在对接同一个系统的时候(对商家来说就是stark服务)各个adapter对于异常的处理几乎一模一样(上述的11-15行),我们可以通过Proxy对其进行抽离实现复用。当然,后续我们也完全有机会根据接口定义来自动生成adapter。

UI

在经过前面的拆分之后,无论咱们的UI层用React还是Vue来写,要做的工作都很简单了:

  1. 监听交互事件并调用对应的usecase来进行响应
  1. 通过usecase来获取entity数据进行渲染

由于entity已经做了过滤和适配处理,所以在ui层我们可以放心大胆的用,不需要再写一堆莫名其妙的判断逻辑。另外由于entity是由前端自己定义的模型,无论开发过程中服务端接口怎么变,受影响的都只有entity工厂函数,ui层不会受到影响。

最后,在ui层我们还剩下令人头痛的技术栈迁移问题。整个团队目前使用vue的项目有10个,按迭代频率和项目规模迁移的方案可以分为两类:

  • 迭代频繁的大应用:主要包括代码行数较多、逻辑较为复杂的几个中大型应用。这些应用想要一把梭直接完成迁移成本极高,但同时每个迭代又有相当的需求。基于这种情况,对于这三个应用我们采取了微前端的方式进行迁移。每个应用分别起一个对应的React应用,对于新页面以及部分逻辑已经完全和ui解藕迁移成本不高的业务,都由React应用来承接,最后通过module federation的方式实现融合。
  • 迭代不频繁的小应用:剩下的应用均是复杂度不高的小应用,这部分应用迭代的需求不多,以维护为主。因此我们的方案是对现有逻辑进行整洁架构重构,在ui和逻辑分层之后直接对ui层进行替换完成迁移。

4. 后续

通过整洁架构我们形成了统一的编码规范,在前端应用标准化的道路上迈下了坚实的一步。可以预见的是整个标准化的过程会非常漫长,我们会陆续往标准中增加新的规范使其更加完善,短期内在规划中的有:

  • 单测即文档:上面提到了usecase通过依赖倒置来配合单测落地,后续团队期望将一些业务逻辑的实现细则通过单测的描述来进行沉淀,解决业务文档实时性的问题。
  • 完善监控体系:前端常遇到的3种异常包括 代码逻辑异常、性能瓶颈(渲染卡顿、内存不足等)、数据导致异常。对于数据异常,我们可以在entity层映射的过程中加入对异常数据的埋点上报来填补目前监控的空白。(代码逻辑异常通过sentry已经监控,性能监控对于中后台应用不需要)

后续在标准逐渐稳定之后,我们也期望基于稳定的规范进行一些工程化的实践(比如根据mooncake文档自动生成adapter层、基于usecase实现功能开关等),敬请期待。

参考链接:

The Clean Architecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Module Federationhttps://webpack.js.org/concepts/module-federation/

Anti-corruption Layer pattern:https://docs.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer


*文/陈子煜

关注得物技术,每周一三五晚18:30更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~

译自: https://opensource.com/article/18/10/book-to-website-epub-using-pandoc

作者: Kiko Fernandez-reyes

译者: jlztan

通过 Markdown 和 Pandoc,可以做到编写一次,发布两次。

Pandoc 是一个命令行工具,用于将文件从一种标记语言转换为另一种标记语言。在我 对 Pandoc 的简介 一文中,我演示了如何把 Markdown 编写的文本转换为网页、幻灯片和 PDF。

在这篇后续文章中,我将深入探讨 Pandoc ,展示如何从同一个 Markdown 源文件生成网页和 ePub 格式的电子书。我将使用我即将发布的电子书《 面向对象思想的 GRASP 原则 》为例进行讲解,这本电子书正是通过以下过程创建的。

首先,我将解释这本书使用的文件结构,然后介绍如何使用 Pandoc 生成网页并将其部署在 GitHub 上;最后,我演示了如何生成对应的 ePub 格式电子书。

你可以在我的 GitHub 仓库 Programming Fight Club 中找到相应代码。

设置图书结构

我用 Markdown 语法完成了所有的写作,你也可以使用 HTML 标记,但是当 Pandoc 将 Markdown 转换为 ePub 文档时,引入的 HTML 标记越多,出现问题的风险就越高。我的书按照每章一个文件的形式进行组织,用 Markdown 的 H1 标记(#)声明每章的标题。你也可以在每个文件中放置多个章节,但将它们放在单独的文件中可以更轻松地查找内容并在以后进行更新。

元信息遵循类似的模式,每种输出格式都有自己的元信息文件。元信息文件定义有关文档的信息,例如要添加到 HTML 中的文本或 ePub 的许可证。我将所有 Markdown 文档存储在名为 parts 的文件夹中(这对于用来生成网页和 ePub 的 Makefile 非常重要)。下面以一个例子进行说明,让我们看一下目录,前言和关于本书(分为 toc.md、preface.md 和 about.md 三个文件)这三部分,为清楚起见,我们将省略其余的章节。

关于本书这部分内容的开头部分类似:

# About this book {-}

## Who should read this book {-}

Before creating a complex software system one needs to create a solid foundation.

General Responsibility Assignment Software Principles (GRASP) are guidelines to assign

responsibilities to software classes in object-oriented programming.

每一章完成后,下一步就是添加元信息来设置网页和 ePub 的格式。

生成网页

创建 HTML 元信息文件

我创建的网页的元信息文件(web-metadata.yaml)是一个简单的 YAML 文件,其中包含 <head> 标签中的作者、标题、和版权等信息,以及 HTML 文件中开头和结尾的内容。

我建议(至少)包括 web-metadata.yaml 文件中的以下字段:

---

title: <a href="/grasp-principles/toc/">GRASP principles for the Object-oriented mind</a>

author: Kiko Fernandez-Reyes

rights: 2017 Kiko Fernandez-Reyes, CC-BY-NC-SA 4.0 International

header-includes:

- |

```{=html}

<link href="https://fonts.googleapis.com/css?family=Inconsolata" rel="stylesheet">

<link href="https://fonts.googleapis.com/css?family=Gentium+Basic|Inconsolata" rel="stylesheet">

```

include-before:

- |

```{=html}

<p>If you like this book, please consider

spreading the word or

<a href="https://www.buymeacoffee.com/programming">

buying me a coffee

</a>

</p>

```

include-after:

- |

```{=html}

<div class="footnotes">

<hr>

<div class="container">

<nav class="pagination" role="pagination">

<ul>

<p>

<span class="page-number">Designed with</span> ?? <span class="page-number"> from Uppsala, Sweden</span>

</p>

<p>

<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="Creative Commons License" style="border-width:0" src="http://www.hmttv.cn/uploadfile/2024/1009/20241009093826494.png" /></a>

</p>

</ul>

</nav>

</div>

</div>

```

---

下面几个变量需要注意一下:

  • header-includes 变量包含将要嵌入 <head> 标签的 HTML 文本。
  • 调用变量后的下一行必须是 - |。再往下一行必须以与 | 对齐的三个反引号开始,否则 Pandoc 将无法识别。{=html} 告诉 Pandoc 其中的内容是原始文本,不应该作为 Markdown 处理。(为此,需要检查 Pandoc 中的 raw_attribute 扩展是否已启用。要进行此检查,键入 pandoc --list-extensions | grep raw 并确保返回的列表包含名为 + raw_html 的项目,加号表示已启用。)
  • 变量 include-before 在网页开头添加一些 HTML 文本,此处我请求读者帮忙宣传我的书或给我打赏。
  • include-after 变量在网页末尾添加原始 HTML 文本,同时显示我的图书许可证。

这些只是其中一部分可用的变量,查看 HTML 中的模板变量(我的文章 Pandoc简介 中介绍了如何查看 LaTeX 的模版变量,查看 HTML 模版变量的过程是相同的)对其余变量进行了解。

将网页分成多章

网页可以作为一个整体生成,这会产生一个包含所有内容的长页面;也可以分成多章,我认为这样会更容易阅读。我将解释如何将网页划分为多章,以便读者不会被长网页吓到。

为了使网页易于在 GitHub Pages 上部署,需要创建一个名为 docs 的根文件夹(这是 GitHub Pages 默认用于渲染网页的根文件夹)。然后我们需要为 docs 下的每一章创建文件夹,将 HTML 内容放在各自的文件夹中,将文件内容放在名为 index.html 的文件中。

例如,about.md 文件将转换成名为 index.html 的文件,该文件位于名为 about(about/index.html)的文件夹中。这样,当用户键入 http://<your-website.com>/about/ 时,文件夹中的 index.html 文件将显示在其浏览器中。

下面的 Makefile 将执行上述所有操作:

# Your book files

DEPENDENCIES=toc preface about

# Placement of your HTML files

DOCS=docs

all: web

web: setup $(DEPENDENCIES)

@cp $(DOCS)/toc/index.html $(DOCS)

# Creation and copy of stylesheet and images into

# the assets folder. This is important to deploy the

# website to Github Pages.

setup:

@mkdir -p $(DOCS)

@cp -r assets $(DOCS)

# Creation of folder and index.html file on a

# per-chapter basis

$(DEPENDENCIES):

@mkdir -p $(DOCS)/$@

@pandoc -s --toc web-metadata.yaml parts/$@.md \

-c /assets/pandoc.css -o $(DOCS)/$@/index.html

clean:

@rm -rf $(DOCS)

.PHONY: all clean web setup

选项 - c /assets/pandoc.css 声明要使用的 CSS 样式表,它将从 /assets/pandoc.cs 中获取。也就是说,在 <head> 标签内,Pandoc 会添加这样一行:

<link rel="stylesheet" href="/assets/pandoc.css">

使用下面的命令生成网页:

make

根文件夹现在应该包含如下所示的文件结构:

.---parts

| |--- toc.md

| |--- preface.md

| |--- about.md

|

|---docs

|--- assets/

|--- index.html

|--- toc

| |--- index.html

|

|--- preface

| |--- index.html

|

|--- about

|--- index.html

部署网页

通过以下步骤将网页部署到 GitHub 上:

  1. 创建一个新的 GitHub 仓库
  2. 将内容推送到新创建的仓库
  3. 找到仓库设置中的 GitHub Pages 部分,选择 Source 选项让 GitHub 使用主分支的内容

你可以在 GitHub Pages 的网站上获得更多详细信息。

我的书的网页 便是通过上述过程生成的,可以在网页上查看结果。

生成电子书

创建 ePub 格式的元信息文件

ePub 格式的元信息文件 epub-meta.yaml 和 HTML 元信息文件是类似的。主要区别在于 ePub 提供了其他模板变量,例如 publisher 和 cover-image 。ePub 格式图书的样式表可能与网页所用的不同,在这里我使用一个名为 epub.css 的样式表。

---

title: 'GRASP principles for the Object-oriented Mind'

publisher: 'Programming Language Fight Club'

author: Kiko Fernandez-Reyes

rights: 2017 Kiko Fernandez-Reyes, CC-BY-NC-SA 4.0 International

cover-image: assets/cover.png

stylesheet: assets/epub.css

...

将以下内容添加到之前的 Makefile 中:

epub:

@pandoc -s --toc epub-meta.yaml \

$(addprefix parts/, $(DEPENDENCIES:=.md)) -o $(DOCS)/assets/book.epub

用于产生 ePub 格式图书的命令从 HTML 版本获取所有依赖项(每章的名称),向它们添加 Markdown 扩展,并在它们前面加上每一章的文件夹路径,以便让 Pandoc 知道如何进行处理。例如,如果 $(DEPENDENCIES 变量只包含 “前言” 和 “关于本书” 两章,那么 Makefile 将会这样调用:

@pandoc -s --toc epub-meta.yaml \

parts/preface.md parts/about.md -o $(DOCS)/assets/book.epub

Pandoc 将提取这两章的内容,然后进行组合,最后生成 ePub 格式的电子书,并放在 Assets 文件夹中。

这是使用此过程创建 ePub 格式电子书的一个 示例 。

过程总结

从 Markdown 文件创建网页和 ePub 格式电子书的过程并不困难,但有很多细节需要注意。遵循以下大纲可能使你更容易使用 Pandoc。

  • HTML 图书:
  • 使用 Markdown 语法创建每章内容
  • 添加元信息
  • 创建一个 Makefile 将各个部分组合在一起
  • 设置 GitHub Pages
  • 部署
  • ePub 电子书:
  • 使用之前创建的每一章内容
  • 添加新的元信息文件
  • 创建一个 Makefile 以将各个部分组合在一起
  • 设置 GitHub Pages
  • 部署

via: https://opensource.com/article/18/10/book-to-website-epub-using-pandoc

作者: Kiko Fernandez-Reyes 选题: lujun9972 译者: jlztan 校对: wxy

本文由 LCTT 原创编译, Linux中国 荣誉推出

点击“了解更多”可访问文内链接

作者:xybaby 
来源:https://www.cnblogs.com/xybaby/p/11335829.html

出整洁的代码,是每个程序员的追求。

《clean code》指出,要想写出好的代码,首先得知道什么是肮脏代码、什么是整洁代码;然后通过大量的刻意练习,才能真正写出整洁的代码。

WTF/min是衡量代码质量的唯一标准,Uncle Bob在书中称糟糕的代码为沼泽(wading),这只突出了我们是糟糕代码的受害者。

国内有一个更适合的词汇:屎山,虽然不是很文雅但是更加客观,程序员既是受害者也是加害者。

对于什么是整洁的代码,书中给出了大师们的总结:

  • Bjarne Stroustrup:优雅且高效;直截了当;减少依赖;只做好一件事
  • Grady booch:简单直接
  • Dave thomas:可读,可维护,单元测试
  • Ron Jeffries:不要重复、单一职责,表达力(Expressiveness)

其中,我最喜欢的是表达力(Expressiveness)这个描述,这个词似乎道出了好代码的真谛:用简单直接的方式描绘出代码的功能,不多也不少。

本文记录阅读《clean code》之后个人“深有同感”或者“醍醐灌顶”的一些观点。

命名的艺术

坦白的说,命名是一件困难的事情,要想出一个恰到好处的命名需要一番功夫,尤其我们的母语还不是编程语言所通用的英语。

不过这一切都是值得了,好的命名让你的代码更直观,更有表达力。

好的命名应该有下面的特征:

名副其实

好的变量名告诉你:是什么东西,为什么存在,该怎么使用

如果需要通过注释来解释变量,那么就先得不那么名副其实了。

下面是书中的一个示例代码,展示了命名对代码质量的提升

避免误导

  • 不要挂羊头卖狗肉
  • 不要覆盖惯用缩略语

这里不得不吐槽前两天才看到的一份代码,居然使用了 l 作为变量名;而且,user居然是一个list(单复数都没学好!!)

有意义的区分

代码是写给机器执行,也是给人阅读的,所以概念一定要有区分度。

# bad
def copy(a_list, b_list):
 pass
 
# good
def copy(source, destination):
 pass

使用读的出来的单词

如果名称读不出来,那么讨论的时候就会像个傻鸟

使用方便搜索的命名

名字长短应与其作用域大小相对应

避免思维映射

比如在代码中写一个temp,那么读者就得每次看到这个单词的时候翻译成其真正的意义

注释

有表达力的代码是无需注释的。

The proper use of comments is to compensate for our failure to express ourself in code.

注释的适当作用在于弥补我们用代码表达意图时遇到的失败,这听起来让人沮丧,但事实确实如此。

The truth is in the code, 注释只是二手信息,二者的不同步或者不等价是注释的最大问题。

书中给出了一个非常形象的例子来展示:用代码来阐述,而非注释

bad
// check to see if the employee is eligible for full benefit
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))
good
if (employee.isEligibleForFullBenefits())

因此,当想要添加注释的时候,可以想想是否可以通过修改命名,或者修改函数(代码)的抽象层级来展示代码的意图。

当然,也不能因噎废食,书中指出了以下一些情况属于好的注释

  1. 法务信息
  2. 对意图的注释,为什么要这么做
  3. 警示
  4. TODO注释
  5. 放大看似不合理之物的重要性

其中个人最赞同的是第2点和第5点,做什么很容易通过命名表达,但为什么要这么做则并不直观,特别涉及到专业知识、算法的时候。

另外,有些第一感觉“不那么优雅”的代码,也许有其特殊愿意,那么这样的代码就应该加上注释,说明为什么要这样,比如为了提升关键路径的性能,可能会牺牲部分代码的可读性。

最坏的注释就是过时或者错误的注释,这对于代码的维护者(也许就是几个月后的自己)是巨大的伤害,可惜除了code review,并没有简单易行的方法来保证代码与注释的同步。

函数

函数的单一职责

一个函数应该只做一件事,这件事应该能通过函数名就能清晰的展示。判断方法很简单:看看函数是否还能再拆出一个函数。

函数要么做什么 do_sth, 要么查询什么 query_sth。最恶心的就是函数名表示只会 query_sth, 但事实上却会 do_sth, 这使得函数产生了副作用。比如书中的例子

函数的抽象层级

每个函数一个抽象层次,函数中的语句都要在同一个抽象层级,不同的抽象层级不能放在一起。比如我们想把大象放进冰箱,应该是这个样子的:

def pushElephantIntoRefrige():
 openRefrige()
 pushElephant()
 closeRefrige()

函数里面的三句代码在同一个层级(高度)描述了要完成把大象放进冰箱这件事顺序相关的三个步骤。

显然,pushElephant 这个步骤又可能包含很多子步骤,但是在pushElephantIntoRefrige 这个层级,是无需知道太多细节的。

当我们想通过阅读代码的方式来了解一个新的项目时,一般都是采取广度优先的策略,自上而下的阅读代码,先了解整体结构,然后再深入感兴趣的细节。

如果没有对实现细节进行良好的抽象(并凝练出一个名副其实的函数),那么阅读者就容易迷失在细节的汪洋里。

某种程度看来,这个跟金字塔原理也很像



每一个层级都是为了论证其上一层级的观点,同时也需要下一层级的支持;同一层级之间的多个论点又需要以某种逻辑关系排序。

pushElephantIntoRefrige 就是中心论点,需要多个子步骤的支持,同时这些子步骤之间也有逻辑先后顺序。

函数参数

函数的参数越多,组合出的输入情况就愈多,需要的测试用例也就越多,也就越容易出问题。

输出参数相比返回值难以理解,这点深有同感,输出参数实在是很不直观。

从函数调用者的角度,一眼就能看出返回值,而很难识别输出参数。输出参数通常逼迫调用者去检查函数签名,这个实在不友好。

向函数传入Boolean(书中称之为 Flag Argument)通常不是好主意。

尤其是传入True or False后的行为并不是一件事情的两面,而是两件不同的事情时。这很明显违背了函数的单一职责约束,解决办法很简单,那就是用两个函数。

Dont repear yourself

在函数这个层级,是最容易、最直观实现复用的,很多IDE也难帮助我们讲一段代码重构出一个函数。

不过在实践中,也会出现这样一种情况:一段代码在多个方法中都有使用,但是又不完全一样,如果抽象成一个通用函数,那么就需要加参数、加if else区别。这样就有点尴尬,貌似可以重构,但又不是很完美。

造成上述问题的某种情况是因为,这段代码也违背了单一职责原则,做了不只一件事情,这才导致不好复用,解决办法是进行方法的细分,才能更好复用。也可以考虑template method来处理差异的部分。

测试

非常惭愧的是,在我经历的项目中,测试(尤其是单元测试)一直都没有得到足够的重视,也没有试行过 TDD。正因为缺失,才更感良好测试的珍贵。

我们常说,好的代码需要有可读性、可维护性、可扩展性,好的代码、架构需要不停的重构、迭代,但自动化测试是保证这一切的基础,没有高覆盖率的、自动化的单元测试、回归测试,谁都不敢去修改代码,只能任其腐烂。

即使针对核心模块写了单元测试,一般也很随意,认为这只是测试代码,配不上生产代码的地位,以为只要能跑通就行了。

这就导致测试代码的可读性、可维护性非常差,然后导致测试代码很难跟随生产代码一起更新、演化,最后导致测试代码失效。

所以说,脏测试 - 等同于 - 没测试。

因此,测试代码的三要素:可读性,可读性,可读性。

对于测试的原则、准则如下:

  • You are not allowed to write any production code unless it is to make a failing unit test pass. 没有测试之前不要写任何功能代码
  • You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures. 只编写恰好能够体现一个失败情况的测试代码
  • You are not allowed to write any more production code than is sufficient to pass the one failing unit test. 只编写恰好能通过测试的功能代码

测试的FIRST 准则:

  1. 快速(Fast)测试应该够快,尽量自动化。
  2. 独立(Independent) 测试应该应该独立。不要相互依赖
  3. 可重复(Repeatable) 测试应该在任何环境上都能重复通过。
  4. 自我验证(Self-Validating) 测试应该有bool输出。不要通过查看日志这种低效率方式来判断测试是否通过
  5. 及时(Timely) 测试应该及时编写,在其对应的生产代码之前编写