Android RecyclerView 动画展开item显示详情

Android RecyclerView 动画展开item显示详情

stackoverflow上看到这个问题,答主给了个demo

看懂了之后记录一下,刚开始看别人代码,就这么3个文件看了一晚上。。

效果如下 :
%title插图%num

res文件
main_activity文件就是一个recyclerview
main_item是两个textview 一个标题一个详细信息

MainActivity就是加载了一个RecyclerView

public class MainActivity extends ActionBarActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main_activity);

        final RecyclerView rv = (RecyclerView) findViewById(R.id.rv);

        final LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        rv.setLayoutManager(layoutManager);

        final MainAdapter adapter = new MainAdapter();
        rv.setAdapter(adapter);
    }

}

MainAdapter中new了一个keepOne对象,点进去看这个类,有两个方法:bind和toggle,其中的bind是在MainAdapter中的onBindViewHolder()方法中调用,而toggle是响应viewholder的点击事件

public static class KeepOneH<VH extends RecyclerView.ViewHolder & Expandable> {
//    opened为-1表示所有item是关闭状态,open为pos值的表示pos位置的item为展开的状态
        private int _opened = -1;
        public void bind(VH holder, int pos) {
            if (pos == _opened)
//                3
//            直接显示expandView 无动画
                ExpandableViewHoldersUtil.openH(holder, holder.getExpandView(), false);
            else
//            直接关闭expandView 无动画
                ExpandableViewHoldersUtil.closeH(holder, holder.getExpandView(), false);
        }

 

@SuppressWarnings("unchecked")
//        响应点击事件的方法
        public void toggle(VH holder) {
//            如果点击的就是开着的item,就关闭该item并把opened置-1
//            ???TODO
            if (_opened == holder.getPosition()) {
                _opened = -1;
//                关闭expandView 有动画
                ExpandableViewHoldersUtil.closeH(holder, holder.getExpandView(), true);
            }
//            如果点击其他本来关闭着的item,则把opened值换成当前pos,把之前开的item给关掉
            else {
                int previous = _opened;
                _opened = holder.getPosition();
//                展开expandView 有动画
                ExpandableViewHoldersUtil.openH(holder, holder.getExpandView(), true);

//                用动画关闭之前的item
                final VH oldHolder = (VH) ((RecyclerView) holder.itemView.getParent()).findViewHolderForPosition(previous);
                if (oldHolder != null)
                    ExpandableViewHoldersUtil.closeH(oldHolder, oldHolder.getExpandView(), true);
            }
        }
    }

 

点进openH和closeH方法进去看

//    4
    public static void openH(final RecyclerView.ViewHolder holder, final View expandView, final boolean animate) {
//        animate参数为true,则有动画效果
        if (animate) {
            expandView.setVisibility(View.VISIBLE);
//            5
//            改变高度的动画,具体操作点进去看
            final Animator animator = ViewHolderAnimator.ofItemViewHeight(holder);
//            扩展的动画结束后透明度动画开始
            animator.addListener(new AnimatorListenerAdapter() {
                @Override public void onAnimationEnd(Animator animation) {
                    final ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(expandView, View.ALPHA, 1);
                    alphaAnimator.addListener(new ViewHolderAnimator.ViewHolderAnimatorListener(holder));
                    alphaAnimator.start();
                }
            });
            animator.start();
        }
//        animate参数为false,则直接设置为可见
        else {
            expandView.setVisibility(View.VISIBLE);
            expandView.setAlpha(1);
        }
    }

 

openH方法接收3个参数,
*个是viewholder.
第二个是展开部分的view,由holder.getExpandView()方法获取。这里定义了一个接口

public static interface Expandable {
        public View getExpandView();
    }

在MainAdapter中传入infos这个Textview
@Override
        public View getExpandView() {
            return infos;
        }

 

第三个是一个标记,true时有动画,false时直接设置其展开或者是关闭的状态。所以在bind()方法中调用的openH()都是false,而toggle()中调用的设置为true。

openH方法中 具体动画的操作为ViewHolderAnimator.ofItemViewHeight(holder)

public static Animator ofItemViewHeight(RecyclerView.ViewHolder holder) {
        View parent = (View) holder.itemView.getParent();
        if (parent == null)
            throw new IllegalStateException("Cannot animate the layout of a view that has no parent");

//        测量扩展动画的起始高度和结束高度
        int start = holder.itemView.getMeasuredHeight();
        holder.itemView.measure(View.MeasureSpec.makeMeasureSpec(parent.getMeasuredWidth(), View.MeasureSpec.AT_MOST), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
        int end = holder.itemView.getMeasuredHeight();
//  6
        final Animator animator = LayoutAnimator.ofHeight(holder.itemView, start, end);
//        设定该item在动画开始结束和取消时能否被recycle
        animator.addListener(new ViewHolderAnimatorListener(holder));
//        设定结束时这个item的宽高
        animator.addListener(new LayoutParamsAnimatorListener(holder.itemView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));

        return animator;
    }

 

可以看出 具体展开的动画在LayoutAnimator.ofHeight(holder.itemView, start, end);中,ViewHolderAnimator只是测量参数,设定监听两个监听事件
1设定在动画开始结束和取消状态下是否可以被回收

public ViewHolderAnimatorListener(RecyclerView.ViewHolder holder) {
            _holder = holder;
        }

        @Override
        public void onAnimationStart(Animator animation) {
            _holder.setIsRecyclable(false);
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            _holder.setIsRecyclable(true);
        }

        @Override
        public void onAnimationCancel(Animator animation) {
            _holder.setIsRecyclable(true);
        }
    }

 

2.设定在动画结束后view的高和宽分别为warp_content,match_parent.

public static class LayoutParamsAnimatorListener extends AnimatorListenerAdapter {
        private final View _view;
        private final int _paramsWidth;
        private final int _paramsHeight;

        public LayoutParamsAnimatorListener(View view, int paramsWidth, int paramsHeight) {
            _view = view;
            _paramsWidth = paramsWidth;
            _paramsHeight = paramsHeight;
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            final ViewGroup.LayoutParams params = _view.getLayoutParams();
            params.width = _paramsWidth;
            params.height = _paramsHeight;
            _view.setLayoutParams(params);
        }
    }

 

再深入一层看展开的动画

public class LayoutAnimator {

    public static class LayoutHeightUpdateListener implements ValueAnimator.AnimatorUpdateListener {

        private final View _view;

        public LayoutHeightUpdateListener(View view) {
            _view = view;
        }

        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            final ViewGroup.LayoutParams lp = _view.getLayoutParams();
            lp.height = (int) animation.getAnimatedValue();
            _view.setLayoutParams(lp);
        }

    }

    public static Animator ofHeight(View view, int start, int end) {
        final ValueAnimator animator = ValueAnimator.ofInt(start, end);
        animator.addUpdateListener(new LayoutHeightUpdateListener(view));
        return animator;
    }
}

 

是用ValueAnimator.ofInt生成一系列高度值,然后监听动画的变化,不断设定view的高度值

修复垂直滑动RecyclerView嵌套水平滑动RecyclerView水平滑动不灵敏问题

在 Android 应用中,大部分情况下都会使用一个垂直滚动的 View 来显示内容(比如 ListView、RecyclerView 等)。但是有时候你还希望垂直滚动的View 里面的内容可以水平滚动。如果直接在垂直滚动的 View 里面使用水平滚动的 View,则滚动操作并不是很流畅。

比如下图中的示例:

%title插图%num

为什么会出现这个问题呢?

上图中的布局为一个 RecyclerView 使用的是垂直滚动的 LinearLayoutManager 布局管理器,而里面每个 Item 为另外一个 RecyclerView 使用的是水平滚动的 LinearLayoutManager。而在 Android系统的事件分发中,即使*上层的 View 只能垂直滚动,当用户水平拖动的时候,*上层的 View 依然会拦截点击事件。下面是 RecyclerView.java 中 onInterceptTouchEvent 的相关代码:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {  
  ...
 
  switch (action) {
    case MotionEvent.ACTION_DOWN:
        ...
 
    case MotionEvent.ACTION_MOVE: {
        ...
 
        if (mScrollState != SCROLL_STATE_DRAGGING) {
          boolean startScroll = false;
          if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
            ...
            startScroll = true;
          }
          if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
            ...
            startScroll = true;
          }
          if (startScroll) {
            setScrollState(SCROLL_STATE_DRAGGING);
          }
      }
    } break;
      ...
 
  }
  return mScrollState == SCROLL_STATE_DRAGGING;
}
 

注意上面的 if 判断:

if(canScrollVertically && Math.abs(dy) > mTouchSlop) {...}  
 

RecyclerView 并没有判断用户拖动的角度, 只是用来判断拖动的距离是否大于滚动的*小尺寸。 如果是一个只能垂直滚动的 View,这样实现是没有问题的。如果我们在里面再放一个 水平滚动的 RecyclerView ,则就出现问题了。

可以通过如下的方式来修复该问题:

if(canScrollVertically && Math.abs(dy) > mTouchSlop && (canScrollHorizontally || Math.abs(dy) > Math.abs(dx))) {...}  
 

下面是一个完整的实现 BetterRecyclerView.java :

public class BetterRecyclerView extends RecyclerView{
  private static final int INVALID_POINTER = -1;
  private int mScrollPointerId = INVALID_POINTER;
  private int mInitialTouchX, mInitialTouchY;
  private int mTouchSlop;
  public BetterRecyclerView(Contextcontext) {
    this(context, null);
  }
 
  public BetterRecyclerView(Contextcontext, @Nullable AttributeSetattrs) {
    this(context, attrs, 0);
  }
 
  public BetterRecyclerView(Contextcontext, @Nullable AttributeSetattrs, int defStyle) {
    super(context, attrs, defStyle);
    final ViewConfigurationvc = ViewConfiguration.get(getContext());
    mTouchSlop = vc.getScaledTouchSlop();
  }
 
  @Override
  public void setScrollingTouchSlop(int slopConstant) {
    super.setScrollingTouchSlop(slopConstant);
    final ViewConfigurationvc = ViewConfiguration.get(getContext());
    switch (slopConstant) {
      case TOUCH_SLOP_DEFAULT:
        mTouchSlop = vc.getScaledTouchSlop();
        break;
      case TOUCH_SLOP_PAGING:
        mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(vc);
        break;
      default:
        break;
    }
  }
 
  @Override
  public boolean onInterceptTouchEvent(MotionEvent e) {
    final int action = MotionEventCompat.getActionMasked(e);
    final int actionIndex = MotionEventCompat.getActionIndex(e);
 
    switch (action) {
      case MotionEvent.ACTION_DOWN:
        mScrollPointerId = MotionEventCompat.getPointerId(e, 0);
        mInitialTouchX = (int) (e.getX() + 0.5f);
        mInitialTouchY = (int) (e.getY() + 0.5f);
        return super.onInterceptTouchEvent(e);
 
      case MotionEventCompat.ACTION_POINTER_DOWN:
        mScrollPointerId = MotionEventCompat.getPointerId(e, actionIndex);
        mInitialTouchX = (int) (MotionEventCompat.getX(e, actionIndex) + 0.5f);
        mInitialTouchY = (int) (MotionEventCompat.getY(e, actionIndex) + 0.5f);
        return super.onInterceptTouchEvent(e);
 
      case MotionEvent.ACTION_MOVE: {
        final int index = MotionEventCompat.findPointerIndex(e, mScrollPointerId);
        if (index < 0) {
          return false;
        }
 
        final int x = (int) (MotionEventCompat.getX(e, index) + 0.5f);
        final int y = (int) (MotionEventCompat.getY(e, index) + 0.5f);
        if (getScrollState() != SCROLL_STATE_DRAGGING) {
          final int dx = x - mInitialTouchX;
          final int dy = y - mInitialTouchY;
          final boolean canScrollHorizontally = getLayoutManager().canScrollHorizontally();
          final boolean canScrollVertically = getLayoutManager().canScrollVertically();
          boolean startScroll = false;
          if (canScrollHorizontally && Math.abs(dx) > mTouchSlop && (Math.abs(dx) >= Math.abs(dy) || canScrollVertically)) {
            startScroll = true;
          }
          if (canScrollVertically && Math.abs(dy) > mTouchSlop && (Math.abs(dy) >= Math.abs(dx) || canScrollHorizontally)) {
            startScroll = true;
          }
          return startScroll && super.onInterceptTouchEvent(e);
        }
        return super.onInterceptTouchEvent(e);
      }
 
      default:
        return super.onInterceptTouchEvent(e);
    }
  }
}
 

