Single-spa与QianKun解读

概要

1. Single-spa
2. Qiankun

Single-spa

single-spa的理想化实现是使用es6 modules,以js entry的方式接入,每一个微应用都是基座app importmap的一个键值

接入Single-spa时需要先对microapp改造:

  1. 微应用路由改造(大多数情况下依赖于路由来激活微应用时)来避免各个应用间的冲突,比如添加一个特定的前缀
  2. 提供给single-spa使用的钩子,改造微应用入口,挂载点变更和生命周期函数导出
  3. 打包工具配置更改 – 要打包成一个js文件(当然也可以使用webpack的manifest,但是根本问题是无法进行分包加载等优化)

js entry的方式由于没有上下文路由/一次全量加载所有js,没法实现按需加载,首屏渲染优化,CSS独立打包,更新版本还要考虑缓存策略的问题

Single-spa 调用流程

registerApps => 处理/验证入参 => 格式化入参生成registerations => reroute() => load()

single-spa.start() => reroute() => load() if not loaded => 调用bootsraped钩子 => 变为bootstraped

路由变更 => reroute() => 判断是否active => active则mount(即启动微应用,比如new vue(),ReactDOM.render())

若inactive => unmount (销毁微应用实例)

single-spa核心方法,区分出各个状态的micro app,返回后处理每种状态,并对应状态需要调用的钩子(各微应用传入),实现状态变更

export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      case NOT_LOADED:
        ...

将一个微应用传入的mount钩子全部转化为promises

export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;
        ....

activeWhen传入方法时,可以实现懒加载,满足条件后才load微应用

Single-spa五个缺少的功能
  1. 需要改造微应用,上文提到的微应用改造
  2. 样式隔离,多个微应用同时挂载,样式冲突
  3. JS 隔离,多个微应用同时挂载,window冲突
  4. 资源预加载,微应用只在符合条件时开始加载
  5. 无法实现应用间的通信

Single-spa缺少这些功能也情有可原,它只专注微应用的宏观管理,不包含某些具体的功能需求

总结来说single-spa – 只做了 管理(初始化、挂载、卸载)微应用,并调用对应方法,粗暴一点讲,single-spa只是微应用的状态管理机

Qiankun

qiankun 基于Single spa封装,see more on https://github.com/umijs/qiankun

qiankun依赖import html entry包 以html entry的方式接入

基于qiankun,只需要提供微应用 hotst的url就可以接入,简单到类似于iframe

注册微应用方法,registerApplication的入参(包含钩子,配置,微应用meta)进入single-spa中被使用/调用

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
    ...
    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
    ...
}
  1. html entry 保留微应用内部优化,减少对微应用入侵

qiankun提供了封装的load方法(最终给single-spa使用) – 通过html entry解析微应用的template, execScripts, assetPublicPath, getExternalScripts资源并加载

execScripts 即微应用的业务代码,包含export出来的三个钩子,qiankun拿到后传入single-spa

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {

    ...

  // get the entry html content and script executor
  const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
  // trigger external scripts loading to make sure all assets are ready before execScripts calling
  await getExternalScripts();

    ...
  1. shadow root / scroped CSS 两种方式实现内置的样式隔离

创建微应用的根节点为shadow root,给元素加attribute实现scopedCSS

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }

  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }

    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }

  return appElement;
}

  1. built-in sandbox window.Proxy 代理window实现微应用运行时沙箱

基于 Proxy 实现的沙箱,实现window的property get/set,全局对象比如document/eval等

export default class ProxySandbox implements SandBox {
...
  constructor(name: string, globalContext = window) {
...
    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
...

if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable, set } = descriptor!;
            // only writable property can be overwritten
            // here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
            // we force to set value by data descriptor
            if (writable || set) {
              Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
            }
          } else {
            target[p] = value;
          }
...
},

      get: (target: FakeWindow, p: PropertyKey): any => {
...
        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        if (p === 'document') {
          return this.document;
        }

        if (p === 'eval') {
          return eval;
        }
...
}

  1. built-in prefetch strategy 内置预加载逻辑,传入配置对象即可使用

根据传入的prefetch configure判断需要prefetch的时间点,监听事件,符合要求调用prefetch()
即importEntry – html entry

function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }

  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}
  1. build-in GloabState 内置全局状态,实现微应用间通信

内部实现状态机,将状态更新,订阅更新等方法通过mount钩子传给微应用

 const parcelConfig: ParcelConfigObject = {
      name: appInstanceId,
      bootstrap,
      mount: [
...
        async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
...
      ],

...
}

Develop with Docker

概要

1. Why Develop with Docker
2. Best Practice
3. Example

为什么Develop with Docker

从Docker的特性中获益
快速搭建开发环境

一般的开发过程中,我们往往需要一个SDK来build打包代码,需要一个runtime来运行build result,开发不同的工程,还可能需要安装不同版本的SDK/Runtime。

例如开发前端工程时,可能会有需要同时运行两个依赖不同node版本的工程。
或者开发前端时需要一个本地运行的后端且不想要安装整套开发的工具,快速地在本地部署一个其他工程的依赖

把复杂应用的打包,运行的依赖、步骤都保存在DOCKERFILE中,尽可能地让代码在每个开发环境中的行为都保持一致

开发体验
  1. 通过docker的layer缓存,加速开发时应用的启动时间
  2. 可以实现几乎和本地运行无差别的开发体验,hot reload, debug
  3. 开发一些需要启动多个app的工程时,使用docker compose实现一行命令启动,预配置

Best Practice

1. 选用合适的base image

使用更小更专用的的base image来build,比如镜像只是需要build工程,而不需要运行,那么我们只需要引入SDK

当多个镜像有许多一样的layer时,构建一个内部公用的base镜像,相同的layer加载一次之后就会被缓存在内存中,那么build多个不同工程时就不会去重新拉取镜像,从而加快启动速度。

2. 使用Multi-Stage

核心是在进入下一个layer的时候清除不需要的组件,理想情况下每个layer只引入上一个layer的结果和当前依赖的组件。
https://docs.docker.com/develop/develop-images/multistage-build/

多阶段构建(multi-stage builds),将构建过程分为多个阶段,每个阶段都可以指定一个基础镜像,这样在一个Dockerfile就能将多个镜像的特性同时用到,例如:先用SDK镜像构建.NET Core工程,再把构建结果和Runtime 合成,最后得到一个可以直接运行.NET Core工程镜像

(17.05前的版本可以维护多个DockerFile来实现)

FROM microsoft/dotnet:2.1.300-sdk AS builder
WORKDIR /App
COPY NuGet.config ./
COPY App.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish --configuration Release --output releaseOutput --no-restore

#build runtime image
FROM microsoft/dotnet:2.1.0-aspnetcore-runtime as runtime
WORKDIR /App
COPY --from=builder /App/releaseOutput ./
ENTRYPOINT ["dotnet", "App.dll"]

使用COPY指令引入builder阶段的文件

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

使用–target参数来让Docker停在某一Stage,让一份DOCKERFILE能够生成不同的镜像(比如生成debug build/release build),共用其他初始stage

docker build --target builder -t Vic/App:latest .
3. 合并多个RUN来减少Layer
RUN apt-get -y update
RUN apt-get install -y python

RUN apt-get -y update && apt-get install -y python

当某个layer被修改,docker会将所有后续的layer重新构建,而跳过之前的layer(已被缓存的)
但当多一个命令的其中运行失败时,docker就会重新执行整个run,所以需要合理设计,尽可能的将更通用的,修改可能性更小的,更长的命令放在头部
使我们修改靠后命令时可以更快build

更少的layer数量可以缩短build时间,减少镜像体积

Example

实现开发中的hot relad
  1. 首先需要给package.json加入一个新的scipts
"start:docker": "ng serve  --host 0.0.0.0 --disableHostCheck --port 9001 --poll",

由于angular dev server以容器的方式运行,我们(宿主机访问)需要通过docker compose network中转,再将请求发至angular dev server,对于angular dev server来说不是通过localhost访问的。

故需要加入参数--host 0.0.0.0 + --disableHostCheck,开放任意ip访问 + 关闭用于 DNS 重绑定的 HTTP 请求的 HOST 检查。

(DevServer 默认只接受来自本地的请求,关闭后可以接受来自任何 HOST 的请求)

由于我们使用类似于共享路径的方式(通过docker volumes挂载本地的代码目录),在容器中运行的dev server无法检测到文件变动的事件,故需要加入--poll 参数来让服务端主动轮询文件的变动信息,来实现hot reload,也就是说无法实现recompile and reload on save了。但实际即使设置较短的轮询时间,也不会对dev server的性能产生非常大的影响,所以感官上差别不大。

  1. 创建一个angular dev server专用的dockerfile
FROM node:lts AS build-angular-development
WORKDIR /app

EXPOSE 9001 49153

COPY Switches.AngularUI/package*.json ./

RUN npm install

CMD ["npm", "run", "start:docker"]

这里注意需要额外暴露49153,是 Webpack Hot Reload Module 的默认端口
当然也可以通过--live-reload-port手动设置一个新端口

这里我们不使用copy指向把代码引入到镜像中,而通过下面docker compose挂载volume来让容器获取到代码

  1. 创建docker-compose.development.yml
version: "3"

services:
  switches-angularUI:
    container_name: Angular-Dev
    image: "ymhvic/switches-development-angular"
    restart: always
    networks:
      switches_network:
        ipv4_address: 172.16.1.0
    build:
      context: .
      dockerfile: Dockerfile.Development.Angular
    ports:
      - "9001:9001"
      - "49153:49153"
    volumes:
       - ./Switches.AngularUI:/app
  switches-api:
    container_name: APIServer-Dev
    image: "ymhvic/switches-development-api"
    depends_on:
      - redis
    restart: always
    networks:
      switches_network:
        ipv4_address: 172.16.1.1
    build:
      context: .
      dockerfile: Dockerfile.Development.API
    ports:
      - "9080:80"
      - "9443:443"
    volumes:
       - ./Switches:/app/Switches
       - ./Switches.Data:/app/Switches.Data
  redis:
    container_name: redis-Dev
    image: "redis:latest"
    restart: always
    networks:
      switches_network:
        ipv4_address: 172.16.1.2
    ports:
      - "6379:6379"