其他问题

当用户快速滑动(fling)RecyclerView 的时候, RecyclerView 需要一段时间来确定其*终位置。 如果用户在快速滑动一个子的水平 RecyclerView,在子 RecyclerView 还在滑动的过程中,如果用户垂直滑动,则是无法垂直滑动的。原因是子 RecyclerView 依然处理了这个垂直滑动事件。

%title插图%num

所以,在快速滑动后的滚动到静止的状态中,子 View 不应该响应滑动事件了,再次看看 RecyclerView 的 onInterceptTouchEvent() 代码:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {  
    ...
 
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            ...
 
            if (mScrollState == SCROLL_STATE_SETTLING) {
                getParent().requestDisallowInterceptTouchEvent(true);
                setScrollState(SCROLL_STATE_DRAGGING);
            }
 
            ...
    }
    return mScrollState == SCROLL_STATE_DRAGGING;
}
 

可以看到,当 RecyclerView 的状态为 SCROLL_STATE_SETTLING (快速滑动后到滑动静止之间的状态)时, RecyclerView 告诉父控件不要拦截事件。

同样的,如果只有一个方向固定,这样处理是没问题的。

针对我们这个嵌套的情况,父 RecyclerView 应该只拦截垂直滚动事件,所以可以这么修改父 RecyclerView:

public class FeedRootRecyclerView extends BetterRecyclerView{  
  public FeedRootRecyclerView(Contextcontext) {
    this(context, null);
  }
 
  public FeedRootRecyclerView(Contextcontext, @Nullable AttributeSetattrs) {
    this(context, attrs, 0);
  }
 
  public FeedRootRecyclerView(Contextcontext, @Nullable AttributeSetattrs, int defStyle) {
    super(context, attrs, defStyle);
  }
 
  @Override
  public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    /* do nothing */
  }
}
 

下图为*终的结果:

%title插图%num

如果感兴趣可以下载 示例项目 ,注意示例项目中使用 kotlin,所以需要配置 kotlin 插件。

元数据驱动的 SaaS 架构与背后的技术思考

引言

作为业务系统技术开发同学,面向当下:

  • 首先应该是快速搭建业务通路,让线上业务跑起来,快速试错,解决生存问题;
  • 第二步是在链路畅通、业务基本跑起来的基础上,如何支撑业务跑得更快,就需要解决快速增长问题;
  • 第三步,在完成支撑业务快速增长的基础上,要进行精细化提升,通过在支撑业务快跑间隙挤时间打磨系统功能和体验,踏踏实实花时间去抽象能力,沉淀产品,提升效能;

同时我们也必须面向未来,如何在抽象能力以及沉淀了产品的基础上,把所承载和沉淀的业务能力快速输出,贡献给整个行业,或为整个社会商业生态提供基座支撑。面向未来,将平台产品进行 SaaS 化升级,真正将能力进行有价值开放输出是我们提前要布局的核心方向。

将平台产品进行 SaaS 输出,需要解决那些问题呢?这里尝试把核心问题列举一下:

1. 如何根据不同用户需求进行计算能力按需调度分配?(IaaS/PaaS)

2. 如何满足用户数据安全性要求,严格隔离不同用户的数据,使用户只能看到自己的数据?(PaaS)

3. 如何支持不同用户在标准的数据对象/数据模型上按需添加自定义的数据对象/扩展模型?(PaaS & SaaS)

4. 如何按照不同用户进行按需功能搭配组合,满足不同用户从基础到专业级不同业务场景需求?(SaaS)

5. 如何统一对平台产品进行升级而不影响用户已有数据及功能?(IaaS、PaaS、SaaS)

通过以上问题,我们可以看出,产品 SaaS 化输出的关键是如何对不同的用户通过标准+扩展能力按需进行算力、数据、安全、功能有效定制,支持多用户共性和个性的问题,即多租户的问题,同时也涉及到计费和服务水平等相关问题。我们下面来聊下上述问题的解题关键和解题思路:

  • 第1个算力问题的核心是调度问题,弹性计算提供在 IaaS 层的统一算力调度能力,而 Serverless 则可以在 PaaS 层提供更高层次的算力调度能力。
  • 第4个问题的核心是业务流程的抽象和业务功能的拆分。领域驱动设计以及服务化(微服务)在平台功能抽象拆分上提供了相对成熟的思路,催化了以纵向业务功能细分作为域划分的依据的服务化方案以及组织结构,主要诉求是在细分的业务功能服务基础上,能按需快速灵活的组合,从而支撑不同的业务模式,提供业务敏捷性,支撑业务创新求变。

当然反过来,由于纵向功能细分,业务功能域增多,整个业务链条上的咬合点越来越多,随之产生越来越多的数据来源冗余重复或者缺失,功能或者重合且各自发散,或者缺失,*终给整体业务带来较多数据和功能的不一致性风险。这样一来,不仅横向端到端的业务串联成本高,而且关键路径的风险收敛成本比较高,矛盾冲突点集中在各纵向域功能和数据咬合处,具体表现为:

 

数据上:

  • 无主数据,有数据需求无 owner;
  • 大量重复且不一致数据;

功能上:

  • 部分业务功能缺失;
  • 域之间存在业务功能重复且行为不一致。

到底是纵向切分域还是横向分业务模式拉平来做,这个问题没有标准答案,更没有*佳答案。只有根据不同的业务发展阶段及时动态调整试错,换言之,这是一个不断寻找相对*优解的动态过程。

弹性计算和 Serverless 解决了算力的问题,领域驱动服务化设计解决了功能的拆分和按需搭配组合的问题,那么剩下的核心问题就是数据了:如何以一套统一的数据架构,既能支撑多租户的数据安全性需求以及通用的数据存储,也能支撑用户扩展的自定义数据对象定义和模型变更,同时也要保证数据定义层面的扩展和变更不会影响自身和其他租户业务功能的可用性。我们来分析下可能的方案(暂不考虑按服务边界进行数据库拆分):

(1)统一的数据库,标准数据模型和扩展数据模型直接映射到物理表和索引:很显然,对于不同租户自定义的数据对象和数据模型要求是无法支撑的,物理数据模型会相互干扰、相互冲突直到无以为继。即使是对于所有租户完全标准的功能和数据存储,平台自身的标准模型升级的 DDL 也会对用户的可用性造成较大影响,所以显然是行不通的。

(2)如果为每个租户创建各自的数据库呢?各自租户拥有各自的数据库,可以满足用户数据安全隔离的需求,也可以满足各租户自定义的数据需求,看上去像是一种合理的 SaaS 数据方案。但是仔细分析,会发现有两个明显的问题:

  1. 如果用户需要修改或者扩展现有物理数据模型而进行的 DDL 操作,必然会影响线上业务的整体可用性,也可能会影响到标准数据模型,从而影响到线上功能使用。
  2. 如果用户可自定义对物理模型进行扩展和定制,当平台进行模型升级的时候,*容易产生物理模型的冲突,导致新旧功能异常。
  3. 由于用户在各自数据库存在各自定义的扩展和定制,则平台数据模型和功能升级需要针对不同的租户进行分别验证,存在*大的升级验证工作量和风险。

以上两种方案可行性低,我们从其中发现的问题是:平台业务系统的逻辑模型到物理模型的直接映射是造成问题的主要因素。既然物理模型的变更是平台不稳定的动因,那么我们是否能通过解耦业务逻辑模型和物理模型的映射关系来尝试解决这个问题呢?

既然问题已经定义清楚了,如何解决这个问题呢?通常我们解决架构问题的一个“万能”的方法是:增加一个层次,我们也来套用一次,增加一个层次(元数据层)来解耦逻辑模型到物理模型强映射的问题。

首先,我们需要对业务进行建模,对业务进行抽象,定义出业务逻辑模型,然后对模型进行二次抽象,定义出逻辑模型的定义数据,实现业务模型的数据化,即模型的元数据(The Metadata of the Logic Model ),将模型结构存储为数据,而不是直接对应的物理存储结构。

其次根据定义出的元数据进行统一抽象,形成元数据逻辑模型。

将元数据逻辑模型映射到元数据物理模型,对应实际存储结构。

通过对业务模型的变更,形成对元数据层的数据变更,而不是物理结构的变更,从而实现业务逻辑模型同物理模型的解耦。

%title插图%num

很多事情说起来好像挺简单,实际上是一个非常巨大的系统工程,将其付诸实践是挑战非常大的事情,而取得踏踏实实的成功则更难。上述问题的解题思路是 Salesforce 的解题思路,而且 Salesforce 不仅取得了成功,也接近将其做到了*致,下面我们站在巨人的肩膀上来看看 Salesforce 如何通过元数据驱动的架构(核心是基础数据架构)来支撑多租户的 SaaS 业务平台。注意:由于 Salesforce 并未有对核心实现逻辑进行完全公开和说明,所以本文所整理的部分核心逻辑包含了作者的逻辑推理和解读,但是确实进行了逻辑验证和场景验证,如有纰漏和不全面的地方,欢迎讨论及指正。

 

%title插图%num元数据驱动的多租户架构

Salesforce 将 Force.com 定义为 PaaS 平台,Force.com  的基础就是元数据驱动的软件架构来支撑多租户应用。首先我来解释下什么是以元数据驱动的软件架构为核心。

 

一、多租户意味着什么

 

多租户的含义用一句话来描述就是:一个云平台,无数多个客户。

一个云平台的含义是:一个代码库,一个数据库,一整套共享的可扩展服务,包括数据服务、应用服务以及 Web 服务。

无数多个客户的含义是:每个客户都被分配一个唯一的租户 OrgID,所有的数据存储都是按照租户 OrgID 隔离的,所有的数据访问必须包含 OrgID,所有的操作也都是包含租户 OrgID 的,也就是所有的客户数据和行为都是被安全的通过唯一的租户 Org 进行严格隔离的。

每个租户/组织只能看到和定义按照自己租户 OrgID 隔离的自己版本的元数据和数据,而且只能执行自己租户 OrgID 所授权的行为,这样每个租户就拥有各自版本的 SaaS 方案。

 

二、元数据驱动意味着什么

 

元数据对于平台意味着平台数据的数据,对于租户意味着是关于租户数据的数据。

当用户定义一个新的用户表的时候,用户创建的不是数据库中的物理表,而是在系统态的元数据表中添加了一条记录,这个记录描述的是用户表的逻辑定义,是虚拟的,这个表并不在数据库中物理存在,而这条记录代表就是用户态的数据表。

当用户定义了用户表的一个新的字段时,用户并没有在物理表中创建物理字段,而是在系统态的元数据表中添加了一个记录,这个记录描述的用户表的字段组成的逻辑结构,是虚拟的,这个字段也不在数据库表结构中物理存在,而这条记录代表的就是用户态的用户表字段。

也就是通过存储在系统态的元数据表中的元数据记录作为虚拟用户的数据库结构。

 

三、元数据驱动的多租户整体架构

我们先来大概了解下元数据驱动的多租户的整体架构,整体架构大概分为 5 个逻辑层次:

1. 底层数据架构分为三个层次:

  • *底层是数据层,存储了离散的系统和用户的业务数据,业务日常运营的数据存储在这里。
  • 公共元数据层,存储了应用系统标准的对象和标准的字段定义,对底层数据的结构进行定义说明。
  • 租户特定元数据,存储了租户自动的对象和自定义的字段定义,用于对底层的数据结构进行定义说明。

2. 通用数据字典 UDD(Universal Data Dictionary) 运行引擎层实现了应用对象到底层数据存储的映射,包含对象模型操作、SOQL 语言解析、查询优化,全文搜索等功能,我们常说的 ORM 功能也是其核心功能,但比其复杂的多。

3. 平台服务层提供 PaaS 层平台服务,提供应用对象模型的创建,权限模型创建,逻辑和工作流程创建以及用户界面的创建,包括屏幕布局、数据项、报表等