networks:
  switches_network:
    ipam:
      driver: default
      config:
        - subnet: 172.16.0.0/16



- ./Switches.AngularUI:/app挂载宿主机的Switches.AngularUI目录挂载到容器的app目录下

容器间通信

在docker-compose中加配置network,每个加入同个network的容器都可以通过container_name来实现访问
比如

container_name:APIServer
ports:
-"9080:80"

那么可以其他容器可以通过http://APIServer:80 访问到

NetCore 也是同理,通过Volumn将开发目录挂载到容器中,使用dotnet watch实现hot reload

Debugging Dotnet Watch processes inside of a Docker container from csharp

在dockerfile中设置环境变量DOTNET_USE_POLLING_FILE_WATCHER

ENV DOTNET_USE_POLLING_FILE_WATCHER 1

来让dotnet使用主动的文件变动轮询

If set to “1” or “true”, dotnet watch uses a polling file watcher instead of CoreFx’s FileSystemWatcher. Used when watching files on network shares or Docker mounted volumes.

CMDs

docker run -it –rm -v $(pwd):/app -p 5001:5001 docker.io/ymhvic/switchesapihotreload
docker build . -t ymhvic/switchesapihotreload –file Dockerfile.Development.API.HotReload

MediatR 解读与实战

MediatR 解读与实战

概要

1. 中介者模式
2. Clean Architecture
3. MediatR

中介者模式

什么是中介者模式

中介者模式即对象通过中介者交互而非直接进行。

中介者模式让原本类之间的依赖转移到为了中介者与类间,由此降低程序的耦合度,并简化类之间的通信。

中介者类似消息的处理中心,对象向中介者发送请求,中介者分发请求至对应目标。获得目标响应后返回至发起者。

为什么要使用中介者模式
  1. 程序随功能增多产生复杂的依赖关系,某一方法可能分别依赖不同层级的对象

  2. 类间的通信、调用流程由于多对多的关系变得繁琐

  3. 利用中介者的特性获得更加合理的代码结构

Clean Architecture

什么是Clean Architecture
  1. 区别于传统架构自下而上的垂直结构,Clean Architecture是洋葱型的结构,只存在一个核心(Domain)层,依赖关系单向流动,但存在一对多的引用

  2. 区别于传统架构核心(Domain)层依赖于应用层、设施层,Clean Architecture反转了依赖关系,数据层、设施层依赖于核心(Domain)层

一般通过向应用层添加抽象(例如接口或抽象基类)来实现。然后在应用层实现这些方法。

比如 Repository 类的实现。首先将 IRepository 接口添加到应用层。然后通过使用某一数据库(MSSQL,MYSQL)创建一个 Repository 类来在 Persistence(在应用层中,或者应用层的依赖) 中实现这个接口。而在 核心(Domain)层 中,刚才编写的逻辑仅使用 IRepository 接口,因此 核心(Domain)层 将保持独立于数据访问。

优势:

  1. 独立于框架/数据库- 核心(Domain)层 不依赖于外部框架,例如 Entity Framework
  2. 测试简单- 核心(Domain)层 中的逻辑可以独立于任何外部依赖(例如 UI、数据库、服务器)进行测试。没有外部依赖,测试编写更简单。
  3. 独立于 UI -Web UI 替换为console UI,或 Angular 替换为 Vue。逻辑包含在 核心(Domain)层 中,因此更改 UI 不会影响逻辑。
  4. 独立于任何机构- 核心(Domain)层 对外界一无所知

Clean-Architecture

更快更方便地实现Clean Architecture中实现presentation/WebAPI层与Application层的分离
  1. 将业务逻辑分离在presentation/WebAPI之外,实现thin controller(如果controller中包含业务逻辑代码,没有独立于UI,假如以后要切换到console app等,就难以重用,controller中的逻辑一般也只能由请求调用)
  2. 实现CQRS + 中介者模式,更好得复用业务逻辑相关的代码

MediatR

什么是MediatR

中介者模式实现,一种进程内消息传递框架,代码层面的消息中心,提供了中介者对象

WebApp中应用MediatR

在Presentation/Controller中调用MediatR发送/发布消息,MediatR的命令创建在Application中, 消息处理方法中调用Application

由此,Application基本等同于所有MediatR消息和消费者的实现。

直接调用与通过MediatR调用比较
功能 通过MediaR调用
调用方法的前后进行额外处理 通过IPipelineBehavior实现类似中间件的功能,通过IRequestPreProcess,IRequestpostProcess来实现为某一方法调用前后附上额外运行的代码
调用方法触发运行一个或多个代码段 通过INotification实现消息的单播/多播
捕捉异常 通过IRequestExceptionHandler,IRequestExceptionAction处理执行方法中产生的异常,运行额外代码

直接调用方法,常常会因为需要上面的一些事件驱动的功能,多次写重复代码,破坏方法的原子性

把MediatR inject到presentation和把普通service inject到presentation有什么区别?

thin-controller
service inject到controller如下图
service inject到controller

使用MediatR后,MediatR作为抽象的一层将依赖进一步根据具体逻辑分散
MediatR inject到controller

把MediatR inject到presentation/WebAPI中,借助MediatR的功能,可以更好的实践下面两点
1. 少写重复代码,尽量复用公共代码
2. 保持代码简洁,逻辑按业务/层次分散,比如WebAPI/presentation中尽可能只包含请求处理验证逻辑,显示逻辑,而appplication包含业务逻辑

MediatR.ServiceFactory

定义了一个名为MediatR.ServiceFactory 的委托,然后给这个委托加上两个扩展方法,扩展方法传入Type,返回一个Type对应的实例。
用来实例化所有的handlers, pipeline behaviors, and pre/post-processors
在注册服务时将ServiceFactory赋值为每个容器框架各自的实例化服务方法

“` C#
namespace MediatR
{
/// <summary>
/// Factory method used to resolve all services. For multiple instances, it will resolve against <see cref="IEnumerable{T}" />
/// </summary>
/// <param name="serviceType">Type of service to resolve</param>
/// <returns>An instance of type <paramref name="serviceType" /></returns>
public delegate object ServiceFactory(Type serviceType);

<pre><code>public static class ServiceFactoryExtensions
{
public static T GetInstance<T>(this ServiceFactory factory)
=> (T) factory(typeof(T));

public static IEnumerable<T> GetInstances<T>(this ServiceFactory factory)
=> (IEnumerable<T>) factory(typeof(IEnumerable<T>));
}
</code></pre>

}

<pre><code class="">在ASP .NET CORE DI 注册服务时
“` C#
//MediatR.Extensions.Microsoft.DependencyInjection/Registration/ServiceRegistrar.cs#L216
public static void AddRequiredServices(IServiceCollection services, MediatRServiceConfiguration serviceConfiguration)
{
// Use TryAdd, so any existing ServiceFactory/IMediator registration doesn’t get overriden
services.TryAddTransient<ServiceFactory>(p => p.GetService); //给ServiceFactory委托赋值
services.TryAdd(new ServiceDescriptor(typeof(IMediator), serviceConfiguration.MediatorImplementationType, serviceConfiguration.Lifetime));
services.TryAdd(new ServiceDescriptor(typeof(ISender), sp => sp.GetService<IMediator>(), serviceConfiguration.Lifetime));
services.TryAdd(new ServiceDescriptor(typeof(IPublisher), sp => sp.GetService<IMediator>(), serviceConfiguration.Lifetime));

// Use TryAddTransientExact (see below), we dó want to register our Pre/Post processor behavior, even if (a more concrete)
// registration for IPipelineBehavior<,> already exists. But only once.
services.TryAddTransientExact(typeof(IPipelineBehavior<,>), typeof(RequestPreProcessorBehavior<,>));
services.TryAddTransientExact(typeof(IPipelineBehavior<,>), typeof(RequestPostProcessorBehavior<,>));
services.TryAddTransientExact(typeof(IPipelineBehavior<,>), typeof(RequestExceptionActionProcessorBehavior<,>));
services.TryAddTransientExact(typeof(IPipelineBehavior<,>), typeof(RequestExceptionProcessorBehavior<,>));
}
//netstandard.library\2.0.3\build\netstandard2.0\ref\netstandard.dll
namespace System
{
//
// Summary:
// Defines a mechanism for retrieving a service object; that is, an object that
// provides custom support to other objects.
public interface IServiceProvider
{
//
// Summary:
// Gets the service object of the specified type.
//
// Parameters:
// serviceType:
// An object that specifies the type of service object to get.
//
// Returns:
// A service object of type serviceType. -or- null if there is no service object
// of type serviceType.
object GetService(Type serviceType);
}
}

发送消息方法