4. 标准应用层提供端到端的标准的业务应用功能。

5. 租户虚拟应用层,用户可以在标准应用层或者平台服务层之上定义自己特有的业务应用功能,满足自己特定的业务场景需要。

%title插图%num

其中,底层数据架构是*为关键的平台基石(The Corner Stone),其核心运行引擎也是基于强大的底层数据架构基础上构建的。本文则以元数据驱动的多租户数据架构为核心来一一展开。

 

四、元数据驱动的多租户数据架构

下面我们具体来看下系统态的数据模型,基于 Salesforce 加上个人推理的元数据驱动的多租户数据模型。注意:由于 Salesforce 并未有对核心逻辑进行完全公开和说明,所以本文所整理的部分核心模型包含了个人的逻辑推理和解读,但是确实进行了逻辑验证和场景验证,如有纰漏和不全面的地方,欢迎讨论及指正。

Salesforce 云服务平台遵循的是面向对象的设计理念,所有的实体、实体关系以及实体的 CRUD 均是以对象的视角进行的,所以其元数据驱动的多租户数据模型的存储基本元素也是按照对象的颗粒度进行存储,源自于 OO 的对象间引用,同普通关系数据库主外键关系异曲同工,只是细节处理上不尽相同,请大家注意这一点。

 

1. 元数据驱动的多租户数据架构概览

首先,我们先来大概了解下元数据驱动的多租户模型的核心内容,元数据驱动的多租户的数据模型主要分为三个部分:元数据表、数据表和功能透视表。

  • 元数据表(Metadata Tables)

元数据表用于存放系统标准对象以及用户自定义对象和字段定义的元数据,也就是系统和用户对象的逻辑结构,即对应于关系数据库中的虚拟表结构。元数据表主要包括Objects 表以及 Fields 表,是系统标准对象和用户对象定义数据的仓库,即元数据仓库。

  • 数据表(Data Tables)

数据表用户存放系统以及用户对象和字段的实际数据,实际的用户业务数据以及应用系统相关数据存放在这里。数据表包括 Data 表和存放大文本数据的 Clob 表,数据表存储了*大部分用户的实际数据,是一个巨大的用户业务数据仓库。

  • 功能透视表(Specialized Pivot Tables)

功能透视表包含了非常关键的关系表、索引表、关系表以及其他特定用途表。例如关系表定义了对象间的关系,索引表解决虚拟结构索引的问题,这部分后续将进行详尽的介绍。

%title插图%num

2. 元数据驱动的多租户数据架构详解

上一节粗略地描述了元数据驱动的多租户模型三大部分模型实体和基本作用,大家可能会比较疑惑,这么简单一个实体模型,怎么就起了这么个牛逼的名字,而且支撑了“一个云平台,无数个客户”。我们下面就对此模型的核心逻辑进行详细展开和推理说明,同时详细阐述以此模型为中心的服务来说明整个元数据层或者说 UDD(Universal Data Dictionary) 层的设计。

土话说:“没有对比,就没有伤害”。道理是相通的,用相似的事物进行对比是对理解客观事物比较好的方法,找出其相同点和共性的地方,找出其不同点和异样的地方,同时识别出是否有不可对比的方面。从各个方面去对比,则能更全面、更深入的了解客观事物。

下面我按照普通应用设计思路方式来定义一个简单直观的多租户 SaaS 数据架构方案示例,作为元数据驱动多租户数据架构方案的对比基准方案,用对比来更好的帮大家了解元数据驱动多租户数据模型及架构的设计逻辑。

(1)普通多租户 SaaS 数据架构方案示例(仅做示例)

  • 多租户基本思路:每个租户一个数据库,提供数据库级别的租户数据隔离,平台提供标准应用功能模型,用户可以在各自数据库内定义以及修改各自的定义模型,所有模型采用数据库物理表、索引、主外键实现。不同的租户通过路由到不同的数据库来实现隔离。
  • 域模型样例采用大家都熟悉的*小集的订单模型实现,包含商品、用户、订单和订单详情表。注意:此简化模型仅用做示意说明,和意图无关的大多数字段均省略,非严谨定义。

%title插图%num

  • 示例模型数据

数据库物理表数据:Customer

%title插图%num

数据库物理表数据:Product

%title插图%num

数据库物理表数据:Order

%title插图%num数据库物理表数据:OrderItem

%title插图%num

  • 实体表关系

Order 表同 OrderItem 为父子表,通过 OrderID 进行主外键关联;Customer 表同 Order 表为父子表,通过 CustomerID 进行主外键关联;Product 表同 OrderItem 表为父子表,通过 ProductID 进行主外键关联。

  • 用户自定制

用户有执行 DDL 权限,可以在自己租户数据库内在进行扩展模型自定义,建立自定义的物理表,索引,关系等。

  • 问题和风险

用户具有执行 DDL 权限,可以自定义数据库物理模型,会带来各租户的自定义数据模型大爆炸,会给后续平台模型定义升级冲突,造成模型升级的巨大的障碍

同时,由于系统标准模型和用户模型均为物理模型,未有做系统标准和自定义数据的有效隔离,如何保证平台应用的每一次升级必然会考虑对现有用户自定义模型的稳定性和可用性的影响,在自定义物理模型的情况下,不仅挑战巨大,而且包含巨大的回归验证的工作量,很难收敛。

当用户执行 DDL 时,通常会锁定数据库物理资源,当数据库数量非常巨大时可能会带来不可控的 downtime,对应用系统的可用性造成巨大的影响。如果数据库是每个租户各自独占,还只会影响到单个租户;如果是多租户共享数据库,则可能会影响到其他租户,影响是灾难性的。作为云平台服务商,不管是用户操作还是系统行为,我们都不期望我们的设计对用户系统的可用性造成影响,所以用户执行 DDL 的行为是否允许确实有待商榷,但是如果不允许,用户可扩展性在这种设计环境中必然受到一定程度的限制。

 

(2)元数据驱动的多租户数据模型(Metadata Tables)

前面章节描述了元数据驱动的多租户模型简单模型图,本小节详细解说下每个核心实体表的核心结构,同时已知资料部分较为简略,无法描述模型全貌和核心细节,为了模型完整性,整体数据模型包含了作者思路推理部分,用以来完整清晰地定义模型。当然由于所有模型都是主观的(subjective),仅代表个人观点,欢迎大家的不同的观点,一起讨论改进。

正如前面介绍“一个云平台”时提到,通过一个统一的数据库来支撑无数个租户,所以元数据驱动的多租户模型是基于一个共享数据库的前提。当然多租户实现设计多种多样,大家可以不拘泥此种。

1)元数据表之对象定义表:Objects 表

%title插图%num

Object 系统表存储了每个租户为它的扩展应用对象定义的元数据,包含如下核心字段:

  • ObjID:应用对象唯一标识,具有固定长度和格式。
  • OrgID:应用对象所归属的租户 ID,用于统一共享数据库内的多租户数据隔离,通常和租户定义的域名对应。
  • ObjName/Name:对象名称,用于系统配置和开发(developer name)。
  • Label: 对象的显示名称。

除了用户自定义对象,系统的标准对象也是采用相同的方式进行定义的。

2)元数据表之字段与关系定义表:Fields 表

 

%title插图%num

 

Fields 系统表存储了每个租户为他的扩展应用对象字段定义的元数据,包含了其所归属的应用对象的租户 OrgID,字段所属对象的 ObjID,字段定义标识 FieldID,字段名称FieldName,字段存储位置定义 FieldNum,数据类型 DataType。数据类型重要补充关联字段(DigitLeft,Scale,TextLength,RelatedTo,ChildRelationshipName)以及是否必选、唯一、索引标记,还有部分标准字段。Fields表非常关键,其不仅定义了普通的应用对象字段,包括基本信息和数据类型信息,而且通过特殊关系字段对不同应用对象之间的关系进行定义,详细说明如下:

  • FieldID:此对象字段的唯一标识,具有固定长度和格式
  • OrgID:其所归属的应用对象所归属的租户 OrgID
  • ObjID:字段所属对象的 ObjID
  • FieldName/Name:字段名,用于系统配置和开发(developer name)。
  • Label:字段展示名称,用以展示给*终用户。
  • FieldNum:对应到 Data 数据表的数据存储字段映射,暨 Data 表中 ValueX 字段中的X。
  • DataType:指定此对象字段的数据类型包含普通类型:Number、TEXT、Auto Number、Date/Time、Email、Text Area等,也包含特殊的关系类型如:Look up关系类型、Master-Detail 关系类型等。
  • DigitLeft 和 Scale:用于 Number、Currency、Geolocation 等数字数据类型的关联设定,例如定义了一个字段的 DateType 为 Number,则需要指定其整数部分的*大位数 DigitLeft 和小数部分的*大位数 Scale,两部分长度总和不超过 18 位。
  • TextLength:当数据类型为 TEXT 时启用,用于指定 TEXT 类型的字符的长度限制。
  • RelatedTo 和 ChildRelationshipName:这两个字段当 DateType 为关系类型(Look up,Master-Detail 等)时会启用,其中 RelatedTo 保存关联的应用对象 ID,ChildRelationshipName 用于保存父子关系中子方的关系名称,同一个父对象的子方的关系名称唯一,用于关系的反向查询。
  • IsRequired:此字段数据保存时,是否校验值的存在。
  • IsUnique:是否允许重复值。
  • IsIndexed:此字段是否需要建索引。
  • 其他字段:此处仅列举了说明模型所需要的字段,其他字段暂不进行列举,不列举原因和其重要性并无直接关联。

3)数据表(Data Tables)之关系数据表:Data 表

 

%title插图%num

MTData 系统表存储了 MTObjects 和 MT_Fields 元数据表内定义的数据对象(表)所对应的数据,一一映射到不同的租户各自定义的表和表中的字段(对象和对象字段)。

  • GUID:数据表的主键,用于存放每个应用对象实例的标识 ID。
  • ObjID:其所归属的应用对象所归属的租户 OrgID。
  • Name:应用对象实例名称。
  • Value0….Value500:用于存放对象实例字段的数据,其 ValueX 中 X 值对应到 Fields 表中 FieldNum 定义,ValueX 存放的数据,不管原始数据类型、存储格式均为变长字符串格式。

 

4)数据表(Data Tables)之非结构化数据表:CLobs

MT_Clobs 用于存储大字符段的存储 CLOB,同时 CLOB 也存储在数据库外的索引结构中,用于快速的 Full-Text 文本检索。

3. 元数据模型核心实体关系图

我们在应用系统开发中,通常我们定义的数据结构包括数据表、表字段,索引通常都会直接定义在物理数据库中,创建物理的表和字段以及索引等。

但是在元数据驱动平台数据模型中,我们定义的用户表包括系统表都是逻辑表,其结构是虚拟的,用户表的定义存储在 Objects 表,对应的字段定义存储在 Fields 表中,实际用户数据存储在 Data 表中。特别注意的是,对象的引用关系定义也定义在 Fields 表中,以特殊数据类型方式来定义。(另:Relationships 表后面章节进行描述)。

从每个租户视角来看,每个租户都在一个共享数据库内拥有一个基于租户标识 OrgID 来隔离的虚拟的租户数据库。

元数据实体包括 Objects 和 Fileds 实体以及实际数据 Data 实体都包含租户 OrgID,这样就可以通过租户 OrgID 来天然隔离各租户的数据,当然不止这些实体,包括索引相关等透视表实体也使如此。

%title插图%num

4、标准对象与标准字段

前面整体架构层次里提到了公共元数据层和标准应用层,公共元数据层提供了标准对象和标准字段的定义。

其中标准对象为每个租户提供公共端到端的应用的标准应用功能。

 

%title插图%num

 

同时用户可以在标准的对象基础上扩展自定义的应用对象,满足自己的特定业务场景。__c 后缀代表自定义,后续详解。

 

%title插图%num

 

而标准字段则提供给每个对象包括自定义对象的共同的字段,包含部分业务字段和非业务字段。

 

%title插图%num

用户也可以在标准对象和自定义对象内自定义不同的字段,以满足业务需要。__c后缀代表自定义,后续详解。

%title插图%num

 

5、对象关系类型

应用对象关系类型主要分为 Look up 和 Master-Detail 两种关系类型,其中 Look up 为弱的父子关系类型,Master-Detail 为强的父子关系类型,其特性对比如下。

%title插图%num

 

6、元数据驱动的多租户数据架构示例

同样采用普通多租户 SaaS 数据架构方案中相同的域模型和示例数据作为参照进行说明,只不过在这里域模型不再对应到数据库的物理模型,而是对应到元数据所定义的虚拟数据库的逻辑模型。请前后对比两种模型对用户业务模型承载的差异和联系,以便深入了解元数据驱动的多租户数据架构。

 

%title插图%num

对于 Tenant 租户 A00001,需要支撑相同的业务逻辑,需要定义相同的域模型,和普通的方案不同的是,这里采用元数据驱动的多租户数据模型来定义订单域模型和对应示例数据,其中域模型定义在元数据表(Metadata Tables)中,数据存储在 Data Tables 表中。

1)用户自定义对象 Product 的定义

Product 对象的基本信息定义在 Objects 表,作为 Objects 表的一条记录,通过 OrgID 进行不同租户数据隔离。Object 中的每一条记录都代表一个不同的对象。Objects 表的定义非常清晰,这里不做过多的解释,请参考 Objects 表介绍。

 

%title插图%num

Product 对象的字段结构定义在 Fields 表,同时通过 ObjID 同 Order 对象定义进行关联,通过 OrgID 进行多租户数据隔离。

FieldID 格式为字段定义的标识 ID,用于区分每个字段定义,对于标准字段,则采用标准字段 ID,如 Name,则直接采用 Name 作为字段标识 ID,对于自定义字段,则元数据引擎自动生成 15 位的标准格式的 FieldID。其他字段定义请参考前面的 Fields 元数据表详细介绍。

下面详细描述一下 Product 对象中每个字段定义:

  1. 产品名称 Name 字段 为标准字段,数据格式为TEXT,长度为80。
  2. 产品编号 ProductNo 为自定义字段,数据格式为 TEXT,长度为 22,FieldNum 为 1 对应 Data 表存储字段 Value1,存储格式为变长字符串。
  3. 产品价格 ProductPrice 为自定义字段,数据格式为 Currentcy(此格式类似Number,不同是带币种),整数*大长度 DigitLeft:16 位,小数位*大精度Scale:2 位,FieldNum 为 2 对应 Data 表存储字段 Value3,存储格式为变长字符串。
  4. 状态 ProductStatus 为自定义字段,数据格式为 TEXT,长度为 20,FieldNum 为 3对应 Data 表存储字段 Value3,存储格式为变长字符串。

 

%title插图%num

 

2)用户自定义对象 Customer 的定义

Customer 对象的基本信息定义在 Objects 表,作为 Objects 表的一条记录,通过 OrgID 进行不同租户数据隔离。Object 中的每一条记录都代表一个不同的对象。Objects表的定义非常清晰,这里不做过多的解释,请参考Objects表介绍。

%title插图%num

Customer 对象的字段结构定义在 Fields 表,同时通过 ObjID 同 Order 对象定义进行关联,通过 OrgID 进行多租户数据隔离。

下面详细描述一下 Customer 对象中每个字段定义:

  • 用户名称 Name,必选标准字段,不过多解释。
  • 用户编号 CustomerNo 为自定义字段,数据类型为 TEXT,长度为 22,FieldNum 为 1 对应 Data 表存储字段 Value1,存储格式为变长字符串。
  • FirstName 和 LastName 为自定义字段,数据类型为 TEXT,长度均为 20,FieldNum 为 2,3 对应 Data 表存储字段 Value2 和 Value3,存储格式为变长字符串。
  • 用户昵称 Nick Name 为自定义字段,数据类型为 TEXT,长度均为 20,FieldNum 为 4 对应 Data 表存储字段 Value4,存储格式为变长字符串。
  • 用户登录名 LoginName 为自定义字段,数据类型为 TEXT,长度均为 20,FieldNum 为 5 对应 Data 表存储字段 Value5,存储格式为变长字符串。
  • 用户状态 CustomerStatus 为自定义字段,数据类型为 TEXT 或者 PickList,长度为 20,FieldNum 为 6 对应 Data表存储字段 Value6。为简化起见,状态字段暂定义为 TEXT,对应 Data 表存储字段 Value4,存储格式为变长字符串。

%title插图%num

3)用户订单 Order 逻辑表的定义

Order 对象的基本信息定义在 Objects 表,作为 Objects 表的一条记录,通过 OrgID 进行多租户数据隔离。Objects 表中的每一条记录都代表一个不同的对象。

%title插图%num

Order 对象的字段结构定义在 Fields 表,同时通过 ObjID 同 Order 对象定义进行关联,通过 OrgID 进行多租户数据隔离。

下面详细描述一下 Order 对象中每个字段定义:

  • 订单编号 OrderNo 为自定义字段,DataType 数据格式为 TEXT,长度为 22,FieldNum 为 1,对应 Data 表存储字段 Value1,存储格式为变长字符串。
  • 关系字段 Customer 为自定义关系字段,DataType 类型为弱类型 Look up 关系,关联到父对象 Customer,则 RelatedTo 列存储 Customer 的 ObjID:01I2v000002zTEZ,对应的 FieldNum 为 2,则 Customer 对象实例 GUID 存储在 Data 表的 Value2 列。ChildRelationshipName 列存储对象父子关系中子关系名称:orders,用于对象关系中从父对象实例数据反查子对象实例数据。
  • 订单状态 OrderStatus 为自定义字段,DataType 类型为 TEXT,长度为 20,FieldNum 为 3,则状态存储在 Data 表的 Value3 列。为简化起见,状态字段暂定义为 TEXT。
  • 下单时间 OrderTime 为自定义字段,DataType 类型为 Date/Time,FieldNum 为4,则下单时间存储在 Data 数据表的 Value4 列。

 

%title插图%num

 

4)用户订单行 OrderItem 逻辑表定义同样的,OrderItem 对象的基本信息也以一条记录的信息定义在 Objects 表,通过 OrgID 进行多租户数据隔离。Objects 表中的每一条记录都代表一个不同的对象。

%title插图%num

OrderItem 的字段结构也定义在 Fields 表,通过 ObjID 同 OrderItem 对象关联,通过 OrgID 进行多租户数据隔离。

下面详细描述一下 Order 对象中每个字段定义:

  • 关系字段 Order 为自定义关系字段,DataType 类型为强类型的 Master-Detail 关系,关联到父对象 Order,则 RelatedTo 列存储 Order 对象的 ObjID:01I2v000002zTEj,对应的 FieldNum 为 1,则 Order 对象实例 GUID 存储在 Data 表的 Value1 列。ChildRelationshipName 列存储对象父子关系中子关系名称:OrderItem(s),用于对象关系中从父对象 Order 实例数据反查子对象实例数据。
  • 关系字段 Product 为自定义关系字段,DataType 类型为弱类型的 Look up 关系,关联到父对象 Product,则 RelatedTo 列存储 Product 对象的 ObjID:01I2v000002zTEU,对应的 FieldNum 为 2,则 Product 对象实例 GUID 存储在Data 表的 Value2 列。ChildRelationshipName 列存储对象父子关系中子关系名称:OrderItem(s),用于对象关系中从父对象 Product 实例数据反查子对象实例数据。
  • 商品实际售价 ItemPrice 为自定义字段,DateType 类型为 Currentcy(此格式类似 Number,不同是带币种),整数*大长度 DigitLeft:16 位,小数位*大精度 Scale:2 位,FieldNum 为 2 对应 Data 表存储列 Value3,存储格式为变长字符串。
  • 商品购买数量 Item Quantity 为自定义字段,DataType 类型为 Number,整形长度为 18 位,无小数位数,FieldNum 为 4,对应 Data 数据表存储列 Value4。
  • 订单明细状态 OrderItemStatus 为自定义字段,Datetype 类型为 TEXT,长度为 20,对应 FieldNum 为 5,对应 Data 数据表存储列 Value5。为简化起见,状态字段暂定义为 TEXT。

 

%title插图%num

5)对象 Schema

定义好的用户应用对象 Schema 如下图

%title插图%num

6)数据表 Data 表用户数据存储

前面提到了用户自定义的应用对象以虚拟结构的方式存储在 Objects 和 Fields 表中,那么用户定义的应用对象 Product、Customer、Order 和 OrderItem 里的数据存储在哪里呢?答案是 Data 表,用户定义的对象的数据均会存储在 Data 表中,每个用户定义对象实例(或者近似称为用户表记录)数据以 Data 表中一条记录的形式存在。Product、Customer、Order 表的数据记录均存储在 Data 表,OrderItem 也亦是如此。

其中,GUID 作为每条数据记录暨是每个对象实例的全局唯一标识,OrgID 进行多租户数据隔离,ObjID 同 Objects 表关联代表具体哪个对象定义。这里重点提一下,Fields 中定义的对象字段在 Data 表中的存储,其中 Fields 表中 FieldNum 非常关键,它对应了对象实例字段在 Data 表中的具体存储位置,FieldNum 对应数字决定着数据存储在 Data 表中的哪个 ValueX 列。前面每个对象结构定义都对 FieldNum 对应 Data 的进行了说明,对象字段 FieldNum 可以不按照顺序来,只要 FieldNum 没有占用,可以任意对应,当然按照顺序是比较好的实践。

再举例来说:

  1. Order 对象的 Customer 关系字段定义在 Fields 表中,其 FieldNum 为 1,则其在 Data 表中存储的位置,就是是 Order 对象实例在 Data 对应的记录中 Value1 这个字段所存储的值,存储的值为 Customer 对象实例 GUID,也就是:a062v00001YXEKuAAP、a062v00001YXEKzAAP 等。
  2. OrderItem 对象的 Product、ItemQuantity 字段定义在 Fields 表中,其对应的 FieldNum 分别为2、4,则其在 Data 表中存储的位置,就是 OrderItem 对象在 Data 对应的记录中 Value2、以及 Value4 所存储的数据,也就是:a052v00000jbgEQAAY、2以及a052v00000jbgMqAAI、3 等记录。

 

%title插图%num

%title插图%num

%title插图%num

7、通用的存储,按需转换 —Data 表数据类型与存储

我们看了元数据驱动的多租户模型的核心关系,明白了用户自定义表(包括应用系统表)以及表结构是在 Objects 和 Fields 进行虚拟定义的,也清楚的知道了系统以及用户表的数据是作为一条条记录存储在 Data 表中的,那么我们下面来看下不同的数据类型如何在 Data 中进行存储的呢?

在 Fields 表中,可以采用任何一种标准的结构化的数据类型,如 text,number,date,以及 date/time 对用户表字段进行定义,也可以采用特殊结构的数据类型对字段类型进行定义,如下拉框 picklist,系统自增字段 autonumber,公式列(只读的公式推导列),布尔多选框,email,URL 以及其他的类型,当然也可以通过系统应用来对 Fields 中的自定义字段进行强制约束包括是否必须非空以及掐校验规则(如符合特定格式,符合特定值范围等)。

上述的各种不同字段格式数据都是存储在 Data 表中的 ValueX 列中的,Data 表中包含 500 个数据列,称为弹性列,用来存储用户数据和系统数据,也就是对应到 Objects 表和 Fields 表对应的虚拟表结构所要承载的数据。

特别的,所有弹性列都用了一个可变长度的字符串类型,以便于他们可以存储任何结构化类型的应用和用户数据(字符串,数字,日期等)。

正是因为弹性列把所有不同的数据类型拉平来存储,所以任一弹性列可以对存储任何对象的任何类型的属性来存储,用户可以指定不同的对象的不同属性对应的不同的存储弹性列,当然同属于相同对象的实例的属性对应的弹性列是一致的。一个弹性列可以存储来不同的格式的数据,前提条件是这些数据属于不同的对象的不同属性。例如:上一节示例中,Data 表的 Value2 列可以存储 Order 表的日期格式的 OrderTime 数据,也可以存储 OrderItem 表的格式为字符串的 OrderID 数据。

%title插图%num