“` C#
var handler = (RequestHandlerWrapper)_requestHandlers.GetOrAdd(requestType,
t => Activator.CreateInstance(typeof(RequestHandlerWrapperImpl<,>).MakeGenericType(requestType, typeof(TResponse))));

1. 维护一个线程安全的字典静态变量缓存,减少每次通过反射实例化消费者的性能消耗
2. 先使用`Activator.CreateInstance()`创建一个实例`RequestHandlerWrapperImpl<,>`实例调用`MakeGenericType()`, 这样就可以安全转化为`RequestHandlerWrapper<TResponse>`

RequestHandlerWrapperImpl类中 handle方法实现
1. 通过容器框架的解析方法,获得对应的消费者实例
2. 通过容器框架的解析方法,获得所有已注册的消息中间件IPipelineBehavior(服务注册时压栈进入,取出reverse()后得到注册时顺序)
3. 使用Linq Aggregate逐一唤起消费者

``` C#
public override Task<TResponse> Handle(IRequest<TResponse> request, CancellationToken cancellationToken,
    ServiceFactory serviceFactory)
{
    Task<TResponse> Handler() => GetHandler<IRequestHandler<TRequest, TResponse>>(serviceFactory).Handle((TRequest) request, cancellationToken);

    return serviceFactory
        .GetInstances<IPipelineBehavior<TRequest, TResponse>>()
        .Reverse()
        .Aggregate((RequestHandlerDelegate<TResponse>) Handler, (next, pipeline) => () => pipeline.Handle((TRequest)request, cancellationToken, next))();
}
Notification消息的发布策略

通过重写MediatR.PublishCore方法来实现自定义的发布策略
默认发布策略是遍历所有消息的handlers, 逐一执行

Benifit from MediatR
  1. 通过MediatR + CQRS的特性,让每个逻辑方法(消息)更加原子,纯净,定义明确
  2. 分离presentation/WebAPI与业务逻辑。controller作用变成了“接收请求并立即分派给 MediatR”。
  3. 强大的事件驱动功能,增加高级别(复杂)方法的抽象能力 保持代码整洁,依赖清晰
  4. MediatR 更加简单地进行mock单元测试
  5. 借助MediatR.IPipelineBehavior实现消息中间件的功能

References

https://github.com/jbogard/MediatR
https://github.com/jbogard/MediatR.Extensions.Microsoft.DependencyInjection
https://github.com/jasontaylordev/CleanArchitecture

EF Core + Code First

概要

1. Entity Framework Core
2. Code First

Entity Framework Core

Entity Framework Core是什么

Entity Framework (EF) Core 是轻量化、可扩展、开源和跨平台版的常用 Entity Framework 数据访问技术。

EF Core与EF的关系可以简单理解为.Net与.Net Core。

但EF Core将成为EF的继承者,并持续更新EF6所缺少的功能。

see Compare EF Core & EF6

Code First

Code First是什么

即在修改数据模型时,首先修改代码,然后使用工具更新数据库。

相对的,Database/Model First先修改数据库结构,然后使用工具生成代码。两种方式没有实际差别。

为什么选择Code First

相较于Database/Model First牵扯到数据库的操作更新等,Code First让开发者更加专注在代码层面,开发者不(更少)care数据库的结构。

EF Core + Code First

在需要对数据的结构/模型进行修改时,

首先对代码中的Entity进行修改,增加/减少字段,增加外键链接等,
完成后使用指令,自动生成更新数据的命令。

add-migration

更新后也可以按具体需求修改。

然后使用

update-database

即对数据库的结构进行更新。

开启自动更新migration的功能,让工程连接的数据库能够随着代码中模型(版本)的更新而一起更新。避免产生代码模型与数据库模型不一致的情况。

Clean Architecture 解读

概要

1. Clean Architecture
2. 项目实战

Clean Architecture

架构是什么

这里引用Uncle Bob的定义:

软件架构是指,设计软件的人为软件赋予的形状,这个形状是指系统如何被划分为组件(Components),各个组件如何排列(Arrangement),组件之间如何沟通(Communication)。

个人认为,一个工程由多个项目组成,项目中之间存在依赖关系。软件的架构即工程中所有关系的集合。

Clean Architecture是什么

一种架构思想:

1.区别于传统架构自下而上的垂直结构,Clean Architecture是洋葱型的结构,只存在一个核心层,依赖关系单向流动。

2.区别于传统架构核心层依赖于数据层、设施层,Clean Architecture反转了依赖关系,数据层、设施层依赖于核心层。

所以(包含于引用层的)数据层应优先编写,构建数据获取代码,其次编写核心层业务类型,数据操作逻辑。由此核心层不受数据获取的限制,使数据库来源类型等维护成本更小。

see the-main-principles-of-clean-architecture

中介者模式实现与MediatR应用

概要

1. Console App Demo
2. Web API + MediatR Demo

Console App Demo

简单的Console App Demo,包括使用自建的中介者对象实现以及MediatR实现

Web API + MediatR Demo

在WebAPI中使用MediatR,最大的改变是将原本注入到controller的依赖分散到了MediatR消息的处理方法中

传统的依赖注入如下图
传统的依赖注入

使用MediatR后,MediatR作为抽象的一层将依赖进一步根据具体逻辑分散
使用MediatR的依赖注入

而接口则变得非常简洁,即
1.发送MediatR消息
2.获得结果
3.返回结果

在MediatR消息的处理方法中,当然也可以通过注入MediatR来再次发送消息,即嵌套的调用。最大程度得分离逻辑来降低逻辑上的耦合

...
using MediatR;
using Application.Exceptions;
using Application.Item.Models;
using Data.RepositoryInterfaces;
using Domain.BaseInterfaces;
using Domain.Entities;