如上所述,弹性列用通用数据类型暨可变长字符串来存储所有类型的数据,这样就可以在不同的用户表字段间共享相同弹性列,即便它们的数据类型各异。

既然所有的数据全部用通用的可变长字符串来存储,那么应用逻辑处理需要不同的数据格式时候怎么办呢?具体做法如下:

当应用系统需要从弹性列读取和写入数据时候,UDD(Universal Data Dictionary) 层暨元数据运行引擎会用底层数据库系统数据转换函数(如 Oracle 数据库的TONUMBER,TODATE,TO_CHAR 函数)按需对数据格式进行转换,将字符串格式转换成对应的数据格式(数字,日期等)。

如果存储非结构化的大文本块数据怎么办呢?模型支持对Clob大字段的定义,对于在 Data 表中具有 CLob 数据的每一行数据,系统将其存储在 Clobs 透视表中,并按照需要同 Data 表的对应数据对象实例记录进行关联。

8、多租户索引透视表 (Pivot Tables)

1)Indexes 透视表

大多数结构化的数据存储在 Data 表内,如前面提到的,所有这些不同类型数据都是以可变字符串的形式存在 ValueX 列里面如各种数字以及日期等全部都是以可变字符存储的,这样虽然对于对象实例各种字段的存储确实非常灵活,不同的列可以存储不同类型的数据,即使同一 ValueX 列不同的对象也可以存储类型的数据,但是这样带来一个巨大的问题,由于不同的数据类型以可变字符串的方式存储在同一列内,你没办法利用底层数据库索引的能力对其进行排序,ValueX 列的数据都是一种按照离散的顺序来存储的。传统的数据库依赖原生的数据库索引来快速在数据表内定位到符合查询条件的记录。而按照 Data 表ValueX列的数据存储情况,在 Data 表建立 ValueX 列的索引来支撑数据快速查询是不现实的。

所以解决办法就是建立另外的透视表叫做 Indexes 索引表,并把数据拷贝出数据表并转换成原始的的数据类型,并存储到Indexes索引表列内,如原来是整形的数据以可变字符串的格式存储 在ValueX 列中,拷贝到 Indexes 表之前通过函数将其转换为原始的数据类型,在存储到 Indexes 对应的 NumValue 列内,以方便建立索引,Indexes 表包含强类型的索引类,像 StringValue,NumValue,DataValue,用来定位对应数据类型的字段数据。

%title插图%num

 

Indexes透视表的字段说明如下:

  • OrgID:其所归属的应用对象所归属的租户OrgID
  • ObjID:字段所属应用对象唯一标识
  • FieldNum:对象字段存储位置
  • ObjInstanceGUID:对象实例唯一标识
  • StringValue:强类型的字符串列
  • NumValue:强类型的数字列
  • DateValue:强类型的日期列

下面的 Indexes 表示例包含对字符、数字和日期性数据的索引需求支持,数据来源于前面的 Data 表数据。

%title插图%num

%title插图%num

%title插图%num

Indexes 表的底层索引是标准的,采用非唯一性的数据库索引。当做对象检索查询的时候,实际上不是在Data数据表上做查询,而是在 Indexes 索引表上做的查询,获取到OrgID,ObjectID 以及 GUID,然后再返回数据表获取数据。也就是当系统查询条件包含对象实例的结构化的字段时,系统查询优化器采用 MT_Indexes 来帮助优化相关的数据访问操作。

2)Unique Indexes透 视表

由于 Data 数据表的多数据类型的无差别存储,无法在 Data 数据表建唯一性的索引供用户来使用对对象字段值进行唯一性校验。为了支持用户对象自定义字段的唯一性校验,解决办法是采用了 UniqueIndexes 透视表;这个表非常类似于 Indexes 表,不过 Uniqueindexes 采用底层原生的数据库索引来强制唯一性校验。当一个用户尝试来插入一个重复的值到具有唯一性约束的对象字段时,或者当用户尝试去在一个现存的包含唯一性的字段进行强制唯一性时,系统会给出唯一性校验失败的提示,阻止用户的下一步操作。

%title插图%num

Unique Indexes 透视表的核心字段说明如下:

  • UniqueStringValue:唯一的字符串列
  • UniqueNumValue:唯一的数字列
  • UniqueDateValue:唯一的日期列
  • 其他字段定义请参考 Indexes 透视表

3)Relationships 索引透视表

在元数据驱动的多租户模型中,提到了在 Objects 表以及 Fields 表中保存了用户对象结构和对象关系的定义,对象关系的定义是通过元数据模型 Fields 表字段数据类型提供了一个特殊的数据类型:“关系” (Relationship), 来给用户用于声明不同的用户应用对象之间的关系,也就是我们通常说引用完整性。

对象之间的引用关系定义以及对象实例间的引用关系存储在元数据表 Objects、Fields 中和 Data 表中,关联查询关系复杂,为了提升对象之间查询的效率,特别是通过对象相互引用关系对对象实例数据进行检索,系统提供关系索引透视表 Relationship 来优化对象引用关联查询。

%title插图%num

Relationships 索引透视表的字段说明如下:

  • OrgID:其所归属的应用对象所归属的租户 OrgID
  • ObjID:子对象的对象标识
  • GUID:子对象实例的唯一标识
  • RelationID:子对象内关系字段定义的标识
  • TargetObjInstanceID:父对象实例的唯一标识

关系透视表 Relationship 定义了两个底层数据库复合索引:

  • *个索引字段:OrgID + GUID,用于从子对象到父对象的关联查询。
  • 第二个索引字段:OrgID + ObjID + RelationID + TargetObjInstanceID,用于父对象到子对象的关联查询。

Relationships 索引透视表会在后面 SOQL 章节进行进一步描述验证。

4)其他索引透视表

其他索引透视表的逻辑类似,都是为了满足特定检索和查询需要,将数据同步到索引表,供应用系统使用。此处不再赘述,如确实有需要再补充。

五、SOQL 与关系 Relationships

SOQL 是 Salesforce Object Query Language 的简称,具有 SQL 类似的语法结构,就像前面提到的一样,Salesforce 是以应用对象(Salesforce Object,简称 SObject)的视角管理业务数据和功能,SOQL 类似对用于对应有对象数据进行查询的 API。

1、从SQL 到 SOQL

SOQL 也是采用类似表查询的结构,同 SQL 非常相似,也通过底层数据库索引来提供查询优化支撑。不同点如下:

  • 没有 select *
  • 没有视图概念
  • SOQL 是只读的
  • 由于底层元数据驱动的多租户数据模型的限制,索引是受限制的,没有原生数据库物理结构丰富的索引支持。
  • 对象到关系的映射 (Object-Relational Mapping) 是自动完成的.
  • SObjects 在多租户环境中并不是对应实际的物理数据表
  • SObjects 包括 SObjects 之间的关系都是以元数据的方式存储在多租户环境中的。

2、SOQL 示例&语法

下面我用示例来说明一下 SOQL 的用法,同时引出SOQL的特殊语法说明,SOQL 大小写不敏感。

1)单个对象的查询及语法说明

 

   select id,productno__c,name,productprice__c,productstatus__c from product__c

%title插图%num

前面提到过系统提供了标准应用对象和标准字段定义,更大的优势在于支持用户自行自定义对象和字段。这里__c 代表的使用户自定义的含义, product__c 代表的用户自定义对象 Product,而非系统标准对象和字段,系统标准对象和字段在 SOQL 无需__c 后缀,如ID,Name,CreatedBy 等字段则为系统提供给每个对象的标准字段,而字段 ProductNo 为用户自定义字段,则 SOQL 中的语法表示为 productno__c。这样的好处是讲标准和用户自定义对象和字段很容易区分开,系统可以定义标准 Product 对象,以 product 表示,用户也可以同样定义一个 Product 对象,不过 SOQL 用 product__c 表示用于区分。

2)子对象关联父对象 (Child to Parent) 查询及语法说明

select id,name,orderno__c,customer__c,customer__r.customerno__c,customer__r.name,orderstatus__c,ordertime__cfrom order__c order by orderno__c

%title插图%num

 

  1. select id,name,orderno__c,
  2. customer__c,customer__r.customerno__c,customer__r.name,orderstatus__c,ordertime__cfrom order__cwhere customer__r.name=‘Cheng Yan’order by orderno__c

%title插图%num

这里是从子对象 Order 关联到父对象 Customer 进行查询,其中:

  • from 后面的对象 order__c 表示 Order 为用于自定义对象
  • Id,name 为 Order 对象内系统定义的标准字段,
  • Orderno__c,customer__c,orderstatus__c,ordertime__c 为用户自定义字段,这里需要说明的是 customer__c 自定义字段存储的是父对象实例 ID
  • customer__r 就特别有意思,其中__r 部分代表父对象关系引用,customer 部分对应关系字段名,customer__r 代表从 Order 对象到 Customer 对象的一个应用关系,并通过 customer__r.customerno__c,customer__r.name 获取到 Customer 对象的字段值。

3)父对象关联子对象 (Parent to Child) 查询及语法说明

select id,orderno__c,customer__r.name,ordertime__c,orderstatus__c,(select id,product__r.productno__c,product__r.name,product__r.productprice__cfrom orderitem__r)from order__corder by orderno__c

%title插图%num

 

这个语句稍微有些复杂,从 Order 对象关联到 OrderItem 对象,又从 OrderItem 关联到 Product,同时还包含了 Order 对象到 Customer 对象的关联。

这里着重说一下从父对象到子对象的关联,父到子的关联是在父对象的主查询语句中在查询字段中用()来封装到子对象的关联,其中

  • 子句中 from orderitem__r 的 orderitem__r 代表的是对子对象 OrderItem 的引用,orderitem 对应的为前文关系字段中提到的 ChildRelationshipName,并且同一个父对象的子方的关系名称唯一(父对象 Name+ChildRelationshipName 必须唯一),用作父对象到子对象的查询关联。
  • 子句中 id,product__r.productno__c,product__r.name,product__r.productprice__c 的上下文为 orderitem__r 代表的子对象。

3、Relationships 索引透视表

Relationships 是为了 SOQL 的快速对象关联查询所定义的,子对象关联父对象( Child to Parent) 查询,复合索引(OrgID+GUID)在 Join 中起到较大作用,而需要从父对象关联子对象 (Parent to Child) 查询,则复合索引 (OrgID + ObjID + RelationID + TargetObjInstanceID) 在 Join 中起到较大作用。

六、如何支撑多租户巨大数据量

前面我们提到 Salesforce 一个共享数据库的概念,那一个共享数据库怎么来支撑如此巨大的多租户数据库呢,同时不仅需要支持巨量数据,并且还可以支撑租户间的数据物理隔离,保证各租户的数据稳定性、可用性和数据安全?

Salesforce 的做法是:分区。所有的 Force.com 的数据,元数据,透视表结构,包含底层数据库索引,都是通过对 OrgID 进行物理分区的,采用的是原生的数据库分区机制。所有的数据以及元数据通过你的 OrgID(16digits)进行分片 Hash。

数据分区是数据库系统提供的被验证过的技术,用以物理划分较大的逻辑数据结构到较小的可以管理的区块中。分区也可以帮助提升性能和扩展性,贴别是在多租户环境下一个巨大的数据系统的扩展性。根据定义,每一个 SOQL 的查询对应一个特别的租户信息,因此查询优化器,仅仅需要考虑访问包含对应租户的数据分区访问,而不是整个表或者索引。

七、无感的对象结构变更(No DDL)

当一个应用系统或者服务组件需要对其数据模型进行升级的时候,通常会通过数据库 DDL 语言对数据库物理结构进行操作,如果涉及的数据量较大,则可能会造成较长时间的数据库变更时效,造成对应时间内的系统不可用,如果是多租户系统还会可能其他租户的可用性造成影响,抑或造成诸多的底层模型不一致产生。

在元数据驱动的数据架构中,所有的 DDL 语言操作对应的使元数据层的元数据的记录的更新,不涉及数据库物理结构的更新,不会造成变更期间的数据库物理结构耗时调整造成的不可用,同时系统平台提供了一个高效的机制来减少对平台多租户应用总体性能影响。

当用户修改了一个表字段列的数据结构,从一种数据类型改成另外一种不同存储格式的数据类型时候,系统会重新分派一个新的弹性列给到这个字段列的数据,将数据从原来的存储弹性列批量拷贝到新的弹性列,然后才会更新此字段列的元数据,暨在 Fields 表中更新这个字段列的元数据,将数据类型更改为新的数据类型,并将 FieldNum 更新为新的 ValueX 列对应的X值。

同时,在如上对用户逻辑表结构调整生效过程中,原来的数据结构和对应的数据访问正常进行,直到逻辑表结构变更生效,对应用系统可用性不会造成影响,用户对此无感知。

八、多租户架构对于研发人员意味着什么

对于研发人员来说,多租户结构*多意味着两个版本:当前版本,以及下一个版本。没有遗留版本需要维护。所有人不用操心旧的技术,旧的版本,所有只有*新的版本,只需要关心*新的版本。

这样就给敏捷开发带来*大的好处,每年做个位的发布,每次发布几百个新的特性新的版本也不会改变用户的体验,新的特性可以根据用户需要开启,通过特性管理来开关。

新版本发布前,提供沙箱环境来允许用户提前试用新版本的系统。如果做 bug 修复,则是在所有租户层面上进行统一修复的。

对于用户应用的发布进行严格管理,防止对其他租户产生影响,通过提供沙箱环境来让用户验证新应用发布,并通过成千上万的自动化测试保证用户的正常功能。

在运行期间,不作任何底层 DDL 操作,不会做表的创建,也不会做表的变更,只可能在*少数的更新周期时候进行。

作者简介:程彦,曾就职于阿里数字供应链事业部担任多年供应链计划域研发,目前在阿里数据中台负责相关商业化产品开发。

a标签实现单文件和多文件下载

a标签实现单文件和多文件下载

*近的项目需要做单文件下载和多文件下载,经过努力终于实现了,想把实现的方法记录一下,分享给大家,希望对你有所帮助

%title插图%num

单文件下载
单个的文件下载只需要使用a标签将文件目标放在href中,然后添加download属性。

download 属性规定被下载的超链接目标。

在a标签中必须设置 href 属性。

该属性也可以设置一个值来规定下载文件的名称。所允许的值没有限制,浏览器将自动检测正确的文件扩展名并添加到文件 (.img, .pdf, .txt, .html, 等等)。

<a href=”文件目标” download=”自定义下载文件的名称”></a>
1
2
多文件下载
创建a标签模拟点击事件

downloadMulti (name, url) {
var aDom = document.createElement(‘a’)
var evt = document.createEvent(‘HTMLEvents’)
evt.initEvent(‘click’, false, false)
aDom.download = name
aDom.href = ‘/file/download?file_path=’ + url
aDom.dispatchEvent(evt)
aDom.click()
},

其中/file/download?file_path= 是后台写的一个服务,只需要把链接拼好赋值给a标签的href。然后调用 downloadMulti (name, url) 将文件名和链接传入就OK了

一次下载多个文件的解决思路-JS

真实经历

*近开发项目需要做文件下载,想想挺简单的,之前也做过,后台提供下载接口,前端使用window.location.href就行了呗。不过开发的时候发现,有些文件有附属文件,点击 下载按钮 需要下载两个文件,而且不能使用压缩包的形式。想想不是也挺简单,点击 下载 发送两个下载请求不就搞定了么。

说干就干,三下五除二就写好了,当点击 下载 的那一刻懵逼了, *个请求竟然自动Cancelled了,顿时一万个草泥马崩腾而过(因为是国外服务器,下载比较慢导致*个请求被Cancelled),这就意味着快速点击不同的 下载 按钮也会有同样的问题,这不行啊,然后开始了自己的下载探索之旅。


a标签 & location.href

我们知道a标签及href指向的如果是一个下载链接,那么相当于下载文件,对于单文件下载还是ok的,不过快速点击几个下载按钮,有的下载会被Cancelled,这可不行,继续百度。

上一段代码:

  1. const download = (url)=>{
  2. window.location.href = url;
  3. }

window.open

我们知道window.open可以打开一个新窗口,那么是不是可以实现下载呢,激动的我赶紧试了试,下载的确可以,不过会快速打开一个新窗口并且关闭,体验非常不好,果断放弃了。


iframe

突然想到iframe也可以向服务器发请求的,激动的我又赶紧试了试,哇塞,果然可以下载,而且没有违和感,代码贴出来。

  1. export const downloadFile = (url) => {
  2. const iframe = document.createElement(“iframe”);
  3. iframe.style.display = “none”; // 防止影响页面
  4. iframe.style.height = 0; // 防止影响页面
  5. iframe.src = url;
  6. document.body.appendChild(iframe); // 这一行必须,iframe挂在到dom树上才会发请求
  7. // 5分钟之后删除(onload方法对于下载链接不起作用,就先抠脚一下吧)
  8. setTimeout(()=>{
  9. iframe.remove();
  10. }, 5 * 60 * 1000);
  11. }

ps: iframe不会相互影响,可以连续下载哦!


其他方案

当然还有一些其他方式,Form下载、二进制流下载等,有空的小伙伴自行研究吧!

JavaScript 多文件下载

3.iframe方式 兼容多种浏览器

function IEdownloadFile(fileName, contentOrPath){
var ifr = document.createElement(‘iframe’);
ifr.style.display = ‘none’;
ifr.src = contentOrPath;
document.body.appendChild(ifr);
// 保存页面 -> 保存文件
ifr.contentWindow.document.execCommand(‘SaveAs’, false, fileName);
document.body.removeChild(ifr);
}

//图片类型保存

var isImg = contentOrPath.slice(0, 10) ===
“data:image”

// dataURL 的情况

isImg && ifr.contentWindow.document.write(
“<span style=”
font-family: Arial, Verdana, sans-serif;
“>”
);</span>

1.h5方式 模拟点击 不支持ie

function downloadFile(fileName, content){
var aLink = document.createElement(‘a’);
, blob = new Blob([content])
, evt = document.createEvent(“HTMLEvents”);

evt.initEvent(“click”);

aLink.download = fileName;
aLink.href = URL.createObjectURL(blob);
aLink.dispatchEvent(evt);
}
2.打开新窗口 模拟保存 不支持360 搜狗等安全浏览器

// 将文件在一个 window 窗口中打开,并隐藏这个窗口。
var win = window.open(“path/to/file.ext”, “new Window”, “width=0,height=0”);
// 在 win 窗口中按下 ctrl+s 保存窗口内容
win.document.execCommand(“SaveAs”, true, “filename.ext”);
// 使用完了,关闭窗口
win.close();

 

阿里云OSS文件下载功能简易实现

1.场景说明
将阿里云OSS文件下载至本地。

2.操作方法
提供2中文件下载方式:1、原生的输入输出流处理;2、commons-io-2.4-sources.jar包中的FileUtils.copyURLToFile现成方法处理。

2.1方式一
利用原生的输入输出流处理。

*步:获取OSS文件链接,读取输入流

// 截取片段代码
URL url = new URL(urllink);
//打开到此 URL 的连接并返回一个用于从该连接读入的 InputStream。
InputStream in = url.openStream();
String gaokuai_url = HttpUpload.uploadOSS(in, fileName);

// logger.info(“FeedbackServiceImpl.downloadFilesFromUrl gaokuai_url:” + gaokuai_url);
if(!gaokuai_url.equals(“”)){
if(sb.length() >0){
sb.append(tag + gaokuai_url);
}else{
sb.append(gaokuai_url);
}
}

第二步:文件下载

public static void download(InputStream inputStream){

try {
File file = new File(“/Users/loongshawn/Downloads/Penguins.jpg”);
OutputStream outputStream = new FileOutputStream(file);

int byteCount = 0;
//1M逐个读取
byte[] bytes = new byte[1024*1024];
while ((byteCount = inputStream.read(bytes)) != -1){
outputStream.write(bytes, 0, byteCount);
}

inputStream.close();
outputStream.close();
} catch (FileNotFoundException e){
e.printStackTrace();
} catch (IOException e){
e.printStackTrace();
}
}

2.2方式二
利用到了commons-io-2.4-sources.jar包中的FileUtils.copyURLToFile方法。

public static void method2(String urllink){

try {
URL httpurl = new URL(urllink);
File file = new File(“/Users/loongshawn/Downloads/Penguins.jpg”);
FileUtils.copyURLToFile(httpurl, file);
} catch (MalformedURLException e){
e.printStackTrace();
} catch (IOException e){
e.printStackTrace();
}
}

3.效果展示
测试demo如下,提供了两种文件下载的方式,上述两种方式均测试可用,但有一点需要说明,OSS文件有访问权限设置,本例中的OSS没有设置访问限制。

public static void main(String[] args){

System.out.println(“HelloWorld!”);

String urllink = “http://XXXX/attachment/201704/06/20170406163542/20170406Penguins.jpg”;

method1(urllink);
method2(urllink);
}

public static void method1(String urllink){

try {
URL url = new URL(urllink);
//打开到此 URL 的连接并返回一个用于从该连接读入的 InputStream。
InputStream in = url.openStream();
download(in);

} catch (IOException e){

}
}

public static void method2(String urllink){

try {
URL httpurl = new URL(urllink);
File file = new File(“/Users/loongshawn/Downloads/Penguins.jpg”);
FileUtils.copyURLToFile(httpurl, file);
} catch (MalformedURLException e){
e.printStackTrace();
} catch (IOException e){
e.printStackTrace();
}

}

下载结果:

%title插图%num

从阿里云上下载文件

@RequestMapping(value = “downloadDatumInfo”, method = {RequestMethod.POST})
public void downloadDatumInfo(@RequestParam(value=”parmData”) String parmData,HttpServletRequest request, HttpServletResponse response) throws IOException{
Map<String, Object> map = CommonUtil.jsonToObject(parmData);
request.setCharacterEncoding(“utf-8”);
response.setCharacterEncoding(“utf-8”);
String fileName = (String) map.get(“fileName”);
String filePath = (String) map.get(“filePath”);
response.setHeader(“Content-Disposition”, “attachment;filename=” + URLEncoder.encode(fileName,”utf-8″));
String companyCode = filePath.substring(42, 45);
String diskName = “yunsuo”+companyCode+”/”;
String key = filePath.substring(filePath.lastIndexOf(“/”)+1,filePath.length());
OSSClient ossClient = new OSSClient(aliyunOssConfig.getEndPoint(), aliyunOssConfig.getAccessKeyId(), aliyunOssConfig.getAccessKeySecret());
InputStream in = OSSUnit.getOSS2InputStream(ossClient, “yunsuo”, diskName, key);
byte[] buff = new byte[1024];
BufferedInputStream bis = null;
OutputStream os = null;
try {
os = response.getOutputStream();
bis =new BufferedInputStream(in);
int len = 0;
while ((len = in.read(buff)) != -1) {
os.write(buff, 0, len);
}
os.flush();
os.close();
in.close();
} catch (Exception e) {
e.printStackTrace();
}
}

/**
* 根据key获取OSS服务器上的文件输入流
*
* @param client
* OSS客户端
* @param bucketName
* bucket名称
* @param diskName
* 文件路径
* @param key
* Bucket下的文件的路径名+文件名
*/
public static InputStream getOSS2InputStream(OSSClient client, String bucketName, String diskName, String key) {
OSSObject ossObj = client.getObject(bucketName, diskName + key);
return ossObj.getObjectContent();
}

除了 Docker,我们还有哪些选择?

所谓三十年河东,三十年河西,曾经在容器领域叱咤风云的 Docker 如今已风光不再。抛开情怀,我们不得不承认,Docker 已经被后浪拍死在沙滩上了……

大约 4 年前的容器领域,Docker 是唯一的选择。

然而,如今情况已然大不同,Docker 不再是是唯一的选择,它只不过是一个容器引擎而已。我们可以用 Docker 构建、运行、拉取、推送或检查容器镜像,但是这里的每一项任务,都可以用其他工具替代,甚至有些工具比 Docker 还好。

所以,下面就让我们来探索一下这个领域,然后卸载和忘记 Docker 吧。

%title插图%num

为什么说不要用 Docker 了?