namespace WebApp.Controllers
{

    [ApiController]
    public class ItemController : ControllerBase
    {

        private readonly IMediator _mediator;

        public ItemController(IMediator mediator)
        {
            _mediator = mediator;
        }

        [HttpGet]
        [Route("")]
        public async Task<ActionResult> Get(int itemId)
        {
            var result = await _mediator.Send(new GetItem(id, userid));
            if (result == null) return NotFound();
            return Ok(result);
        }
    }
}

namespace Application.Item.Queries
{
    public class GetItem : IRequest<ItemViewModel>
    {
        public GetItem(int itemId, string userId)
        {
            UserId = userId;
            ItemId = itemId;
        }

        public string UserId { get; }
        public int ItemId { get; }
    }

    public class GetItemHandler : IRequestHandler<GetItem, ItemViewModel>
    {
        private readonly IMediator _mediator;
        private readonly IItemRepository _itemRepository;

        public GetItemHandler(
            IMediator mediator,
            IItemRepository itemRepository
        )
        {
            _mediator = mediator;
            _itemRepository = itemRepository;
        }

        public async Task<ItemViewModel> Handle(GetItem arg, CancellationToken cancellationToken)
        {
            var item = await _itemRepository.Get()
                .Where(i => i.Id == arg.ItemId)
                .Select(i => new
                {
                    Id = i.Id,
                    Name = i.Name,
                }).SingleOrDefaultAsync();

            if (item == null)
                throw new NotFoundException(arg.ItemId.ToString(), nameof(Domain.Entities.Item));

            var companyUser = await _mediator.Send(new GetCompanyUser(arg.UserId, item.CompanyId));

            bool isCompanyUser = companyUser != null;

            return new ItemViewModel(){
                ItemId = item.Id,
                ItemName = item.Name,
                IsCompanyUser = isCompanyUser
            };
        }
    }

    public class GetItemPreProcessor : IRequestPreProcessor<GetItem>
    {
        private readonly IMediator _mediator;
        public GetItemPreProcessor(
            IMediator mediator)
        {
            _mediator = mediator;
        }

        public Task<ItemViewModel>  Process(GetItem request, CancellationToken cancellationToken)
        {
            return await _mediator.Send(new InsertItemViewRecord(request));
        }
    }
}


namespace Application.Item.Commands
{
    //implement InsertItemViewRecord
}

Demo主要流程如下图

中介者设计模式与MediatR

概要

1. 中介者模式
2. MediatR

中介者模式

什么是中介者模式

中介者模式即对象通过中介者交互而非直接进行。

中介者模式让原本类之间的依赖转移到为了中介者与类间,由此在很大程度上降低程序的耦合度,并大大简化类之间的通信。

中介者类似消息的处理中心,对象向中介者发送请求,中介者分发请求至对应目标。获得目标响应后返回至发起者。

为什么要使用中介者模式

1.程序随功能增多产生复杂的依赖关系,某一方法可能分别依赖不同层级的对象

2.类间的通信、调用流程由于多对多的关系变得繁琐

3.由于类间的相互引用而产生高耦合的代码

中介者模式如何解决问题

以WebApi工程为例,最上层的controller只依赖于中介者对象,中介者对象依赖于业务逻辑。

1.根据中介者的位置进行分割,此时中介者作为controller和业务逻辑之间的分隔起到解耦的作用。上层的controller与下层的业务逻辑始终只与中介者交互,两部分即消息的发送和接收者可以独立的修改,controller的替换或者更新的工作量也就大大减少。

2.在我们对所有的逻辑都尽可能地进行分割的前提下,通过于中介者交互获得某一逻辑片段的响应,组合后完成复杂逻辑。

原本的复杂逻辑可能依赖于多个下层方法,分隔后复杂逻辑只依赖于中介者。

原本依赖的多个下层方法现在分布在各个逻辑片段中,由此,下层方法的改动只对这些逻辑片段产生影响。

3.以复杂逻辑为例,中介消息的接收者只表明调用各个逻辑的顺序以及组合方式。逻辑片段及其依赖的下层方法的修改只要保证自身的返回内容正确,符合请求目的,就不会对其他的所依赖的对象产生影响

中介者模式存在的问题

有的朋友可能说了,我只要写一个巨大的类,把所有的业务逻辑的方法都放进去,让所有的controller都依赖于这个类,那我是不是也实现了中介者模式?

从结构上来说,这个类也是起到了中介者的角色。但是随着业务增加,中介者对象变得臃肿庞大。

其次这个巨大的类相当于对所有的下层方法产生了依赖,每次使用这个类时,都需要将所有的依赖注入,与依赖注入的目的相违背。

而MediatR在一定程度上解决了这个问题。MediatR提供了简洁且功能丰富的中介者对象
并将依赖向外部分散,依赖注入到细致的下层逻辑中

MediatR

什么是MediatR

.NET中的中介者模式实现,一种进程内消息传递框架,提供了中介者对象
MediatR支持以同步或异步的形式进行请求/响应,支持消息的单播/多播