如果长期以来你一直在使用 Docker,那么说服你考虑其他工具可能需要多费点唇舌。

首先,Docker 是一个整体化的工具,它试图做好所有的事情,但往往只会适得其反。在大多数情况下,我们应该选择专门的工具,它可能只做一件事情,但会做到*好。

可能你因为担心需要学习使用不同的 CLI、不同的 API 或接受不同的概念,所以会害怕使用其他工具。但是,请不用担心。本文介绍的任何工具都可以完美地无缝衔接,因为它们(包括 Docker)都遵循同一个 OCI(OpenContainer Initiative,开放容器计划)规范。OCI 包括容器运行时、容器分发和容器镜像的规范,涵盖了使用容器所需的所有功能。

因为有了 OCI,所以你可以自由选择适合自己的需求的工具,与此同时,你可以继续使用与 Docker 相同的 API 和 CLI 命令。

因此,如果你愿意尝试新工具,那么我们就来比较一下 Docker 与其竞争对手的优缺点和功能,看看是否有必要考虑放弃 Docker,并尝试使用一些新鲜出炉的工具。

%title插图%num

容器引擎

 

在比较 Docker 与其他工具时,我们需要分别讨论它的各个组件,首先要讨论的就是容器引擎。

容器引擎是一种工具,它提供了处理镜像与容器的用户界面,这样你就不需要与 SECCOMP 规则或 SELinux 策略苦苦纠缠了。除此之外,容器引擎还可以从远程仓库提取镜像,并将其解压到本地磁盘上。它似乎也运行容器,但是实际上,它的工作是创建容器清单以及镜像层的目录。接着,它将这些文件传递给 runc 或 crun 等容器运行时。

目前有很多容器引擎可供我们使用,不过 Docker *主要的竞争对手是红帽开发的 Podman。与 Docker 不同,Podman 不需要运行守护进程,也不需要 root 特权,这些都是 Docker 长期以来一直备受关注的问题。从名字就可以看出来,Podman 不仅可以运行容器,还可以运行 pod。

如果你不熟悉 pod 的话,我可以简单介绍一下:pod 是 Kubernetes 的*小计算单元,由一个或多个容器 (主容器与负责支持主容器的 sidercar 容器) 组成。因此,Podman 用户以后可以很轻松地将他们的工作负载迁移到 Kubernetes。

下面,我们通过一个简单的演示来说明如何在一个 Pod 中运行两个容器:

~ $ podman pod create --name mypod~ $ podman pod listPOD ID        NAME    STATUS    CREATED         # OF CONTAINERS   INFRA ID211eaecd307b  mypod   Running   2 minutes ago   1                 a901868616a5 ~ $ podman run -d --pod mypod nginx  # First container~ $ podman run -d --pod mypod nginx  # Second container~ $ podman ps -a --pod CONTAINER ID IMAGE                          COMMAND               CREATED        STATUS            PORTS  NAMES               POD           POD NAME3b27d9eaa35c  docker.io/library/nginx:latest  nginx -g daemon o...  2 seconds ago Up 1 second ago         brave_ritchie      211eaecd307b  mypodd638ac011412 docker.io/library/nginx:latest nginx -g daemon o...  5 minutesago  Up 5 minutes ago         cool_albattani      211eaecd307b mypoda901868616a5 k8s.gcr.io/pause:3.2                                  6 minutesago  Up 5 minutes ago         211eaecd307b-infra  211eaecd307b mypod

*后一点,Podman 提供的 CLI 命令与 Docker 完全相同,因此你只需执行

alias docker=podman

然后就像什么都没有发生过一样。

除了 Docker 和 Podman 之外,还有其他容器引擎,但我并不看好它们的发展,或者不适合用于本地开发。

不过,如果你想对容器引擎有一个较为完整的了解,我也可以介绍一些:

  • LXD:LXD 是 LXC(Linux 容器)的容器管理器(守护进序)。这个工具提供了运行系统容器的能力,而这些系统容器提供了类似于虚拟机的容器环境。该工具比较小众,没有太多用户,所以除非你有非常特殊的用例,否则*好还是使用 Docker 或 Podman。
  • CRI-O:如果在网上搜索 cri-o 是什么,你可能会发现它被描述成了一种容器引擎。但实际上,它是一种容器运行时。它既不是容器引擎,也不适合“常规”使用。我的意思是说,它是专门作为 Kubernetes 运行时(CRI)而创建的,并不是给*终用户使用的。
  • rkt:rkt(读作“rocket”)是 CoreOS 开发的容器引擎。这里提到这个项目只是为了清单的完整性,因为这个项目已经结束了,它的开发也停止了,因此你不应该再使用它。

%title插图%num

构建镜像

 

对于容器引擎,实际上 Docker 的替代品只有一种选择(即 Podman)。但是,在构建镜像方面,我们有很多选择。

首先,我们来看一看 Buildah。这也是一款红帽开发的工具,可以很好地与 Podman 协同工作。如果你已经安装了 Podman,可能会注意到 podman build 子命令,因为它的二进制文件已经包含在 Podman 中了,实际上这个命令只是经过包装的 Buildah。

至于功能,Buildah 沿用了 Podman 的方针:没有守护进程,不需要 root 特权,而且生成的是符合 OCI 的镜像,因此你的镜像的运行方式与使用 Docker 构建的镜像完全相同。它还能使用 Dockerfile 或 Containerfile 构建镜像, Dockerfile 与 Containerfile 实际上是同一个东西,只是叫法不同罢了。除此之外,Buildah 还提供了对镜像层更精细的控制,支持提交大量的变更到单个层。我认为,它与 Docker 之间有一个出乎意料的区别(但这个区别是好事),那就是使用 Buildah 构建的镜像特定于用户,因此你可以只列出自己构建的镜像。

你可能会问,既然 Podman CLI 中已经包含了 Buildah,为什么还要使用单独的 Buildah CLI 呢?其实,Buildah CLI 是 podman build 所包含的命令的超集,因此你可能不需要直接使用 BuildahCLI,但是通过使用它,你可能会发现一些额外的功能。

下面,我们来看一个示例:

~ $ buildah bud -f Dockerfile . ~ $ buildah from alpine:latest  # Create starting container - equivalent to"FROM alpine:latest"Getting image source signaturesCopying blob df20fa9351a1 doneCopying config a24bb40132 doneWriting manifest to image destinationStoring signaturesalpine-working-container # Name of the temporary container~ $ buildah run alpine-working-container -- apk add--update --no-cache python3  # equivalentto "RUN apk add --update --no-cache python3"fetchhttp://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gzfetchhttp://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz... ~ $ buildah commit alpine-working-containermy-final-image  # Create final imageGetting image source signaturesCopying blob 50644c29ef5a skipped: already existsCopying blob 362b9ae56246 doneCopying config 1ff90ec2e2 doneWriting manifest to image destinationStoring signatures1ff90ec2e26e7c0a6b45b2c62901956d0eda138fa6093d8cbb29a88f6b95124c ~ # buildah imagesREPOSITORY              TAG     IMAGE ID      CREATED         SIZElocalhost/my-final-image latest  1ff90ec2e26e 22 seconds ago  51.4 MB

从上面的脚本可以看出,你可以直接使用 buildah bud 构建镜像,其中 bud 代表使用 Dockerfile 进行构建,你也可以使用其他脚本化的方法,比如使用 Buildahs 的 from、run 和 copy,它们分别对应 Dockerfile 中的 FROM、RUN、COPY 命令。

接下来是 Google 的 Kaniko。Kaniko 也是利用 Dockerfile 构建容器镜像,而且与 Buildah 类似,它也不需要守护进程。但它与 Buildah 的主要区别在于,Kaniko 更加侧重于 Kubernetes 中的镜像构建。

Kaniko 本身也要作为镜像(gcr.io/kaniko-project/executor) 运行,这对于Kubernetes 来说是没有问题的,但对于本地构建来说不是很方便,并且在某种程度上违背了构建镜像的目的,因为你需要使用 Docker 运行 Kaniko 镜像才能构建镜像。话虽如此,如果你正在寻找在 Kubernetes 集群中构建镜像的工具 (例如在 CI/CD 管道中),那么 Kaniko 可能是一个不错的选择,因为它不需要守护进程,而且更安全。

以我个人的经验来看,我认为两者都能很好地完成工作,但是使用 Kaniko 时,我遇到了一些随机的构建故障,而且在将镜像推送到仓库时也出现了失败的情况。

我要介绍的第三个工具是 buildkit,也可以称之为 docker build 二代。它是 Moby 项目的一部分(与 Docker一样),只需设置 DOCKER_BUILDKIT=1 docker build,就可以启动这个工具,并作为 Docker 的一个实验性功能使用。那么,这个工具究竟能给你带来什么?它带来了很多改进和很酷的功能,包括并行构建、跳过未使用的阶段、更好的增量构建以及不需要 root 权限等构建。但是,它仍然需要运行守护进程 (buildkitd)。因此,如果你不想摆脱 Docker,同时又想要一些新的功能和改进,那么可以考虑一下 buildkit。

这里,我也会列出一些其他的工具,它们有各自的特定用途,但不是我的首选:

  • Source-To-Image(S2I):这是一个不使用 Dockerfile,直接根据源代码构建镜像的工具包。这个工具在简单的预期场景和工作流中表现良好,但如果你需要多一些自定义,如果你的项目的结构不符合预期,那么它就变得非常烦人和笨拙。如果你对 Docker 不太满意,或者你在 OpenShift 集群上构建镜像,则可以考虑使用 S2I,因为使用 S2I 构建镜像是它的一个内置功能。
  • Jib:这是一款由 Google 开发的工具,专门用于构建 Java 镜像。它提供了 Maven 和 Gradle 插件,可以让你轻松地构建镜像,而无需在意 Dockerfile。
  • Bazel:这也是一款由 Google 开发的工具。它不仅可用于构建容器镜像,而且是一个完整的构建系统。如果你只是想构建镜像,那么使用 Bazel 可能会有点大材小用,但*对是一种不错的学习体验,如果你愿意,可以先从 rules_docker 着手。

%title插图%num

容器运行时

 

*后我们来说说负责运行容器的容器运行时。容器运行时是整个容器生命周期的一部分,除非你对速度、安全性等有一些非常特殊的要求,否则请不要乱动它。

看到这里,如果你感到厌倦了,则可以跳过这一部分。但是,如果你想了解一下在容器运行时方面,都有哪些选择,则可以看看下面这些:

runc 是一款流行的容器运行时,且符合 OCI 容器运行时规范。Docker(通过containerd)、Podman 和 CRI-O 都在使用它,因此无需我多言。它几乎是所有容器引擎的默认设置,因此即便你在阅读本文后抛弃了 Docker,很可能仍然会使用 runc。

runc 的另一种替代品是 crun。这是一款由红帽开发的工具,全部用 C 语言编写(runc 是用 Go 编写的),所以它比 runc 更快,内存效率更高。由于它也是兼容 OCI 的运行时,所以如果你想试试看的话,应该能很快上手。虽然它现在还不是很流行,但是它即将作为 RHEL 8.3 版本的备选 OCI 运行时,出现在技术预览中,而且考虑到它是红帽的产品,所以*终很可能会成为 Podman 或 CRI-O 的默认配置。

说到 CRI-O,前面我说过,它并不是容器引擎,而是容器运行时。这是因为 CRI-O 没有推送镜像之类的功能,但这些功能是容器引擎应该具备的。CRI-O 内部使用 runc 来运行容器。你不应该在自己的机器上尝试使用这个运行时,因为它的设计就是 Kubernetes 节点上的运行时,而且它是“Kubernetes 所需的唯一的运行时”。因此,除非你要建立 Kubernetes 集群,否则就不应该考虑 CRI-O。

*后一个是 containerd,它是云原生计算基金会即将推出的一个项目。它是一个守护进程,可作为各种容器运行时和操作系统的 API 接口。它后台依赖于 runc,它是 Docker 引擎的默认运行时。Google Kubernetes Engine(GKE)和 IBM Kubernetes Service(IKS)也在使用它。它是 Kubernetes容器运行时接口的一个实现(与 CRI-O 一样),因此是 Kubernetes 集群运行时的理想选择。