WebApp中应用MediatR

MediatR在WebApi中的位置处于Application与Presentation/Controller之间,请求进入后发送通过MediatR消息,消息处理方法中调用Application

由此,MediatR作为Presentation/Controller和Application的中间连接件,前者只对MediatR产生依赖,而MediatR只对后者产生依赖

把MediatR inject 到 presentation和把service inject到presentation有什么区别和各自的利弊?

首先就是两个依赖关系不一样 理想状态下是presentation只对mediator中介者产生依赖 如果是service直接注入的话 通常情况下不可能只注入一个 presentation就会产生多个引用 增加两者之间的耦合

MediatR把presentation和service分隔开来 这样两个部分的修改变更就更简单

优势是MediatR能分散所有的依赖 降低耦合
弊端是中介者成了程序不能分割的一部分 所以他必须要可靠高效

直接调用与通过MediatR调用比较
功能 通过MediaR调用
调用方法的前后进行额外处理 通过IPipelineBehavior实现类似中间件的功能,通过IRequestPreProcess,IRequestpostProcess来实现为某一方法调用前后附上额外运行的代码
调用方法触发运行一个或多个代码段 通过INotification实现消息的单播/多播
捕捉异常 通过IRequestExceptionHandler,IRequestExceptionAction处理执行方法中产生的异常,运行额外代码
Notification消息的发布策略

通过重写MediatR.PublishCore方法来实现自定义的发布策略
默认发布策略是遍历所有消息的handlers, 逐一执行

umi+cross-env 工程多环境配置

概要

1. umi
2. cross-env 多环境配置

umi

简单介绍

Check umijs here

umi = roadhog + 路由 + HTML 生成 + 完善的插件机制

cross-env多环境配置

为什么需要多环境配置

一般,工程存在三个环境配置即开发环境 dev,测试环境 staging,正式环境 prod,配置中的插件,路由,语言等配置一般情况下可以在多个环境通用。
但是其中的第三方应用密钥,接口地址等都可能因为环境的改变而改变。

当需要发布至其他环境,则需要在打包前对配置进行手动的修改或替换。来让 SDK 确定打包时使用或输出后的配置文件。

cross-env

使用 cross-env,在打包使用的命令中加入环境参数的标识。让 umi 的打包省去修改配置的步骤,实现一键打包。

实操

umi 的配置可以使用.umirc.ts 或 config/config.ts,我们这里使用 config.ts 作为例子。
首先提取公共的项目配置部分保存为 config.ts。

import { IConfig } from 'umi-types'; // ref: https://umijs.org/config/

const baseConfig: IConfig = {
  treeShaking: true,
  routes: [
    {
      path: '/',
      component: '../layouts/index',
      routes: [
        {
          path: '/',
          component: './index',
        }
      ],
    },
  ],
  plugins: [
    [
      'umi-plugin-react',
      {
        antd: true,
        dva: true,
        dynamicImport: {
          webpackChunkName: true,
        },
        title: 'Demo',
        dll: true,
        locale: {
          enable: true,
          default: 'en-US',
        },
        routes: {
          exclude: [
            /models\//,
            /services\//,
            /model\.(t|j)sx?$/,
            /service\.(t|j)sx?$/,
            /components\//,
          ],
        },
      },
    ],
  ],
};
export default baseConfig;

新建 config.dev.ts,config.prod.ts,导入公共配置并加入开发环境以及正式环境的特有配置。

import { IConfig } from 'umi-types'; // ref: https://umijs.org/config/
import baseConfig from './config'; // ref: https://umijs.org/config/

const config: IConfig = {
  ...baseConfig,
  define: {
    "process.env.apiUrl": 'https://api.test.com',
    "process.env.appKey": 'key',
    "process.env.appSecret": 'secret'
  },
};

export default config;

代码中使用process.env.apiUrl即可获取到当前配置的对应值

开启 dev-server

    cross-env UMI_ENV=dev umi dev

打包

    cross-env UMI_ENV=prod umi build

Dynamic Business Central API 接入与调试

概要

1. Business Central
2. 配置以及调试

Business Central

简单介绍

Dynamic 365 Business Central是专为中小企业设计的全功能ERP解决方案,Dynamic NAV的云上Saas版本。包括财务,进销存,CRM,供应链管理等功能。

接口相关

Dynamic 365 Business Central 提供除了仓储相关外所有的REST API,并提供OAuth 2.0,Basic Auth(只建议开发使用)两种验证方式。提供各个模块中每个文档的CURD,文档地址
需要使用仓库相关接口,则需要配置Web Servic以OData的方式请求。也可以通过安装第三方的BC插件,得到第三方提供的Web Service。使用OData V4即可以得到Json格式的返回报文。

配置以及调试

验证方式

Basic Auth即用户名密码验证,实质上也是在Auth服务器请求得到Token后发起。在BC Users card中得到用户名User Name以及密码信息Web Service Access Key。
填入Basic Auth对应字段即可。