%title插图%num

镜像的检查与分发

 

*后一部分内容是镜像的检查与分发,主要是为了替代 docker inspect,并增加在远程仓库之间复制镜像的能力(可选)。

在这里,我要提到的唯一可以完成这些任务的工具是 Skopeo。它由红帽开发,是 Buildah、Podman 和 CRI-O 的附属工具。除了基本的 skopeo inspect(Docker 有相应的命令),Skopeo 还可以通过 skopeo copy 令来复制镜像,因此你可以直接在远程仓库之间复制镜像,无需将它们拉取到本地。如果你使用本地仓库,那么这个功能也可以作为拉取/推送。

另外,我还想提一下 Dive,这是一款检查、探索和分析镜像的工具。它更加人性化,提供了更加方便阅读的输出,而且还可以更深入地挖掘镜像,并分析和测量镜像的效率。此外,它也很适合在 CI 管道中使用,用于衡量你的镜像是否“足够高效”,或者换句话说,是否浪费了太多空间。

看穿容器的外表,Linux容器实现原理演示

容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”也就是独立的“运行环境”。下面我们使用 C 语言和 Namespace 技术来手动创建一个容器,演示 Linux 容器*基本的实现原理。

什么是容器?容器其实是一种特殊的进程而已,只是这个进程运行在自己的 “运行环境” 中,比如有自己的文件系统而不是使用主机的文件系统(文件系统这个对我来说印象是*深刻的,也是让人对容器很更好理解的一个切入点)。

有一个计算数值总和的小程序,这个程序的输入来自一个文件,计算完成后的结果则输出到另一个文件中。为了让这个程序可以正常运行,除了程序本身的二进制文件之外还需要数据,而这两个东西放在磁盘上,就是我们平常所说的一个“程序”,也就是代码的可执行镜像。

当“程序”被执行起来之后,它就从磁盘上的二进制文件变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是进程,而计算机执行环境的总和就是它的动态表现。

而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”也就是独立的“运行环境”。那么怎么去造成这个边界呢?

  • 对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段;
  • Namespace 技术则是用来修改进程视图的主要方法;

下面我们使用 C 语言和 Namespace 技术来手动创建一个容器,演示 Linux 容器*基本的实现原理。

%title插图%num

自己实现一个容器

Linux 中关于 Namespace 的系统调用主要有这么三个:

  • clone()—实现线程的系统调用,用来创建一个新的进程,同时可以设置 Namespace 的一些参数。
  • unshare()—使某个进程脱离某个 namespace。
  • setns()—把某进程加入到某个 namespace。

我们使用 clone 来创建一个子进程,通过创建出来的效果可以看到,子进程的 PID 是跟在父亲节点后面的,而不是 1。

  1. 1#define _GNU_SOURCE
  2. 2#include <sys/types.h>
  3. 3#include <sys/wait.h>
  4. 4#include <sys/mount.h>
  5. 5#include <stdio.h>
  6. 6#include <sched.h>
  7. 7#include <signal.h>
  8. 8#include <unistd.h>
  9. 9
  10. 10#define STACK_SIZE (1024 * 1024)
  11. 11static char container_stack[STACK_SIZE];
  12. 12
  13. 13char* const container_args[] = {
  14. 14    “/bin/bash”,
  15. 15    NULL
  16. 16};
  17. 17
  18. 18int container_main(void* arg) {
  19. 19    printf(“Container [%5d] – inside the container!\n”, getpid());
  20. 20    execv(container_args[0], container_args);
  21. 21    printf(“Something’s wrong!\n”);
  22. 22    return 1;
  23. 23}
  24. 24
  25. 25int main() {
  26. 26    printf(“Parent [%5d] – start a container!\n”, getpid());
  27. 27    int container_id = clone(container_main, container_stack + STACK_SIZE, SIGCHLD, NULL);
  28. 28    waitpid(container_id, NULL, 0);
  29. 29    printf(“Parent – container stopped!\n”);
  30. 30    return 0;
  31. 31}

%title插图%num

接下去这段代码我们给创建出来的进程设置 PID namespace 和 UTS namespace。从实际的效果我们可以看到子进程的 pid 为 1,而子进程中打开的 bash shell 显示的主机名为 container_dawn。是不是有点容器那味了?这里子进程在自己的 PID Namespace 中的 PID 为 1,因为 Namespace 的隔离机制,让这个子进程误以为自己是第 1 号进程,相当于整了个障眼法。但是,实际上这个进程在宿主机的进程空间中的编号不为 1,是一个真实的数值,比如 14624。

  1. 1int container_main(void* arg) {
  2. 2    printf(“Container [%5d] – inside the container!\n”, getpid());
  3. 3    sethostname(“container_dawn”15);
  4. 4    execv(container_args[0], container_args);
  5. 5    printf(“Something’s wrong!\n”);
  6. 6    return 1;
  7. 7}
  8. 8
  9. 9int main() {
  10. 10    printf(“Parent [%5d] – start a container!\n”, getpid());
  11. 11    int container_id = clone(container_main, container_stack + STACK_SIZE,
  12. 12                                CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
  13. 13    waitpid(container_id, NULL, 0);
  14. 14    printf(“Parent – container stopped!\n”);
  15. 15    return 0;
  16. 16}

%title插图%num

*后我们改变一下这个进程可以看到的文件系统,我们首先使用 docker export 将 busybox 镜像导出成一个 rootfs 目录,这个 rootfs 目录的情况如图所示,已经包含了 /proc、/sys 等特殊的目录。

%title插图%num

接下去,我们在代码中使用 chroot() 函数将创建出来的子进程的根目录改变成上述的 rootfs 目录。从实现的效果来看,创建出来的子进程的 PID 为 1,并且这个子进程将上述提到的 rootfs 目录当成了自己的根目录。

  1. 1char* const container_args[] = {
  2. 2    “/bin/sh”,
  3. 3    NULL
  4. 4};
  5. 5
  6. 6int container_main(void* arg) {
  7. 7    printf(“Container [%5d] – inside the container!\n”, getpid());
  8. 8
  9. 9    if (chdir(“./rootfs”) || chroot(“./”) != 0) {
  10. 10        perror(“chdir/chroot”);
  11. 11    }
  12. 12
  13. 13    execv(container_args[0], container_args);
  14. 14    printf(“Something’s wrong!\n”);
  15. 15    return 1;
  16. 16}
  17. 17
  18. 18int main() {
  19. 19    printf(“Parent [%5d] – start a container!\n”, getpid());
  20. 20    int container_id = clone(container_main, container_stack + STACK_SIZE,
  21. 21                                CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
  22. 22    waitpid(container_id, NULL, 0);
  23. 23    printf(“Parent – container stopped!\n”);
  24. 24    return 0;
  25. 25}

%title插图%num

需要注意的是所使用的 shell 需要改一下,因为 busybox 中没有 /bin/bash,假如还是 /bin/bash 的话是会报错的,因为 chroot 改变子进程的根目录视图之后,*终是从 rootfs/bin/  中找 bash 这个程序的。

上面其实已经基本实现了一个容器,接下去我们实现一下 Docker 卷的基本原理(假设你已经知道卷是什么了)。在代码中,我们将 /tmp/t1 这个目录挂载到 rootfs/mnt 这个目录中,并采用 MS_BIND 的方式,这种方式使得 rootfs/mnt (进入容器之后就是 mnt 目录)的视图其实就是 /tmp/t1 的视图,你对 rootfs/mnt 的修改其实就是对 /tmp/t1 修改,rootfs/mnt 相当于 /tmp/t1 的另一个入口而已。当然,在实验之前,你先确保 /tmp/t1 和 rootfs/mnt 这两个目录都已经被创建好了。实验效果见代码之后的那张图。

  1. 1char* const container_args[] = {
  2. 2    “/bin/sh”,
  3. 3    NULL
  4. 4};
  5. 5
  6. 6int container_main(void* arg) {
  7. 7    printf(“Container [%5d] – inside the container!\n”, getpid());
  8. 8
  9. 9    /*模仿 docker 中的 volume*/
  10. 10    if (mount(“/tmp/t1”“rootfs/mnt”“none”, MS_BIND, NULL)!=0) {
  11. 11        perror(“mnt”);
  12. 12    }
  13. 13
  14. 14    /* 隔离目录 */
  15. 15    if (chdir(“./rootfs”) || chroot(“./”) != 0) {
  16. 16        perror(“chdir/chroot”);
  17. 17    }
  18. 18
  19. 19    execv(container_args[0], container_args);
  20. 20    printf(“Something’s wrong!\n”);
  21. 21    return 1;
  22. 22}
  23. 23
  24. 24int main() {
  25. 25    printf(“Parent [%5d] – start a container!\n”, getpid());
  26. 26    int container_id = clone(container_main, container_stack + STACK_SIZE,
  27. 27                                CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
  28. 28    waitpid(container_id, NULL, 0);
  29. 29    printf(“Parent – container stopped!\n”);
  30. 30    return 0;
  31. 31}

%title插图%num

除了上述所使用的 PID、UTS、Mount namespace,Linux 操作系统还提供了 IPC、Network 和 User 这些 Namespace。

%title插图%num

总结

通过上面我们可以看到,容器的创建和普通进程创建没什么区别。都是父进程先创建一个子进程,只是对于容器来说,这个子进程接下去通过内核提供的隔离机制再给自己创建一个独立的资源环境。

同理,在使用 Docker 的时候,其实也并没有一个真正的 Docker 容器运行在宿主机里面。Docker 项目启动还是用户原来的应用进程,只是在创建进程的时候,Docker 为这个进程指定了它所需要启用的一组 Namespace 参数。这样,这个进程只能“看”到当前 Namespace 所限定的资源、文件、设备、状态或者配置。而对于宿主机以及其他不相关的程序,这个进程就完全看不到了。这时,进程就会以为自己是 PID Namespace 里面 1 号进程,只能看到各自 Mount Namespace 里面挂载的目录和文件,只能访问 Network Namespace 里的网络设备。这种就使得进程运行在一个独立的“运行环境”里,也就是容器里面。

因此,对接一开始所说的,还想再唠叨一句:容器其实就是一种特殊的进程而已。**只是这个进程和它运行所需的所有资源都打包在了一起,进程执行时所使用的资源也都是打包中的。相比虚拟机的方式,本质是进程的容器则仅仅是在操作系统上划分出了不同的“运行环境”,从而使得占用资源更少,部署速度更快。

友情链接: SITEMAP | 旋风加速器官网 | 旋风软件中心 | textarea | 黑洞加速器 | jiaohess | 老王加速器 | 烧饼哥加速器 | 小蓝鸟 | tiktok加速器 | 旋风加速度器 | 旋风加速 | quickq加速器 | 飞驰加速器 | 飞鸟加速器 | 狗急加速器 | hammer加速器 | trafficace | 原子加速器 | 葫芦加速器 | 麦旋风 | 油管加速器 | anycastly | INS加速器 | INS加速器免费版 | 免费vqn加速外网 | 旋风加速器 | 快橙加速器 | 啊哈加速器 | 迷雾通 | 优途加速器 | 海外播 | 坚果加速器 | 海外vqn加速 | 蘑菇加速器 | 毛豆加速器 | 接码平台 | 接码S | 西柚加速器 | 快柠檬加速器 | 黑洞加速 | falemon | 快橙加速器 | anycast加速器 | ibaidu | moneytreeblog | 坚果加速器 | 派币加速器 | 飞鸟加速器 | 毛豆APP | PIKPAK | 安卓vqn免费 | 一元机场加速器 | 一元机场 | 老王加速器 | 黑洞加速器 | 白石山 | 小牛加速器 | 黑洞加速 | 迷雾通官网 | 迷雾通 | 迷雾通加速器 | 十大免费加速神器 | 猎豹加速器 | 蚂蚁加速器 | 坚果加速器 | 黑洞加速 | 银河加速器 | 猎豹加速器 | 海鸥加速器 | 芒果加速器 | 小牛加速器 | 极光加速器 | 黑洞加速 | movabletype中文网 | 猎豹加速器官网 | 烧饼哥加速器官网 | 旋风加速器度器 | 哔咔漫画 | PicACG | 雷霆加速