OAuth2.0即使用Azure AD的验证服务器请求得到Token后,使用Token请求。

OData

Open Data Protocol是一种描述如何创建和访问Restful服务的OASIS标准。简而言之,OData通过url变化和http method的组合,达到新增、变更、删除所调用资源的目的。这里以Business Central Warehouse Shipment为例。
首先进入Web Service添加相关服务。可以看到BC自带的shipment服务(ID 7335)和安装了第三方插件后增加的相关Web Service。
使用OData V4 URL以获得Json格式的响应。

以下是GET示例

Azure App Service 多容器部署实例

概要

1. 项目结构
2. 构建以及部署
3. 相关链接

项目结构

一个简单的Web应用 – Switches

应用实现了一个可变长度的布尔数组修改存储,并在前端以switch的形式展示。点击switch时发送请求修改数组中某元素值,修改成功后重新获取数组值。
项目使用Angular, .Net Core, Redis, 前后端分离结构。后端连接redis以实现简单存储的功能。

在一个App Service中实现

App Service实例中只需要运行两个容器即.Net Core App以及Redis, 前端打包后的js以静态文件的形式host在Net Core的默认目录下。
核心是Docker Compose的使用,并为.Net Core App,redis两者之间建立连接。

为什么要需要Scaling

AutoScale在应用负载时让适当数量的资源运行。 当负载增加时,添加资源来处理增加的负载;当资源空闲时,删除资源以节省资金。
App Service中存在两种Scaling即Scale Up以及Scale Out。

Scale Up即对整个实例进行扩大,得到更强计算能力的同时也意味着需要付出更多的成本。
Scale Out即对实例的数量进行调整,即时每个实例的大小相同,但处理工作的能力自然随着数量增加而提高

AutoScale只支持Scale out,并能制定复杂的规则自动化调整,对于用户数量不多但并发较高的应用更加友好。

为什么要需要多容器运行

发挥多实例即Scale Out的优势。Scale Out后拥有多个后端实例,多个redis实例。为高并发提供保障。

App Service的Scaling功能以实例为单位,故多容器运行的实例在Scaling时容器运行数量会成倍增长。而且由于自动创建销毁的特性,App Service不适用于存储功能的应用,但我们可以在实例中使用缓存服务来提高应用的响应速度。
例如WordPress的Redis应用。每个实例第一次访问数据库后将数据存储在Redis,下一次获取数据时先检索redis获取,由此减少对数据库的访问。

当然也可以对Redis做持久化的存储,在容器重启时不丢失数据。

构建以及部署

构建.Net Core App

工程简单分为API以及Data两个工程,Data中的RedisService类包含连接Redis,操作Redis的方法。
在StartUp时注册为一个singleton service,注入至Controller实现调用。
API中存在两个接口,获取当前布尔数组值,修改当前数组值。

[HttpGet]
public async Task<string> Get()
{
    return await _redisService.Get(_switchKey);
}

异步调用RedisService中方法并返回结果。

构建Angular App

工程简单分为Service,Components两个部分,Service中使用Anglar HttpClient发送请求,Component中存在一个组件使用多个Switch的状态展示当前数组值。OnChange时获取所有Switch状态作为参数通过注入的Service发起修改请求。
这里修改了build prod时的输出目录为.Net Core App开启静态文件支持后默认的wwwroot目录,即以静态文件的形式serve。

  "scripts": {
    "build:prod": "ng build --prod --output-path=../Switches/wwwroot/",
  }
构建Dock Compose

App service中多容器运行的核心即Dock Compose,
这里首先声明所有需要的容器即.Net Core App以及Redis。
由于.Net Core App在启动时首先尝试连接Redis,但若此时Redis仍未启动,将导致无法连接从而无法正完成startUp。如何解决?使用depends_on字段,使得Redis容器能够先于.Net Core App启动。

version: "3"

services:
  switches:
    container_name: APIServer
    image: "ymhvic/switches"
    depends_on:
      - redis
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "80:80"
      - "443:443"

  redis:
    container_name: redis
    image: "redis:latest"
    restart: always
    ports:
      - "6379:6379"

以此模板启动,宿主机的80以及443端口将映射到.Net Core App容器对应端口,6379映射至Redis。

由于App Service不支持networks字段,这里无法使用内网ip连接
.Net Core App的redis连接字符串如下:

{
    "RedisConnectionString": "redis:6379"
}

可以在下文的git仓库中找到使用networks的Docker compose。
这里附上App Service不支持的Docker compose字段。https://docs.microsoft.com/en-us/azure/app-service/containers/configure-custom-container#configure-multi-container-apps

迭代更新流程

前端修改后build prod输出至wwwroot,后端修改后重新生成镜像push至仓库。
重启app service下载最新镜像即完成新版本的更新。

相关链接

App Service实例
https://switches.azurewebsites.net

ymhvic/Switches镜像仓库
https://hub.docker.com/repository/docker/ymhvic/switches

.Net Core App代码仓库
https://github.com/VictorYMH/Switches