超大只乌龟

超大只乌龟 查看完整档案

深圳编辑广东科技学院  |  软件工程 编辑码畜  |  后端开发 编辑 segmentfault.com/u/chenwugui/articles 编辑
编辑

区区码农

个人动态

超大只乌龟 发布了文章 · 1月25日

啃碎JDK源码(八):ReentrantLock

前言

上一次我们已经讲了AQS,如果对其不熟悉的话建议先去看看其实现原理,看完再来看ReentrantLock就很简单了。

啃碎JDK源码(一):String
啃碎JDK源码(二):Integer
啃碎JDK源码(三):ArrayList
啃碎JDK源码(四):HashMap
啃碎JDK源码(五):ConcurrentHashMap
啃碎JDK源码(六):LinkedList
啃碎JDK源码(七):AbstractQueuedSynchronizer(AQS)

ReentrantLockSynchornized 在面试中经常被用来比较,如果想了解Synchronized的话可以看我另外一篇文章:死磕Synchronized

正文

先来了解一下一些核心属性:

public class ReentrantLock implements Lock, java.io.Serializable {
// 实现AQS的内部类
private final Sync sync;
  ......
}

没错,ReentrantLock没有什么值得注意的属性,因为已经在AQS中定义好了,我们只需要继承它然后进行简单的实现即可。

先看下 ReentrantLock 的用法:

  public static void main(String[] args) {
        Lock lock = new ReentrantLock();
        lock.lock();
        try {
            // 执行业务
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

只要调用 lock 方法就可以进行加锁操作,表示接下来的这段代码已经被当前线程锁住,其他线程需要执行时需要拿到这个锁才能执行,而当前线程在执行完之后要显式的调用 unlock 释放锁。

注意:看源码之前你必须要对AQS比较熟悉才行,可以参考我上一篇博客:
啃碎JDK源码(七):AbstractQueuedSynchronizer(AQS)

我们来跟进源码看一下,先来看我们的加锁lock方法:

  public void lock() {
      sync.lock();
  }
  
  // Sync继承了AQS
  abstract static class Sync extends AbstractQueuedSynchronizer {
  
     abstract void lock();
     ......
  }
  

可以看到是调用内部类的lock方法,而它是一个抽象方法,我们看下谁继承了这个抽象接口:

image.png

FairSyncNonfairSyncReentrantLock 的另外两个内部类。顾名思义一个是公平锁,一个是非公平锁。(公平锁就是永远都是队列的第一位才能得到锁

AQS有一个同步队列(CLH),是一种先进先出队列。公平锁的意思就是严格按照这个队列的顺序来获取锁,非公平锁的意思就是不一定按照这个队列的顺序来。

在new对象的时候便会对sync初始化,如下:

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

可以看出默认是非公平锁,如果传true则初始化为公平锁。

那我们首先来看看非公平锁:

  static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        final void lock() {
            // CAS修改状态
            if (compareAndSetState(0, 1))
                // 设置独占线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
                // 进入队列等待
                acquire(1);
        }
        // tryAcquire是AQS的抽象方法,我们这里对其实现
        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }    

首先用 compareAndSetState 方法使用CAS修改state状态变量的值,如果修改成功的话使用 setExclusiveOwnerThread(Thread.currentThread()) 方法将当前线程设置为独占锁的持有线程,否则调用AQS的 acquire 方法进去队列等待处理。

接下来看一下acquire方法:

   public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

该方法是AQS里的方法,我们上次已经介绍过了,这里直接截过来看下:

image.png

这次我们主要关注由子类ReentrantLock实现的tryAcquire方法:

image.png

    protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
     }
     
     final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            // 如果锁处于空闲状态
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    // 设置当前线程为获取独占锁的线程
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                // 当前线程已经持有了锁(可重入)
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                // 直接修改state遍历,因为已经持有锁,不需要用CAS去修改
                setState(nextc);
                return true;
            }
            return false;
        }

上面代码和我们在上次手动实现一个可重入锁的代码差不多,这里就不再展开。

那接下来看一下 unlock 方法:

   public void unlock() {
       sync.release(1);
   }
   
   public final boolean release(int arg) {
        // 尝试释放锁
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

release 方法在AQS类中定义好了,我们子类主要实现 tryRelease 方法:

    protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            // 在释放锁资源之前要先判断当前线程是否还持有锁
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

这段代码上篇文章我们也已经讲过了,如果忘记的同学可以回头看看。

看完非公平锁的最后来看看公平锁的加锁方法:

     protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

其实代码基本和前面一样,只是多了hasQueuedPredecessors方法用来判断是否存在比等待更久的线程,因为要按照等待时间顺序获取资源,其它的这里就不再细说了。

其它疑问

以下问题来自 从源码角度理解ReentrantLock

为什么基于FIFO的同步队列可以实现非公平锁?

由FIFO队列的特性知,先加入同步队列等待的线程会比后加入的线程更靠近队列的头部,那么它将比后者更早的被唤醒,它也就能更早的得到锁。从这个意义上,对于在同步队列中等待的线程而言,它们获得锁的顺序和加入同步队列的顺序一致,这显然是一种公平模式。然而,线程并非只有在加入队列后才有机会获得锁,哪怕同步队列中已有线程在等待,非公平锁的不公平之处就在于此。回看下非公平锁的加锁流程,线程在进入同步队列等待之前有两次抢占锁的机会:

  • 第一次是使用compareAndSetState方法尝试修改state变量,只有在当前锁未被任何线程占有(包括自身)时才能成功。
  • 第二次是在进入同步队列前使用tryAcquire(arg)尝试获取锁。

只有这两次获取锁都失败后,线程才会构造结点并加入同步队列等待。而线程释放锁时是先释放锁(修改state值),然后才唤醒后继结点的线程的。试想下这种情况,线程A已经释放锁,但还没来得及唤醒后继线程C,而这时另一个线程B刚好尝试获取锁,此时锁恰好不被任何线程持有,它将成功获取锁而不用加入队列等待。线程C被唤醒尝试获取锁,而此时锁已经被线程B抢占,故而其获取失败并继续在队列中等待。
那我们在开发中为什么大多使用非公平锁?很简单,因为它性能好啊。

为什么非公平锁性能好

  1. 线程不必加入等待队列就可以获得锁,不仅免去了构造结点并加入队列的繁琐操作,同时也节省了线程阻塞唤醒的开销,线程阻塞和唤醒涉及到线程上下文的切换和操作系统的系统调用,是非常耗时的。。
  2. 减少CAS竞争。如果线程必须要加入阻塞队列才能获取锁,那入队时CAS竞争将变得异常激烈,CAS操作虽然不会导致失败线程挂起,但不断失败重试导致的对CPU的浪费也不能忽视。

总结

有关 ReentrantLock的知识就介绍到这里了,有什么不对的地方请多多指教。

image.png

查看原文

赞 0 收藏 0 评论 0

超大只乌龟 发布了文章 · 1月11日

啃碎JDK源码(七):AbstractQueuedSynchronizer(AQS)

前言

在之前我们已经对部分JDK源码做了介绍:

啃碎JDK源码(一):String
啃碎JDK源码(二):Integer
啃碎JDK源码(三):ArrayList
啃碎JDK源码(四):HashMap
啃碎JDK源码(五):ConcurrentHashMap
啃碎JDK源码(六):LinkedList

今天我们正式开始介绍juc包下面的类,也就是和多线程打交道的地方,和锁打交道的类用的比较的的无非就是 ReentrantLockReentrantReadWriteLock 等,但是我们今天要介绍的是 AbstractQueuedSynchronizer 这个抽象类,也就是面试中经常被问到的 AQS,因为不管是ReentrantLock 还是ReentrantReadWriteLock 以及其它的一些都是基于它实现的,所以很有必要先来了解一下。

正文

AQS的全称为(AbstractQueuedSynchronizer),我们可以把它看成一个帮助我们实现锁的同步器,它基于FIFO(先进先出)的队列实现的,并且内部维护了一个状态变量 state,通过原子更新这个状态变量即可以实现加锁解锁操作。

来看下 AbstractQueuedSynchronizer 的继承结构

image.png

能看到 ReentrantLock等并不是直接继承 AbstractQueuedSynchronizer,而是其内部类 Sync

接着来看看一些重要的属性:

// 队列的头节点
private transient volatile Node head;    
// 队列的尾节点
private transient volatile Node tail;
// 控制加锁解锁的状态变量
private volatile int state;

定义了一个状态变量和一个队列,状态变量用来控制加锁解锁,队列用来放置等待的线程

这个 state变量很重要,用来做状态标识。比方说在 ReentrantLock 里面它表示获取锁的线程数,假如等于0表示还没有线程获取锁,1表示有线程获取了锁。大于1表示重入锁的数量

注意,这几个变量都要使用 volatile 关键字来修饰,因为是在多线程环境下操作,要保证它们的值修改之后对其它线程立即可见。

还有我们的 Node内部类

static final class Node {
        // 标识一个节点是共享模式
        static final Node SHARED = new Node();
        // 标识一个节点是互斥模式
        static final Node EXCLUSIVE = null;
        // 标识线程已取消
        static final int CANCELLED =  1;
        // 标识后继节点需要唤醒
        static final int SIGNAL    = -1;       
        // 标识线程等待在一个条件上
        static final int CONDITION = -2;
        // 标识后面的共享锁需要无条件的传播
        static final int PROPAGATE = -3;
        // 当前节点保存的线程对应的等待状态
        volatile int waitStatus;
        // 上一个结点
        volatile Node prev;
        // 下一个结点
        volatile Node next;
        // 当前结点保存的线程
        volatile Thread thread;
        // 下一个等待在条件上的节点(Condition锁时使用)
        Node nextWaiter;

        // 是否是共享模式
        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        // 获取前一个节点
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    
        }

        Node(Thread thread, Node mode) {
            // 把共享模式还是互斥模式存储到nextWaiter这个字段里面了
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) {
            // 等待的状态,在Condition中使用
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

上面是一个标准的双向链表结构,保存着当前线程、前一个节点、后一个节点以及线程的状态等信息。属性比较多,看不懂没关系,后面用到会重新讲一下。

那么在源码里面是如何修改这些变量的呢?其实就是通过我们之前说的 CAS 来修改,如果不了解的话请参考 一文看懂CAS

比方说 state 状态变量的修改

// 获取Unsafe类的实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 状态变量state的偏移量
private static final long stateOffset;


static {
    try {
        stateOffset = unsafe.objectFieldOffset
                  (AbstractQueuedSynchronizer.class.getDeclaredField("state"));
   .......
   } catch (Exception ex) { throw new Error(ex); }
}

核心是 compareAndSetState 方法:

protected final boolean compareAndSetState(int expect, int update) {
    // 如果当前值等于except,那么更新成update
 return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

既然 AbstractQueuedSynchronizer 是一个抽象类,那么子类要实现哪些接口呢?

比如说用来加锁的 tryAcquire 方法

// 互斥模式下尝试获取锁
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

可以看到只是抛出了异常,并且值得注意的是该方法并没有定义成抽象方法,因为只要实现一部分方法就可以自己手动编写一个锁了,定义成 protect 也是方便子类去实现,除此之外还有

// 互斥模式下尝试释放锁
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}
// 共享模式下尝试获取锁
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}
// 共享模式下尝试释放锁
protected int tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}
// 当前线程是否持有锁
protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}

基于AQS手动实现一个锁

现在我们尝试一下基于AQS手动实现一个锁:

/*
* 实现Lock接口
*/
public class MyLock implements Lock {

    private final Sync sync;

    public MyLock() {
        sync = new Sync();
    }

    // 定义一个内部类Sync继承AbstractQueuedSynchronizer
    private static class Sync extends AbstractQueuedSynchronizer {

        // 尝试获取独占锁
        @Override
        protected boolean tryAcquire(int arg) {
            // AQS方法:CAS更新state状态变量
            if (compareAndSetState(0, 1)) {
                // AQS方法:设置当前线程为持有锁的线程
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 尝试释放独占锁
        @Override
        protected boolean tryRelease(int arg) {
            if (getState() == 0)
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            // AQS方法:当前线程已持有锁,可以直接修改state值,不需要通过CAS修改
            setState(0);
            return true;
        }

        // 锁是否已被释放
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        Condition newCondition() {
            return new ConditionObject();
        }

    }

    @Override
    public void lock() {
        sync.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }

    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

好了,我们的锁已经写完了,核心就是使用一个静态内部类Sync继承AQS,实现加锁、释放锁等方法,其实我们熟悉的 ReentrantLock也是这样的实现原理,现在我们来测试一下:

public class TestLock {

    private final Lock lock = new MyLock();

    private volatile int count = 1;

    private static class WorkThread extends Thread {

        private final TestLock myLock;

        public WorkThread(TestLock myLock) {
            this.myLock = myLock;
        }

        @Override
        public void run() {
            myLock.execute();
        }
    }

    public void execute() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "获取到的count=" + count++);
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        TestLock myLock = new TestLock();
        // 启动100个线程
        for (int i = 1; i <= 100; i++) {
            new WorkThread(myLock).start();
        }
    }

}

我们启动100个线程对 count 加一,看看打印结果:

image.png

可以看到最后的确是加到了100,也就是说我们的锁是可用的。

image

但是,我们现在的锁是不可重入锁,学过ReentrantLock的同学应该知道它是可重入锁,也就是在线程持有锁的情况下可以重新获得锁,假如我们改一下execute方法:

public void execute() {
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + "获取到的count=" + count++);
        if (count == 5) {
            execute();
        }
    } finally {
        lock.unlock();
    }
}

当 count == 5 时执行调用自身,看下执行结果:

image.png

可以看到线程被阻塞了,因为当前持有锁的线程不能重新获取锁,所以我们需要对 tryAcquiretryRelease 方法进行改造:

        // 尝试获取独占锁
        @Override
        protected boolean tryAcquire(int arg) {
            // 获取当前线程
            Thread currentThread = Thread.currentThread();
            int state = getState();
            if (state == 0) {
                // AQS方法:CAS更新state状态变量
                if (compareAndSetState(0, 1)) {
                    // AQS方法:设置当前线程为持有锁的线程
                    setExclusiveOwnerThread(currentThread);
                    return true;
                } else if (currentThread == getExclusiveOwnerThread()) {
                    // 因为是独占锁,所以同一时刻只能有一个线程能获取到锁,如果当前的锁是被当前线程获取过了,则将状态变量+1
                    int newState = state + arg;
                    // 设置新的状态变量
                    setState(newState);
                    return true;
                }
            }
            return false;
        }

        // 尝试释放独占锁
        @Override
        protected boolean tryRelease(int arg) {
            // 判断当前锁释放是当前线程锁独占的,如果判断不成立则抛出异常
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            int newState = getState() - arg;
            boolean free = false;
            if (newState == 0) {
                // 如果状态为0了则说明当前线程可以释放对锁的持有了
                setExclusiveOwnerThread(null);
                free = true;
            }
    
   // AQS方法:当前线程已持有锁,可以直接修改state值,不需要通过CAS修改
            setState(newState);
            return free;
        }

tryAcquire 方法主要加了判断:如果 state 不为0的时候,判断当前线程是否已经和锁绑定,已经绑定的话则将 state+1 同时返回true

tryRelease方法中主要增加了释放锁的时候是对 state 变量逐次减一当减到0的时候才将锁与当前线程绑定的状态去除,释放锁。

重新运行下已经不会阻塞了,如果不懂的地方看下注释就明白了。

AQS源码剖析

那么当线程获取不到锁的时候是如何等待的呢?又是什么时候被唤醒的呢?接下来我们一步步跟随源码看看到底做了什么?

独占模式

AQS独占模式和共享模式,首先来看看独占模式,看下lock加锁方法:

@Override
public void lock() {
    sync.acquire(1);
}

这里可以看到并不是调用我们重写的 tryAcquire 方法,而是调用父类 AbstractQueuedSynchronizer 的方法:

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
 }

此方法是独占模式下线程获取共享资源的顶层入口。如果获取锁成功,线程直接返回,否则进入等待队列,直到获取锁为止,且整个过程忽略中断的影响。

首先是调用 tryAcquire方法来获取尝试获取锁,跟过去看一下AQS的实现:

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

可以看到该方法定义为protect,也就是由我们子类去实现的,如果获取锁失败的话那么会进入等待队列,来看看addWaiter方法:

/*
 * 将当前线程添加到等待队列的队尾,并返回当前线程所在的节点
 */
private Node addWaiter(Node mode) {
    // 以独占模式把当前线程封装成一个Node节点
    Node node = new Node(Thread.currentThread(), mode);
    // 尝试将结点放到队列尾部
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // 使用CAS把node作为尾节点
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 尾节点为空或者利用CAS把node设为尾节点失败时通过enq方法进行入队
    enq(node);
    return node;
}

  /*
   * 采用for循环自旋的方式把node插入到队列中
   */
  private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            // 队列为空,需要初始化
            if (t == null) {
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
  • addWaiter方法用于将当前线程添加到等待队列的队尾,并返回当前线程所在的节点。
  • enq 方法中采用了非常经典的自旋操作,只有通过CAS把node设为尾节点后,当前线程才能退出该方法,否则的话,当前线程不断的尝试,直到能把节点添加到队列中为止。

继续看一下acquireQueued方法:

  /*
   * 通过自旋获取锁
   */
  final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            // 默认线程没有被中断过
            boolean interrupted = false;
            for (;;) {
                // 获取该节点的前驱节点
                final Node p = node.predecessor();
                // 如果前驱节点是头节点并且当前线程获取到锁
                if (p == head && tryAcquire(arg)) {
                    // 设置当前节点为头节点
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 挂起当前线程
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                // 移除当前节点
                cancelAcquire(node);
        }
    }

该方法比较复杂,我们来仔细分析一下:

  • 首先获取前一个节点,如果前驱节点是头节点的话则尝试获取锁,如果获取锁成功的话设置当前节点为头节点
  • 否则调用 shouldParkAfterFailedAcquire 方法判断是否需要挂起当前线程

shouldParkAfterFailedAcquire方法从名字也能看出来是当获取锁失败后用来判断是否需要挂起当前线程,实现功能简单的讲就是把当前node节点的有效前驱(有效是指waitStatus不是CANCELLED的)找到,并且将有效前驱的状态设置为SIGNAL,之后便返回true代表马上可以阻塞了。来看看实现代码:

    /*
     * 判断是否需要挂起当前线程
     */
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * 前驱节点已经设置了SIGNAL,如果前驱变成了head,并且head的代表线程
             * exclusiveOwnerThread释放了锁,就会来根据这个SIGNAL来唤醒自己
             */
            return true;
        if (ws > 0) {
            /*
             * 发现传入的前驱的状态大于0,即CANCELLED。说明前驱节点已经因为超时或响应了中断,而取消了自己。
             * 所以需要向前遍历直到找到一个<=0的节点
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * 如果是其他情况,那么CAS尝试设置前驱节点为SIGNAL
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }
    

shouldParkAfterFailedAcquire返回true的情况下,继续看parkAndCheckInterrupted方法

  private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

调用LockSupport的 park 方法挂起当前线程,返回该线程是否被中断过,如果被中断过,直接设置 interrupted = true
(LockSupport类是Java6引入的一个类,提供了基本的线程同步原语,有兴趣的小伙伴可以去了解一下)

最后来看下 cancelAcquire方法:

  /**
   * 取消当前节点
   */
  private void cancelAcquire(Node node) {
        // Ignore if node doesn't exist
        if (node == null)
            return;
        // 把当前节点的线程设为 null
        node.thread = null;

        // 和前面一样向前遍历找到有效的前驱节点
        Node pred = node.prev;
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
   
        Node predNext = pred.next;

        // 把node节点的ws设为CANCELLED
        node.waitStatus = Node.CANCELLED;

        // 如果node是尾节点,利用CAS把前驱节点设为尾节点,后继节点为null方便GC
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            // 前驱节点不是头结点 && 线程不为空 && waitStatus为singal
            // 利用CAS把node的next设为pred的next节点
            int ws;
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
                // node是头结点,唤起它的后继节点
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }

最后如果acquireQueued方法返回true,需要进行自我中断。因为parkAndCheckInterrupted方法不响应中断,并且内部调用了Thread.interrupted方法清除中断标记位。所以当该方法返回true(被中断)时,需要手动补偿中断标记位。

static void selfInterrupt() {
     Thread.currentThread().interrupt();
}

流程总结

  • tryAcquire()尝试直接去获取锁,如果成功则直接返回;
  • addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  • acquireQueued()使线程在队列中等待直到获取锁。如果在整个等待过程中被中断过,则返回true,否则返回false。
  • 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
独占式锁获取流程

调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,即线程获取同步状态失败后进入同步队列,后续对线程进行中断操作时,线程不会从同步队列中移除。获取流程:

  1. 当前线程通过tryAcquire()方法尝试获取锁,成功则直接返回,失败则进入队列排队等待,通过CAS获取同步状态。
  2. 如果尝试获取锁失败的话,构造同步节点(独占式的Node.EXCLUSIVE),通过addWaiter(Node node,int args)方法,将节点加入到同步队列的队列尾部。
  3. 最后调用acquireQueued(final Node node, int args)方法,使该节点以死循环的方式获取同步状态,如果获取不到,则阻塞节点中的线程。acquireQueued方法当前线程在死循环中获取同步状态,而只有前驱节点是头节点的时候才能尝试获取锁(同步状态)( p == head && tryAcquire(arg))。

上面看完了加锁的流程,接下来看看是如何释放锁的?

   public final boolean release(int arg) {
        // tryRelease由子类实现
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                // 唤醒后继节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

代码比较简单,tryRelease 和上面一样也是有事子类去实现,如果释放锁成功的话那么我们需要调用 unparkSuccessor 方法去唤醒后继节点,看下具体实现:

  private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            // 将当前节点的状态修改为0
            compareAndSetWaitStatus(node, ws, 0);
        // 如果直接后继为空或者它的waitStatus大于0(已经放弃获取锁了),我们就遍历整个队列,获取第一个需要唤醒的节点
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            // 唤醒节点
            LockSupport.unpark(s.thread);
    }

代码比较简单,这里就不展开细说了。

共享模式

上面讲完了独占模式,现在来讲下共享模式,所谓共享模式就是同一个时刻允许多个线程持有锁,比方说 ReentrantReadWriteLock 就是实现了共享模式的AQS,直接上代码:

  public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
   }
   
   /*
    * 共享模式获取锁
    */
    private void doAcquireShared(int arg) {
        // 加入队列尾部
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                // head是已经拿到锁的节点
                if (p == head) {
                    // 尝试获取锁,返回的r是剩余的资源数量,如果大于0那么需要唤醒后续节点
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        // 将head指向自己,还有剩余资源可以再唤醒之后的线程
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

这个方法大致上看起来和独占模式是很相像的。区别只在于独占模式下,在本方法中获取到资源后,只是将本节点设置为head节点。而共享模式下,设置完head节点后,还需要判断是否需要唤醒多个线程,看一下如何唤醒线程:

private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        /*
         * 什么情况下要唤醒后继结点?
         * 1.资源剩余数大于0,有剩余资源肯定是要唤醒后继结点的
         * 2.头结点不存在。
         * 3.头结点状态小于0,意味着后继节点要求node(也就是当前head)唤醒后继结点
         */
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

doReleaseShared方法里面才是真正来唤醒线程:

private void doReleaseShared() {    
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    // 由于该方法可能被并发调用,为了避免不必要的唤醒浪费,因为通过cas来抢占唤醒权利。
                    // 抢占成功者执行真正的后继结点唤醒任务。如果失败则进行重试
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                // 如果ws==0,可能是head结点已经没有后继结点,也有可能是因为head节点的后继结点唤醒权被其他线程刚抢占成功。
                // 如果没有后继结点,显然不需要做任何事情
                // 如果是唤醒权被其他线程抢占,则不需要做任何事情。因为唤醒是在队列上进行传播的。所以这里就cas将head节点的状态值修改为 PROPAGATE。用来表达该线程唤醒操作意图已经传达。但是会由别的线程真正的执行后续的唤醒动作。同样,如果失败了,则重试。
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

加锁流程

tryAcquireShared方法也是由子类去实现,但是AQS已经把其返回值的语义定义好了:负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里acquireShared()的流程就是:

  1. tryAcquireShared()尝试获取资源,成功则直接返回;
  2. 失败则通过doAcquireShared()进入等待队列park(),直到被unpark()/interrupt()并成功获取到资源才返回。整个等待过程也是忽略中断的。
  3. doAcquireShared(int)此方法用于将当前线程加入等待队列尾部休息,直到其他线程释放锁唤醒自己,自己成功拿到相应的资源后才返回。

看完加锁的方法,现在来看共享模式下的释放锁方法:

  public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

可以看到解锁也是用的doReleaseShared方法,代码比较简单这里不再展开细说。

总结

今天有关AQS的知识点就介绍到这里,有什么不对的地方请多多指教!

image

查看原文

赞 4 收藏 4 评论 0

超大只乌龟 收藏了文章 · 2020-12-22

Java读写Excel原来这么简单

前言

相信现在很多搞后端的同学大部分做的都是后台管理系统,那么管理系统就肯定免不了 Excel 的导出导入功能,今天我们就来介绍一下 Java 如何实现 Excel 的导入导出功能。

Java领域解析,生成Excel比较有名的框架有Apache poi,Jxl等,但他们都存在一个严重的问题就是非常的耗内存,如果你的系统并发量不大的话可能还行,但是一旦并发上来后一定会OOM或者JVM频繁的full gc.

EasyExcel是阿里巴巴开源的一个excel处理框架,以使用简单,节省内存著称,今天我们来使用阿里巴巴开源的EasyExcel框架来实现Excel的导入导出功能。

官方文档:EasyExcel

本文主要有以下几个知识点:

  • 从Excel读取数据
  • 导出数据到Excel
  • Excel模板填充

正文

首先第一步得先导入EasyExcel的Jar包

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>easyexcel</artifactId>
  <version>2.2.4</version>
</dependency>

<!--xls-->
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi</artifactId>
  <version>3.17</version>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>3.17</version>
</dependency>

导出数据到Excel.

接下来看看如何导出数据到到Excel中,有两种写法,一种是不创建对象的写入,另一种是根据对象写入。

- 不创建对象的写入

@SpringBootTest
class Tests {
/*
 * 不创建对象的写
 */
 @Test
 public void test() {
 // 生成Excel路径
 String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        EasyExcel.write(fileName).head(head()).sheet("模板").doWrite(dataList());
    }
    
     private List<List<String>> head() {
      List<List<String>> list = new ArrayList<>();
      List<String> head0 = new ArrayList<>();
      head0.add("姓名");
      List<String> head1 = new ArrayList<>();
      head1.add("年龄");
      List<String> head2 = new ArrayList<>();
      head2.add("生日");
      list.add(head0);
      list.add(head1);
      list.add(head2);
      return list;
  }
  
    private List<List<Object>> dataList() {
        List<List<Object>> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            List<Object> data = new ArrayList<>();
            data.add("张三");
            data.add(25);
            data.add(new Date());
            list.add(data);
        }
        return list;
    }
}

代码很简单,核心就一句代码:

EasyExcel.write(fileName).head(head()).sheet("模板").doWrite(dataList());

head()用来放表头数据,dataList()用来放每一行的数据。

看下效果图:

image.png

如果想设置自动列宽可以这样子:

EasyExcel.write(fileName).head(head()).registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
        .sheet("模板").doWrite(dataList());

效果图:

image.png

- 根据对象写入

接下来是根据对象导入Excel,首先我们要定义一个对象:

@Data
public class User {

    @ExcelProperty("姓名")
    private String name;
    
    @ExcelProperty("性别")
    private String sex;
    
    @ExcelProperty("年龄")
    private Integer age;
    
    @ExcelProperty("身份证")
    private String cardid;
}

使用@ExcelProperty注解来指定标题名称

@SpringBootTest
class Tests {

 @Test
 public void test() {
      // 生成Excel路径
      String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
      EasyExcel.write(fileName, User.class).sheet("模板").doWrite(data());
    }
    
    private List<User> data() {
        List<User> userList = new ArrayList<>();
        User user;
        for (int i = 1; i <= 10; i++) {
            user = new User();
            user.setName("张三" + i);
            user.setSex("男");
            user.setAge(i);
            user.setCardid("440582xxxx");
            userList.add(user);
        }
        return userList;
    }
}

使用对象导出数据也是很简单,只要doWrite方法传入我们的对象集合就可以了。

效果图:

image.png

忽略字段

如果对象里面有些字段我们并不想导出到Excel中,只要使用@ExcelIgnore注解就可以了:

/*
 忽略这个字段
*/ 
@ExcelIgnore 
private String filed;

写入指定的列

如果我们想导出数据到指定的列中该如何设置呢?

@Data
public class User {

    @ExcelProperty(value = "姓名", index = 0)
    private String name;
    
    @ExcelProperty(value = "性别", index = 1)
    private String sex;
    
    @ExcelProperty(value = "年龄", index = 2)
    private Integer age;
    
    @ExcelProperty(value = "身份证", index = 4)
    private String cardid;
}

@ExcelPropertyindex可以指定导出的列索引,来看下效果图:

image.png

复杂头写入

很多时候Excel里会有很多复杂的表头,那么如何实现呢?

@Data
public class User {
    @ExcelProperty("姓名")
    private String name;
    
    @ExcelProperty("性别")
    private String sex;
    
    @ExcelProperty("年龄")
    private Integer age;
    
    @ExcelProperty("身份证")
    private String cardid;
    
    @ExcelProperty({"普通高等学校全日制教育", "学历"})
    private String kultur;
    
    @ExcelProperty({"普通高等学校全日制教育", "学位"})
    private String degree;
    
    @ExcelProperty({"普通高等学校全日制教育", "专业"})
    private String major;
    
    @ExcelProperty({"普通高等学校全日制教育", "获得学历时间"})
    private String graduatetime;
    
    @ExcelProperty({"普通高等学校全日制教育", "毕业院校"})
    private String school;
}

很简单不再细说,直接来看效果图:

image.png

写入到模板

我们上面都是生成新的数据写到Excel,如果说现在有一个模板文件,就像下面这种:

image.png

模板文件里面已经有一条数据了,那我们怎么在后面添加数据呢?

image

其实很简单:

String templateName = "C:\\Users\\likun\\Desktop\\模板.xlsx";
String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
EasyExcel.write(fileName).withTemplate(templateName).sheet("模板").doWrite(data());

使用withTemplate(templateName)方法传入模板路径就可以了,有个地方需要注意的是:这里的write方法只传文件路径,不传对象,如果传了对象又会生成新的表头,效果图如下:

image.png

注意:EasyExcel导出数据都是生成新的 Excel 文件,而不是在原来的文件上修改。

行高、列宽

这里参考官方文档的例子:

@Data
@ContentRowHeight(10)
@HeadRowHeight(20)
@ColumnWidth(25)
public class WidthAndHeightData {
    @ExcelProperty("字符串标题")
    private String string;
    @ExcelProperty("日期标题")
    private Date date;
    /**
     * 宽度为50
     */
    @ColumnWidth(50)
    @ExcelProperty("数字标题")
    private Double doubleData;
}

都是加个注解的事儿,这里不再细说。

合并单元格

@ContentLoopMerge(eachRow = 2)
@ExcelProperty("姓名")
private String name;

@ContentLoopMerge(eachRow = 2)表示姓名这一列每隔两行就进行合并

效果图:

image.png

@ContentLoopMerge还有一个columnExtend属性,可以对列进行合并

@ContentLoopMerge(eachRow = 2,columnExtend = 4)
@ExcelProperty("姓名")
private String name;

效果图:

image.png

当然这些只是简单的合并,如果需要复杂的合并可以自己定义一个策略,具体实现可以参考官方文档

自定义拦截器

有时候我们会有一些特殊的需求,比如说我们想给某个单元格设置下拉框,那么我们可以通过自定义拦截器来实现,据图代码如下:

public class CustomSheetWriteHandler implements SheetWriteHandler {

 @Override
 public void beforeSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
    }
    
 @Override
 public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
        CellRangeAddressList cellRangeAddressList = new CellRangeAddressList(2, 2, 0, 0);
        DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper();
        DataValidationConstraint constraint = helper.createExplicitListConstraint(new String[] {"测试1", "测试2"});
        DataValidation dataValidation = helper.createValidation(constraint, cellRangeAddressList);
        writeSheetHolder.getSheet().addValidationData(dataValidation);
    }
}

我们需要定义一个拦截器实现SheetWriteHandler方法,然后重写拦截方法,在afterSheetCreate方法里面对第二行第一列的单元格设置下拉框,然后只要注册上去就可以了:

.registerWriteHandler(new CustomSheetWriteHandler())

效果图:

image.png

Excel模板填充

还有一个常见的业务需求就是模板填充,网上大部分都是简单的填充,今天来看一下复杂模板的填充,下面是模板:

image.png

要想使用EasyExcel填充模板,我们需要在添加占位符{字段名},表格的需要用{自定义名称.字段名},来简单看下代码:

首先我们需要为表格定义一个简历对象:

@Data
public class WorkHistory {
    private String ubegintime;
    private String uendtime;
    private String uworkcomp;
    private String uworkdesc;
}

接下来开始填充数据:

    @Test
    public void test() {
        // 生成Excel路径
        String filePath = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        String templatePath = "C:\\Users\\likun\\Desktop\\模板.xlsx";
        ExcelWriter excelWriter = EasyExcel.write(filePath).withTemplate(templatePath).build();
        WriteSheet writeSheet = EasyExcel.writerSheet().build();
        FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
        // 填充数据
        Map<String, Object> map = new HashMap<>(64);
        map.put("uname", "张三");
        map.put("usex", "男");
        map.put("ubirthday", "2020.10.01");
        map.put("ucardid", "440582xxxxxxxx");
        map.put("umarriage", "未婚");
        map.put("unation", "汉族");
        map.put("unative", "广东xxxx");
        map.put("ubirthplace", "广东xxxx");
        map.put("upolity", "团员");
        map.put("uworktime", "2020.05.15");
        map.put("uhealth", "良好");
        excelWriter.fill(map, writeSheet);
        excelWriter.fill(new FillWrapper("data1", data1()), fillConfig, writeSheet);
        // 别忘记关闭流
        excelWriter.finish();
    }

    private List<WorkHistory> data1() {
        List<WorkHistory> list = new ArrayList<>();
        WorkHistory workHistory;
        for (int i = 1; i <= 3; i++) {
            workHistory = new WorkHistory();
            workHistory.setUbegintime("2020.05.01");
            workHistory.setUendtime("2020.05.01");
            workHistory.setUworkcomp("xxx公司");
            workHistory.setUworkdesc("后勤");
            list.add(workHistory);
        }
        return list;
    }

填充数据主要是下面两行代码:

excelWriter.fill(map, writeSheet);
excelWriter.fill(new FillWrapper("data1", data1()), fillConfig, writeSheet)

上面是填充字段,下面是填充我们的表格,注意这里data1的名字要和模板里面的名字一样。

forceNewRow(Boolean.TRUE)代表表格每次都会重新生成新的一行,而不是使用下面的空行。

看下填充的效果图:

image.png

合并单元格

可以看到数据已经填充进去了,但是表格单元格格式不符合我们的预期效果,虽然 EasyExcel 也提供了自定义策略来合并单元格,但是因为是通过回调方法触发,不好控制,因此我们这里使用原生的 Apache POI 来实现:

......
FileInputStream inputStream = new FileInputStream(new File(filePath));
XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
XSSFSheet sheet = workbook.getSheetAt(0);
// 合并列
sheet.addMergedRegion(new CellRangeAddress(8, 8, 1, 2));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 3, 4));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 5, 9));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 10, 11));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 1, 2));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 3, 4));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 5, 9));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 10, 11));
// 合并行
sheet.addMergedRegion(new CellRangeAddress(6, 9, 0, 0));

String mergeExcelPath="C:\\Users\\likun\\Desktop\\合并单元格.xlsx";
FileOutputStream outputStream = new FileOutputStream(mergeExcelPath);
workbook.write(outputStream);
outputStream.flush();

核心代码是就是

sheet.addMergedRegion(new CellRangeAddress(row1, row2, col1, col2));

来看下效果图吧:

image.png

设置边框

可以看到单元格已经合并了,现在就是合并后没有边框,当然也有提供API供我们使用,

RegionUtil.setBorderBottom(BorderStyle.THIN, new CellRangeAddress(8, 8, 1, 2), sheet);

image.png

可以看到单元格已经设置了边框,至于其它的请大伙自行设置,这边只做个简单演示。

插入头像

EasyExcel也支持头像导出,但是只能插入到一个单元格里面,因此我们还是用原生API来插入头像:

// 转换成流
ByteArrayOutputStream byteArrayOut = new ByteArrayOutputStream();
BufferedImage bufferImg = ImageIO.read(new File("C:\\Users\\likun\\Pictures\\头像\\1.jpg"));
ImageIO.write(bufferImg, "jpg", byteArrayOut);

XSSFDrawing patriarch = sheet.createDrawingPatriarch();
XSSFClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0, (short) 11, 2, (short) 12, 6);
anchor.setAnchorType(ClientAnchor.AnchorType.DONT_MOVE_AND_RESIZE);
patriarch.createPicture(anchor, workbook.addPicture(byteArrayOut.toByteArray(), HSSFWorkbook.PICTURE_TYPE_JPEG));

只要用XSSFClientAnchor配置好参数,就能在指定的位置插入图片。前四个参数是偏移量,默认为0就可以了,后四个就是图片边缘的单元格位置,具体细节这里不再细说。

new XSSFClientAnchor(0, 0, 0, 0, (short) 11, 2, (short) 12, 6);

效果图:

image.png

从Excel读取数据

先来看下如何从Excel读取数据,首先定义一个监听器继承 AnalysisEventListener 类:

@EqualsAndHashCode(callSuper = true)
@Data
public class ExcelListener extends AnalysisEventListener<Object> {
    private static final Logger LOGGER = LoggerFactory.getLogger(ExcelListener.class);
/**
 * 自定义用于暂时存储data
 */ 
 private List<JSONObject> dataList = new ArrayList<>();
 
/**
 * 导入表头
 */
 private Map<String, Integer> importHeads = new HashMap<>(16);
 
/**
 * 这个每一条数据解析都会来调用
 */
 @Override
 public void invoke(Object data, AnalysisContext context) {
        String headStr = JSON.toJSONString(data);
        dataList.add(JSONObject.parseObject(headStr));
    }
    
/**
 * 这里会一行行的返回头
 */
 @Override
 public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
        for (Integer key : headMap.keySet()) {
            if (importHeads.containsKey(headMap.get(key))) {
                continue;
            }
            importHeads.put(headMap.get(key), key);
        }
    }
    
/**
 * 所有数据解析完成了 都会来调用
 */
 @Override
 public void doAfterAllAnalysed(AnalysisContext context) {
     LOGGER.info("Excel解析完毕");
    }
}

当解析每一条数据时都会调用invoke方法,invokeHeadMap方法会返回我们的表格头,当所有数据都解析完毕时最后会调用doAfterAllAnalysed方法。

上面代码是我项目里面用的,你们也可以根据自己需求编写,上面用JSONObject集合来存放Excel中每一条数据,用一个Map存放我们的表格头。

那么有了监听器之后该如何使用呢?

这里有个很重要的点就是 监听器不能被spring管理,要每次读取excel都要new.

看下如何读取前端发送过来的Excel文件:

    @PostMapping("upload")
    @ResponseBody
    public String upload(MultipartFile file) throws IOException {
       ExcelListener excelListener = new ExcelListener();
       EasyExcel.read(file.getInputStream(), excelListener).sheet().doRead();
       ......
    }

只要调用read方法就可以读取数据,那么接下来只要去拿到数据就可以了。

比如读取表格头数据:

Map<String, Integer> importHeads = excelListener.getImportHeads();

或者读取数据集合

List<JSONObject> dataList = excelListener.getDataList();

当然我们也可以根据文件路径去读取

    @Test
    public void test() {
        // 生成Excel路径
        String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        ExcelListener excelListener = new ExcelListener();
        EasyExcel.read(fileName, excelListener).sheet().doRead();
        // 表格头数据
        Map<String, Integer> importHeads = excelListener.getImportHeads();
        System.out.println(importHeads);
        // 每一行数据
        List<JSONObject> dataList = excelListener.getDat![image]aList();
        for (JSONObject object : dataList) {
            System.out.println(object);
        }
    }

这是我们要读取的Excel数据

image.png

来看下读取到的数据:

image.png

上面的读取是不使用对象的读取方式,也有使用对象去读取的方式,因为和上面导出的差不多这里就不再展开描述没如果有需要的同学可以参考官方文档

总结

代码已上传Github:https://github.com/chenwuguii/wugui

今天有关Java操作Excel的知识点就暂时到这里,如果有什么不对的地方请多多指教!

image

查看原文

超大只乌龟 发布了文章 · 2020-12-22

Java读写Excel原来这么简单

前言

相信现在很多搞后端的同学大部分做的都是后台管理系统,那么管理系统就肯定免不了 Excel 的导出导入功能,今天我们就来介绍一下 Java 如何实现 Excel 的导入导出功能。

Java领域解析,生成Excel比较有名的框架有Apache poi,Jxl等,但他们都存在一个严重的问题就是非常的耗内存,如果你的系统并发量不大的话可能还行,但是一旦并发上来后一定会OOM或者JVM频繁的full gc.

EasyExcel是阿里巴巴开源的一个excel处理框架,以使用简单,节省内存著称,今天我们来使用阿里巴巴开源的EasyExcel框架来实现Excel的导入导出功能。

官方文档:EasyExcel

本文主要有以下几个知识点:

  • 从Excel读取数据
  • 导出数据到Excel
  • Excel模板填充

正文

首先第一步得先导入EasyExcel的Jar包

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>easyexcel</artifactId>
  <version>2.2.4</version>
</dependency>

<!--xls-->
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi</artifactId>
  <version>3.17</version>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>3.17</version>
</dependency>

导出数据到Excel.

接下来看看如何导出数据到到Excel中,有两种写法,一种是不创建对象的写入,另一种是根据对象写入。

- 不创建对象的写入

@SpringBootTest
class Tests {
/*
 * 不创建对象的写
 */
 @Test
 public void test() {
 // 生成Excel路径
 String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        EasyExcel.write(fileName).head(head()).sheet("模板").doWrite(dataList());
    }
    
     private List<List<String>> head() {
      List<List<String>> list = new ArrayList<>();
      List<String> head0 = new ArrayList<>();
      head0.add("姓名");
      List<String> head1 = new ArrayList<>();
      head1.add("年龄");
      List<String> head2 = new ArrayList<>();
      head2.add("生日");
      list.add(head0);
      list.add(head1);
      list.add(head2);
      return list;
  }
  
    private List<List<Object>> dataList() {
        List<List<Object>> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            List<Object> data = new ArrayList<>();
            data.add("张三");
            data.add(25);
            data.add(new Date());
            list.add(data);
        }
        return list;
    }
}

代码很简单,核心就一句代码:

EasyExcel.write(fileName).head(head()).sheet("模板").doWrite(dataList());

head()用来放表头数据,dataList()用来放每一行的数据。

看下效果图:

image.png

如果想设置自动列宽可以这样子:

EasyExcel.write(fileName).head(head()).registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
        .sheet("模板").doWrite(dataList());

效果图:

image.png

- 根据对象写入

接下来是根据对象导入Excel,首先我们要定义一个对象:

@Data
public class User {

    @ExcelProperty("姓名")
    private String name;
    
    @ExcelProperty("性别")
    private String sex;
    
    @ExcelProperty("年龄")
    private Integer age;
    
    @ExcelProperty("身份证")
    private String cardid;
}

使用@ExcelProperty注解来指定标题名称

@SpringBootTest
class Tests {

 @Test
 public void test() {
      // 生成Excel路径
      String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
      EasyExcel.write(fileName, User.class).sheet("模板").doWrite(data());
    }
    
    private List<User> data() {
        List<User> userList = new ArrayList<>();
        User user;
        for (int i = 1; i <= 10; i++) {
            user = new User();
            user.setName("张三" + i);
            user.setSex("男");
            user.setAge(i);
            user.setCardid("440582xxxx");
            userList.add(user);
        }
        return userList;
    }
}

使用对象导出数据也是很简单,只要doWrite方法传入我们的对象集合就可以了。

效果图:

image.png

忽略字段

如果对象里面有些字段我们并不想导出到Excel中,只要使用@ExcelIgnore注解就可以了:

/*
 忽略这个字段
*/ 
@ExcelIgnore 
private String filed;

写入指定的列

如果我们想导出数据到指定的列中该如何设置呢?

@Data
public class User {

    @ExcelProperty(value = "姓名", index = 0)
    private String name;
    
    @ExcelProperty(value = "性别", index = 1)
    private String sex;
    
    @ExcelProperty(value = "年龄", index = 2)
    private Integer age;
    
    @ExcelProperty(value = "身份证", index = 4)
    private String cardid;
}

@ExcelPropertyindex可以指定导出的列索引,来看下效果图:

image.png

复杂头写入

很多时候Excel里会有很多复杂的表头,那么如何实现呢?

@Data
public class User {
    @ExcelProperty("姓名")
    private String name;
    
    @ExcelProperty("性别")
    private String sex;
    
    @ExcelProperty("年龄")
    private Integer age;
    
    @ExcelProperty("身份证")
    private String cardid;
    
    @ExcelProperty({"普通高等学校全日制教育", "学历"})
    private String kultur;
    
    @ExcelProperty({"普通高等学校全日制教育", "学位"})
    private String degree;
    
    @ExcelProperty({"普通高等学校全日制教育", "专业"})
    private String major;
    
    @ExcelProperty({"普通高等学校全日制教育", "获得学历时间"})
    private String graduatetime;
    
    @ExcelProperty({"普通高等学校全日制教育", "毕业院校"})
    private String school;
}

很简单不再细说,直接来看效果图:

image.png

写入到模板

我们上面都是生成新的数据写到Excel,如果说现在有一个模板文件,就像下面这种:

image.png

模板文件里面已经有一条数据了,那我们怎么在后面添加数据呢?

image

其实很简单:

String templateName = "C:\\Users\\likun\\Desktop\\模板.xlsx";
String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
EasyExcel.write(fileName).withTemplate(templateName).sheet("模板").doWrite(data());

使用withTemplate(templateName)方法传入模板路径就可以了,有个地方需要注意的是:这里的write方法只传文件路径,不传对象,如果传了对象又会生成新的表头,效果图如下:

image.png

注意:EasyExcel导出数据都是生成新的 Excel 文件,而不是在原来的文件上修改。

行高、列宽

这里参考官方文档的例子:

@Data
@ContentRowHeight(10)
@HeadRowHeight(20)
@ColumnWidth(25)
public class WidthAndHeightData {
    @ExcelProperty("字符串标题")
    private String string;
    @ExcelProperty("日期标题")
    private Date date;
    /**
     * 宽度为50
     */
    @ColumnWidth(50)
    @ExcelProperty("数字标题")
    private Double doubleData;
}

都是加个注解的事儿,这里不再细说。

合并单元格

@ContentLoopMerge(eachRow = 2)
@ExcelProperty("姓名")
private String name;

@ContentLoopMerge(eachRow = 2)表示姓名这一列每隔两行就进行合并

效果图:

image.png

@ContentLoopMerge还有一个columnExtend属性,可以对列进行合并

@ContentLoopMerge(eachRow = 2,columnExtend = 4)
@ExcelProperty("姓名")
private String name;

效果图:

image.png

当然这些只是简单的合并,如果需要复杂的合并可以自己定义一个策略,具体实现可以参考官方文档

自定义拦截器

有时候我们会有一些特殊的需求,比如说我们想给某个单元格设置下拉框,那么我们可以通过自定义拦截器来实现,据图代码如下:

public class CustomSheetWriteHandler implements SheetWriteHandler {

 @Override
 public void beforeSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
    }
    
 @Override
 public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
        CellRangeAddressList cellRangeAddressList = new CellRangeAddressList(2, 2, 0, 0);
        DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper();
        DataValidationConstraint constraint = helper.createExplicitListConstraint(new String[] {"测试1", "测试2"});
        DataValidation dataValidation = helper.createValidation(constraint, cellRangeAddressList);
        writeSheetHolder.getSheet().addValidationData(dataValidation);
    }
}

我们需要定义一个拦截器实现SheetWriteHandler方法,然后重写拦截方法,在afterSheetCreate方法里面对第二行第一列的单元格设置下拉框,然后只要注册上去就可以了:

.registerWriteHandler(new CustomSheetWriteHandler())

效果图:

image.png

Excel模板填充

还有一个常见的业务需求就是模板填充,网上大部分都是简单的填充,今天来看一下复杂模板的填充,下面是模板:

image.png

要想使用EasyExcel填充模板,我们需要在添加占位符{字段名},表格的需要用{自定义名称.字段名},来简单看下代码:

首先我们需要为表格定义一个简历对象:

@Data
public class WorkHistory {
    private String ubegintime;
    private String uendtime;
    private String uworkcomp;
    private String uworkdesc;
}

接下来开始填充数据:

    @Test
    public void test() {
        // 生成Excel路径
        String filePath = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        String templatePath = "C:\\Users\\likun\\Desktop\\模板.xlsx";
        ExcelWriter excelWriter = EasyExcel.write(filePath).withTemplate(templatePath).build();
        WriteSheet writeSheet = EasyExcel.writerSheet().build();
        FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
        // 填充数据
        Map<String, Object> map = new HashMap<>(64);
        map.put("uname", "张三");
        map.put("usex", "男");
        map.put("ubirthday", "2020.10.01");
        map.put("ucardid", "440582xxxxxxxx");
        map.put("umarriage", "未婚");
        map.put("unation", "汉族");
        map.put("unative", "广东xxxx");
        map.put("ubirthplace", "广东xxxx");
        map.put("upolity", "团员");
        map.put("uworktime", "2020.05.15");
        map.put("uhealth", "良好");
        excelWriter.fill(map, writeSheet);
        excelWriter.fill(new FillWrapper("data1", data1()), fillConfig, writeSheet);
        // 别忘记关闭流
        excelWriter.finish();
    }

    private List<WorkHistory> data1() {
        List<WorkHistory> list = new ArrayList<>();
        WorkHistory workHistory;
        for (int i = 1; i <= 3; i++) {
            workHistory = new WorkHistory();
            workHistory.setUbegintime("2020.05.01");
            workHistory.setUendtime("2020.05.01");
            workHistory.setUworkcomp("xxx公司");
            workHistory.setUworkdesc("后勤");
            list.add(workHistory);
        }
        return list;
    }

填充数据主要是下面两行代码:

excelWriter.fill(map, writeSheet);
excelWriter.fill(new FillWrapper("data1", data1()), fillConfig, writeSheet)

上面是填充字段,下面是填充我们的表格,注意这里data1的名字要和模板里面的名字一样。

forceNewRow(Boolean.TRUE)代表表格每次都会重新生成新的一行,而不是使用下面的空行。

看下填充的效果图:

image.png

合并单元格

可以看到数据已经填充进去了,但是表格单元格格式不符合我们的预期效果,虽然 EasyExcel 也提供了自定义策略来合并单元格,但是因为是通过回调方法触发,不好控制,因此我们这里使用原生的 Apache POI 来实现:

......
FileInputStream inputStream = new FileInputStream(new File(filePath));
XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
XSSFSheet sheet = workbook.getSheetAt(0);
// 合并列
sheet.addMergedRegion(new CellRangeAddress(8, 8, 1, 2));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 3, 4));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 5, 9));
sheet.addMergedRegion(new CellRangeAddress(8, 8, 10, 11));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 1, 2));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 3, 4));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 5, 9));
sheet.addMergedRegion(new CellRangeAddress(9, 9, 10, 11));
// 合并行
sheet.addMergedRegion(new CellRangeAddress(6, 9, 0, 0));

String mergeExcelPath="C:\\Users\\likun\\Desktop\\合并单元格.xlsx";
FileOutputStream outputStream = new FileOutputStream(mergeExcelPath);
workbook.write(outputStream);
outputStream.flush();

核心代码是就是

sheet.addMergedRegion(new CellRangeAddress(row1, row2, col1, col2));

来看下效果图吧:

image.png

设置边框

可以看到单元格已经合并了,现在就是合并后没有边框,当然也有提供API供我们使用,

RegionUtil.setBorderBottom(BorderStyle.THIN, new CellRangeAddress(8, 8, 1, 2), sheet);

image.png

可以看到单元格已经设置了边框,至于其它的请大伙自行设置,这边只做个简单演示。

插入头像

EasyExcel也支持头像导出,但是只能插入到一个单元格里面,因此我们还是用原生API来插入头像:

// 转换成流
ByteArrayOutputStream byteArrayOut = new ByteArrayOutputStream();
BufferedImage bufferImg = ImageIO.read(new File("C:\\Users\\likun\\Pictures\\头像\\1.jpg"));
ImageIO.write(bufferImg, "jpg", byteArrayOut);

XSSFDrawing patriarch = sheet.createDrawingPatriarch();
XSSFClientAnchor anchor = new XSSFClientAnchor(0, 0, 0, 0, (short) 11, 2, (short) 12, 6);
anchor.setAnchorType(ClientAnchor.AnchorType.DONT_MOVE_AND_RESIZE);
patriarch.createPicture(anchor, workbook.addPicture(byteArrayOut.toByteArray(), HSSFWorkbook.PICTURE_TYPE_JPEG));

只要用XSSFClientAnchor配置好参数,就能在指定的位置插入图片。前四个参数是偏移量,默认为0就可以了,后四个就是图片边缘的单元格位置,具体细节这里不再细说。

new XSSFClientAnchor(0, 0, 0, 0, (short) 11, 2, (short) 12, 6);

效果图:

image.png

从Excel读取数据

先来看下如何从Excel读取数据,首先定义一个监听器继承 AnalysisEventListener 类:

@EqualsAndHashCode(callSuper = true)
@Data
public class ExcelListener extends AnalysisEventListener<Object> {
    private static final Logger LOGGER = LoggerFactory.getLogger(ExcelListener.class);
/**
 * 自定义用于暂时存储data
 */ 
 private List<JSONObject> dataList = new ArrayList<>();
 
/**
 * 导入表头
 */
 private Map<String, Integer> importHeads = new HashMap<>(16);
 
/**
 * 这个每一条数据解析都会来调用
 */
 @Override
 public void invoke(Object data, AnalysisContext context) {
        String headStr = JSON.toJSONString(data);
        dataList.add(JSONObject.parseObject(headStr));
    }
    
/**
 * 这里会一行行的返回头
 */
 @Override
 public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
        for (Integer key : headMap.keySet()) {
            if (importHeads.containsKey(headMap.get(key))) {
                continue;
            }
            importHeads.put(headMap.get(key), key);
        }
    }
    
/**
 * 所有数据解析完成了 都会来调用
 */
 @Override
 public void doAfterAllAnalysed(AnalysisContext context) {
     LOGGER.info("Excel解析完毕");
    }
}

当解析每一条数据时都会调用invoke方法,invokeHeadMap方法会返回我们的表格头,当所有数据都解析完毕时最后会调用doAfterAllAnalysed方法。

上面代码是我项目里面用的,你们也可以根据自己需求编写,上面用JSONObject集合来存放Excel中每一条数据,用一个Map存放我们的表格头。

那么有了监听器之后该如何使用呢?

这里有个很重要的点就是 监听器不能被spring管理,要每次读取excel都要new.

看下如何读取前端发送过来的Excel文件:

    @PostMapping("upload")
    @ResponseBody
    public String upload(MultipartFile file) throws IOException {
       ExcelListener excelListener = new ExcelListener();
       EasyExcel.read(file.getInputStream(), excelListener).sheet().doRead();
       ......
    }

只要调用read方法就可以读取数据,那么接下来只要去拿到数据就可以了。

比如读取表格头数据:

Map<String, Integer> importHeads = excelListener.getImportHeads();

或者读取数据集合

List<JSONObject> dataList = excelListener.getDataList();

当然我们也可以根据文件路径去读取

    @Test
    public void test() {
        // 生成Excel路径
        String fileName = "C:\\Users\\likun\\Desktop\\测试.xlsx";
        ExcelListener excelListener = new ExcelListener();
        EasyExcel.read(fileName, excelListener).sheet().doRead();
        // 表格头数据
        Map<String, Integer> importHeads = excelListener.getImportHeads();
        System.out.println(importHeads);
        // 每一行数据
        List<JSONObject> dataList = excelListener.getDat![image]aList();
        for (JSONObject object : dataList) {
            System.out.println(object);
        }
    }

这是我们要读取的Excel数据

image.png

来看下读取到的数据:

image.png

上面的读取是不使用对象的读取方式,也有使用对象去读取的方式,因为和上面导出的差不多这里就不再展开描述没如果有需要的同学可以参考官方文档

总结

代码已上传Github:https://github.com/chenwuguii/wugui

今天有关Java操作Excel的知识点就暂时到这里,如果有什么不对的地方请多多指教!

image

查看原文

赞 19 收藏 15 评论 1

超大只乌龟 收藏了文章 · 2020-12-03

Java填充word模板

前言

本文主要有以下几个知识点:

  • 占位符填充
  • 头像导出
  • 表格填充
  • word转pdf

先来看下最后的效果图:

image.png

正文

模板配置

先来看下我们的模板:

image.png

首先我们需要先在word模板里面设置占位符,这里有一个非常重要的点就是我们是根据${占位符}来替换的,其实word文档本质上就是一个xml文件,因此我们需要保证占位符不被切割,具体做法如下:

1.首先用解压工具打开模板

image.png

2.打开document.xml文件

image.png

3.可以看出文件并未格式化,我们先格式化代码

image.png

image.png

4.可以看到我们的占位符被切割了,我们需要干掉中间多余的。

image.png

image.png

5.点击开始后直接覆盖源文件就可以了,现在可以开始写代码了。

image.png

注意要保证我们的每个占位符不被切割,否则是无法进行替换的

模板填充

导入jar

<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi</artifactId>
 <version>4.1.2</version>
</dependency>
<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi-scratchpad</artifactId>
 <version>4.1.2</version>
</dependency>
<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi-ooxml</artifactId>
 <version>4.1.2</version>
</dependency>

首先看下我们两个表格的实体类(不一定要给表格建对象,根据个人需求添加即可)

// 家庭成员
@Builder(toBuilder = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EpRewandpun {
    private String urewdate;
    private String urewunit;
    private String urewdesc;
}

// 奖惩情况
@Builder(toBuilder = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EpPmenber {
    private String uconnection;
    private String uname;
    private String ubirthday;
    private String uworkunit;
    private String uploity;
    private String ustatus;
}

接着看下我们的测试方法:

@SpringBootTest
class Tests {
    @Test
    void contextLoads() throws IOException {
        String template = "C:UserslikunDesktop员工基本情况表.docx";
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("${uname}", "乌龟");
        paramMap.put("${usex}", "男");
        paramMap.put("${ubirthdate}", "1998年10月22日");
        paramMap.put("${unation}", "汉族");
        paramMap.put("${unative}", "广东深圳");
        paramMap.put("${uplace}", "广东汕头");
        paramMap.put("${upolity}", "团员");
        paramMap.put("${uworkdate}", "2020年3月16日");
        paramMap.put("${uhealth}", "良好");
        paramMap.put("${umajorpost}", "软件开发");
        paramMap.put("${umajor}", "Java开发");
        
        // 照片路径以及大小
        Map<String, Object> phomap = new HashMap<>(8);
        phomap.put("width", 100);
        phomap.put("height", 130);
        phomap.put("type", "png");
        phomap.put("content", "E:\\Photo\\头像.jpg");
        paramMap.put("${upho}", phomap);
        
        //查询员工家庭信息
        List<EpPmenber> menberlist = new ArrayList<>();
        for (int i = 1; i < 3; i++) {
            EpPmenber pmenber = new EpPmenber();
            pmenber.setUname("小王");
            pmenber.setUconnection("父亲");
            pmenber.setUbirthday("1962年10月2日");
            pmenber.setUploity("群众");
            pmenber.setUworkunit("广东xxx公司");
            pmenber.setUstatus("无");
            menberlist.add(pmenber);
        }
        paramMap.put("menberlist", menberlist);
        
        //查询员工奖励情况
        List<EpRewandpun> andpunlist = new ArrayList<>();
        for (int i = 1; i < 3; i++) {
            EpRewandpun rewandpun = new EpRewandpun();
            rewandpun.setUrewdate("2020年5月1日");
            rewandpun.setUrewunit("深圳xxx有限公司");
            rewandpun.setUrewdesc("无");
            andpunlist.add(rewandpun);
        }
        paramMap.put("andpunlist", andpunlist);
        
        // 模板填充
        XWPFDocument doc = WordUtil.generateWord(paramMap, template);
        FileOutputStream fopts = new FileOutputStream("C:Users\\likun\\Desktop\\模板填充.docx");
        doc.write(fopts);
        fopts.close();
    }
}

代码比较简单不再细说,先来看下我们的 generateWord 核心方法:

public class WordUtil {

    /**
     * 根据指定的参数值、模板,生成 word 文档
     * 注意:其它模板需要根据情况进行调整
     *
     * @param param    变量集合
     * @param template 模板路径
     */
   public static XWPFDocument generateWord(Map<String, Object> param, String template) {
    XWPFDocument doc = null;
    try {
        OPCPackage pack = POIXMLDocument.openPackage(template);
        doc = new XWPFDocument(pack);
        if (param != null && param.size() > 0) {
            // 处理段落
            List<XWPFParagraph> paragraphList = doc.getParagraphs();
            processParagraphs(paragraphList, param, doc);
            // 处理表格
            Iterator<XWPFTable> it = doc.getTablesIterator();
            //表格索引
            int i = 0;
            List<EpPmenber> menberlist = (List<EpPmenber>) param.get("menberlist");
            List<EpRewandpun> andpunlist = (List<EpRewandpun>) param.get("andpunlist");
            while (it.hasNext()) {
                XWPFTable table = it.next();
                int size = table.getRows().size() - 1;
                XWPFTableRow row2 = table.getRow(size);
                if (i == 1) {//家庭成员
                    if (menberlist.size() > 0) {
                        for (int j = 0; j < menberlist.size() - 1; j++) {
                            copy(table, row2, size + j);
                        }
                    }
                } else if (i == 2) {//奖惩情况
                    if (andpunlist.size() > 0) {
                        for (int j = 0; j < andpunlist.size() - 1; j++) {
                            copy(table, row2, size + j);
                        }
                    }
                }
                List<XWPFTableRow> rows = table.getRows();
                int _row = 0;
                for (XWPFTableRow row : rows) {
                    List<XWPFTableCell> cells = row.getTableCells();
                    for (XWPFTableCell cell : cells) {
                        List<XWPFParagraph> paragraphListTable = cell.getParagraphs();
                        processParagraphs(paragraphListTable, param, doc);
                    }
                    // 家庭成员
                    if (i == 1 && _row >= size) {
                        if (menberlist.size() == 0) {
                            _row++;
                            continue;
                        }
                        row.getCell(0).setText(menberlist.get(_row - size).getUconnection());
                        row.getCell(1).setText(menberlist.get(_row - size).getUname());
                        row.getCell(2).setText(menberlist.get(_row - size).getUbirthday());
                        row.getCell(3).setText(menberlist.get(_row - size).getUploity());
                        row.getCell(4).setText(menberlist.get(_row - size).getUworkunit());
                        row.getCell(5).setText(menberlist.get(_row - size).getUstatus());
                    } else if (i == 2 && _row >= size) {//奖励情况
                         if (andpunlist.size() == 0) {
                            _row++;
                            continue;
                        }
                        row.getCell(0).setText(andpunlist.get(_row - size).getUrewdate());
                        row.getCell(1).setText(andpunlist.get(_row - size).getUrewunit());
                        row.getCell(2).setText(andpunlist.get(_row - size).getUrewdesc());
                    }
                    _row++;
                }
                i++;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return doc;
}

看下我们的 copy 方法,用来拷贝行属性进行对表格进行复制

/**
 * 拷贝赋值行
 *
 */
public static void copy(XWPFTable table, XWPFTableRow sourceRow, int rowIndex) {
    // 在表格指定位置新增一行
    XWPFTableRow targetRow = table.insertNewTableRow(rowIndex);
    // 复制行属性
    targetRow.getCtRow().setTrPr(sourceRow.getCtRow().getTrPr());
    List<XWPFTableCell> cellList = sourceRow.getTableCells();
    if (null == cellList) {
        return;
    }
    // 复制列及其属性和内容
    XWPFTableCell targetCell = null;
    for (XWPFTableCell sourceCell : cellList) {
        targetCell = targetRow.addNewTableCell();
        // 列属性
        targetCell.getCTTc().setTcPr(sourceCell.getCTTc().getTcPr());
        // 段落属性
        if (sourceCell.getParagraphs() != null && sourceCell.getParagraphs().size() > 0) {
            targetCell.getParagraphs().get(0).getCTP().setPPr(sourceCell.getParagraphs().get(0).getCTP().getPPr());
            if (sourceCell.getParagraphs().get(0).getRuns() != null && sourceCell.getParagraphs().get(0).getRuns().size() > 0) {
                XWPFRun cellR = targetCell.getParagraphs().get(0).createRun();
                cellR.setText(sourceCell.getText());
                cellR.setBold(sourceCell.getParagraphs().get(0).getRuns().get(0).isBold());
            } else {
                targetCell.setText(sourceCell.getText());
            }
        } else {
            targetCell.setText(sourceCell.getText());
        }
    }
}

接下来是我们的 processParagraphs 方法,用来进行占位符的替换以及头像的插入。

/**
 * 处理段落
 */
@SuppressWarnings({"unused", "rawtypes"})
public static void processParagraphs(List<XWPFParagraph> paragraphList, Map<String, Object> param, XWPFDocument doc) throws InvalidFormatException, IOException {
    if (paragraphList != null && paragraphList.size() > 0) {
        for (XWPFParagraph paragraph : paragraphList) {
            List<XWPFRun> runs = paragraph.getRuns();
            for (XWPFRun run : runs) {
                String text = run.getText(0);
                if (text != null) {
                    boolean isSetText = false;
                    for (Entry<String, Object> entry : param.entrySet()) {
                        String key = entry.getKey();
                        if (text.contains(key)) {
                            isSetText = true;
                            Object value;
                            if (entry.getValue() != null) {
                                value = entry.getValue();
                            } else {
                                value = "";
                            }
                            // 文本替换
                          if (value instanceof String) {
                            // 处理答案中的回车换行
                          if (((String) value).contains("n")) {
                                    String[] lines = ((String) value).split("n");
                                    if (lines.length > 0) {
                                        text = text.replace(key, lines[0]);
                                        for (int j = 1; j < lines.length; j++) {
                                            run.addCarriageReturn();
                                            run.setText(lines[j]);
                                        }
                                    }
                                } else {
                                    text = text.replace(key, value.toString());
                                }
                            } else if (value instanceof Map) {
                                // 图片替换
                                text = text.replace(key, "");
                                Map pic = (Map) value;
                                int width = Integer.parseInt(pic.get("width").toString());
                                int height = Integer.parseInt(pic.get("height").toString());
                                int picType = getPictureType(pic.get("type").toString());
                                String byteArray = (String) pic.get("content");
                                CTInline inline = run.getCTR().addNewDrawing().addNewInline();
                                //插入图片
                                insertPicture(doc, byteArray, inline, width, height, paragraph);
                            }
                        }
                    }
                    if (isSetText) {
                        run.setText(text, 0);
                    }
                }
            }
        }
    }
}

接下来是 insertPicture 方法,用来插入图片,以及 getPictureType 方法获取图片类型。

/**
 * 插入图片
 *
 */
 private static void insertPicture(XWPFDocument document, String filePath, CTInline inline, int width, int height, XWPFParagraph paragraph) throws Exception {
        // 读取图片路径
        InputStream inputStream = new FileInputStream(new File(filePath));;
        
        document.addPictureData(inputStream, XWPFDocument.PICTURE_TYPE_PNG);
        int id = document.getAllPictures().size() - 1;
        final int emu = 9525;
        width *= emu;
        height *= emu;
        String blipId = paragraph.getDocument().getRelationId(document.getAllPictures().get(id));

        String picXml = getPicXml(blipId, width, height);
        XmlToken xmlToken = null;
        try {
            xmlToken = XmlToken.Factory.parse(picXml);
        } catch (XmlException xe) {
            xe.printStackTrace();
        }
        inline.set(xmlToken);
        inline.setDistT(0);
        inline.setDistB(0);
        inline.setDistL(0);
        inline.setDistR(0);
        CTPositiveSize2D extent = inline.addNewExtent();
        extent.setCx(width);
        extent.setCy(height);
        CTNonVisualDrawingProps docPr = inline.addNewDocPr();
        docPr.setId(id);
        docPr.setName("IMG_" + id);
        docPr.setDescr("IMG_" + id);
    }
    
    
/**
 * 根据图片类型,取得对应的图片类型代码
 *
 * @param picType
 * @return int
 */
 private static int getPictureType(String picType) {
    int res = XWPFDocument.PICTURE_TYPE_PICT;
    if (picType != null) {
        if ("png".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_PNG;
        } else if ("dib".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_DIB;
        } else if ("emf".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_EMF;
        } else if ("jpg".equalsIgnoreCase(picType) || "jpeg".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_JPEG;
        } else if ("wmf".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_WMF;
        }
    }
    return res;
}

private static String getPicXml(String blipId, int width, int height) {
    String picXml = "" + "<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">" + "   <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">" + "      <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">" + "         <pic:nvPicPr>" + "            <pic:cNvPr id="" + 0 + "" name="Generated"/>" + "            <pic:cNvPicPr/>" + "         </pic:nvPicPr>" + "         <pic:blipFill>" + "            <a:blip r:embed="" + blipId + "" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>" + "            <a:stretch>" + "               <a:fillRect/>" + "            </a:stretch>" + "         </pic:blipFill>" + "         <pic:spPr>" + "            <a:xfrm>"
 + "               <a:off x="0" y="0"/>" + "               <a:ext cx="" + width + "" cy="" + height + ""/>" + "            </a:xfrm>" + "            <a:prstGeom prst="rect">" + "               <a:avLst/>" + "            </a:prstGeom>" + "         </pic:spPr>" + "      </pic:pic>" + "   </a:graphicData>" + "</a:graphic>";
    return picXml;
}

最后填充就是这个样子:

image.png

word转pdf

最后在补充一个知识点,有时候会需要我们把 word 文档转成 pdf,其实用的是收费插件 aspose-words,在文末有github地址,我已经把插件放到doc目录下,有需要可自行下载。

<!-- Word文档转换 -->
<dependency>
 <groupId>com.aspose.words</groupId>
 <artifactId>aspose-words-jdk16</artifactId>
 <version>16.4.0</version>
</dependency>

接下来看下代码:

public class AsposeWordUtil {

   private static final String WIN = "win";
   
/**
 * word转pdf 需引入 aspose-words-16.4.0-jdk16.jar包 收费插件windows linux下均可用
 *
 * @param inPath
 * 源文件路径
 * @param outPath
 * 输出文件路径
 */
 public static void convertPdfToDocx(String inPath, String outPath) {
      try {
         FontSettings fontSettings = new FontSettings();
         File file = new File(outPath);
         FileOutputStream os = new FileOutputStream(file);

         Document doc = new Document(inPath); 
         // 另外服务器需要上传中文字体到/usr/share/fonts目录(复制windowsC:WindowsFonts目录下的字体文件即可)
         String cos = System.getProperty("os.name");
         if (cos.toLowerCase().startsWith(WIN)) {
            // windows环境
            fontSettings.setFontsFolder("C:/Windows/Fonts", false);
         } else {
            // Linux环境
            fontSettings.setFontsFolder("/usr/share/fonts", false);
         }
         doc.setFontSettings(fontSettings);
         // 全面支持DOC, DOCX, OOXML, RTF HTML,OpenDocument, PDF,EPUB, XPS, SWF 相互转换
         doc.save(os, SaveFormat.PDF);
         os.close();
      } catch (Exception e) {
         e.printStackTrace();
      }
   }
}

注意:Linux服务器上需要上传中文字体,用法也很简单,只要传入word文档路径和要生成的pdf路径就可以了。

String wordPath = "C:Users\\likun\\Desktop\\人员基本情况.docx";
String pdfPath = "C:Users\\likun\\Desktop\\人员基本情况.pdf";
AsposeWordUtil.convertPdfToDocx(wordPath, pdfPath);

效果图:

image.png

总结

代码已上传Github:https://github.com/chenwuguii/wugui

代码写到这里也就结束了,大伙可根据业务需求自行进行调整,如果有什么不对的地方请多多指教。

image

查看原文

超大只乌龟 发布了文章 · 2020-12-03

Java填充word模板

前言

本文主要有以下几个知识点:

  • 占位符填充
  • 头像导出
  • 表格填充
  • word转pdf

先来看下最后的效果图:

image.png

正文

模板配置

先来看下我们的模板:

image.png

首先我们需要先在word模板里面设置占位符,这里有一个非常重要的点就是我们是根据${占位符}来替换的,其实word文档本质上就是一个xml文件,因此我们需要保证占位符不被切割,具体做法如下:

1.首先用解压工具打开模板

image.png

2.打开document.xml文件

image.png

3.可以看出文件并未格式化,我们先格式化代码

image.png

image.png

4.可以看到我们的占位符被切割了,我们需要干掉中间多余的。

image.png

image.png

5.点击开始后直接覆盖源文件就可以了,现在可以开始写代码了。

image.png

注意要保证我们的每个占位符不被切割,否则是无法进行替换的

模板填充

导入jar

<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi</artifactId>
 <version>4.1.2</version>
</dependency>
<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi-scratchpad</artifactId>
 <version>4.1.2</version>
</dependency>
<dependency>
 <groupId>org.apache.poi</groupId>
 <artifactId>poi-ooxml</artifactId>
 <version>4.1.2</version>
</dependency>

首先看下我们两个表格的实体类(不一定要给表格建对象,根据个人需求添加即可)

// 家庭成员
@Builder(toBuilder = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EpRewandpun {
    private String urewdate;
    private String urewunit;
    private String urewdesc;
}

// 奖惩情况
@Builder(toBuilder = true)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EpPmenber {
    private String uconnection;
    private String uname;
    private String ubirthday;
    private String uworkunit;
    private String uploity;
    private String ustatus;
}

接着看下我们的测试方法:

@SpringBootTest
class Tests {
    @Test
    void contextLoads() throws IOException {
        String template = "C:UserslikunDesktop员工基本情况表.docx";
        Map<String, Object> paramMap = new HashMap<>();
        paramMap.put("${uname}", "乌龟");
        paramMap.put("${usex}", "男");
        paramMap.put("${ubirthdate}", "1998年10月22日");
        paramMap.put("${unation}", "汉族");
        paramMap.put("${unative}", "广东深圳");
        paramMap.put("${uplace}", "广东汕头");
        paramMap.put("${upolity}", "团员");
        paramMap.put("${uworkdate}", "2020年3月16日");
        paramMap.put("${uhealth}", "良好");
        paramMap.put("${umajorpost}", "软件开发");
        paramMap.put("${umajor}", "Java开发");
        
        // 照片路径以及大小
        Map<String, Object> phomap = new HashMap<>(8);
        phomap.put("width", 100);
        phomap.put("height", 130);
        phomap.put("type", "png");
        phomap.put("content", "E:\\Photo\\头像.jpg");
        paramMap.put("${upho}", phomap);
        
        //查询员工家庭信息
        List<EpPmenber> menberlist = new ArrayList<>();
        for (int i = 1; i < 3; i++) {
            EpPmenber pmenber = new EpPmenber();
            pmenber.setUname("小王");
            pmenber.setUconnection("父亲");
            pmenber.setUbirthday("1962年10月2日");
            pmenber.setUploity("群众");
            pmenber.setUworkunit("广东xxx公司");
            pmenber.setUstatus("无");
            menberlist.add(pmenber);
        }
        paramMap.put("menberlist", menberlist);
        
        //查询员工奖励情况
        List<EpRewandpun> andpunlist = new ArrayList<>();
        for (int i = 1; i < 3; i++) {
            EpRewandpun rewandpun = new EpRewandpun();
            rewandpun.setUrewdate("2020年5月1日");
            rewandpun.setUrewunit("深圳xxx有限公司");
            rewandpun.setUrewdesc("无");
            andpunlist.add(rewandpun);
        }
        paramMap.put("andpunlist", andpunlist);
        
        // 模板填充
        XWPFDocument doc = WordUtil.generateWord(paramMap, template);
        FileOutputStream fopts = new FileOutputStream("C:Users\\likun\\Desktop\\模板填充.docx");
        doc.write(fopts);
        fopts.close();
    }
}

代码比较简单不再细说,先来看下我们的 generateWord 核心方法:

public class WordUtil {

    /**
     * 根据指定的参数值、模板,生成 word 文档
     * 注意:其它模板需要根据情况进行调整
     *
     * @param param    变量集合
     * @param template 模板路径
     */
   public static XWPFDocument generateWord(Map<String, Object> param, String template) {
    XWPFDocument doc = null;
    try {
        OPCPackage pack = POIXMLDocument.openPackage(template);
        doc = new XWPFDocument(pack);
        if (param != null && param.size() > 0) {
            // 处理段落
            List<XWPFParagraph> paragraphList = doc.getParagraphs();
            processParagraphs(paragraphList, param, doc);
            // 处理表格
            Iterator<XWPFTable> it = doc.getTablesIterator();
            //表格索引
            int i = 0;
            List<EpPmenber> menberlist = (List<EpPmenber>) param.get("menberlist");
            List<EpRewandpun> andpunlist = (List<EpRewandpun>) param.get("andpunlist");
            while (it.hasNext()) {
                XWPFTable table = it.next();
                int size = table.getRows().size() - 1;
                XWPFTableRow row2 = table.getRow(size);
                if (i == 1) {//家庭成员
                    if (menberlist.size() > 0) {
                        for (int j = 0; j < menberlist.size() - 1; j++) {
                            copy(table, row2, size + j);
                        }
                    }
                } else if (i == 2) {//奖惩情况
                    if (andpunlist.size() > 0) {
                        for (int j = 0; j < andpunlist.size() - 1; j++) {
                            copy(table, row2, size + j);
                        }
                    }
                }
                List<XWPFTableRow> rows = table.getRows();
                int _row = 0;
                for (XWPFTableRow row : rows) {
                    List<XWPFTableCell> cells = row.getTableCells();
                    for (XWPFTableCell cell : cells) {
                        List<XWPFParagraph> paragraphListTable = cell.getParagraphs();
                        processParagraphs(paragraphListTable, param, doc);
                    }
                    // 家庭成员
                    if (i == 1 && _row >= size) {
                        if (menberlist.size() == 0) {
                            _row++;
                            continue;
                        }
                        row.getCell(0).setText(menberlist.get(_row - size).getUconnection());
                        row.getCell(1).setText(menberlist.get(_row - size).getUname());
                        row.getCell(2).setText(menberlist.get(_row - size).getUbirthday());
                        row.getCell(3).setText(menberlist.get(_row - size).getUploity());
                        row.getCell(4).setText(menberlist.get(_row - size).getUworkunit());
                        row.getCell(5).setText(menberlist.get(_row - size).getUstatus());
                    } else if (i == 2 && _row >= size) {//奖励情况
                         if (andpunlist.size() == 0) {
                            _row++;
                            continue;
                        }
                        row.getCell(0).setText(andpunlist.get(_row - size).getUrewdate());
                        row.getCell(1).setText(andpunlist.get(_row - size).getUrewunit());
                        row.getCell(2).setText(andpunlist.get(_row - size).getUrewdesc());
                    }
                    _row++;
                }
                i++;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return doc;
}

看下我们的 copy 方法,用来拷贝行属性进行对表格进行复制

/**
 * 拷贝赋值行
 *
 */
public static void copy(XWPFTable table, XWPFTableRow sourceRow, int rowIndex) {
    // 在表格指定位置新增一行
    XWPFTableRow targetRow = table.insertNewTableRow(rowIndex);
    // 复制行属性
    targetRow.getCtRow().setTrPr(sourceRow.getCtRow().getTrPr());
    List<XWPFTableCell> cellList = sourceRow.getTableCells();
    if (null == cellList) {
        return;
    }
    // 复制列及其属性和内容
    XWPFTableCell targetCell = null;
    for (XWPFTableCell sourceCell : cellList) {
        targetCell = targetRow.addNewTableCell();
        // 列属性
        targetCell.getCTTc().setTcPr(sourceCell.getCTTc().getTcPr());
        // 段落属性
        if (sourceCell.getParagraphs() != null && sourceCell.getParagraphs().size() > 0) {
            targetCell.getParagraphs().get(0).getCTP().setPPr(sourceCell.getParagraphs().get(0).getCTP().getPPr());
            if (sourceCell.getParagraphs().get(0).getRuns() != null && sourceCell.getParagraphs().get(0).getRuns().size() > 0) {
                XWPFRun cellR = targetCell.getParagraphs().get(0).createRun();
                cellR.setText(sourceCell.getText());
                cellR.setBold(sourceCell.getParagraphs().get(0).getRuns().get(0).isBold());
            } else {
                targetCell.setText(sourceCell.getText());
            }
        } else {
            targetCell.setText(sourceCell.getText());
        }
    }
}

接下来是我们的 processParagraphs 方法,用来进行占位符的替换以及头像的插入。

/**
 * 处理段落
 */
@SuppressWarnings({"unused", "rawtypes"})
public static void processParagraphs(List<XWPFParagraph> paragraphList, Map<String, Object> param, XWPFDocument doc) throws InvalidFormatException, IOException {
    if (paragraphList != null && paragraphList.size() > 0) {
        for (XWPFParagraph paragraph : paragraphList) {
            List<XWPFRun> runs = paragraph.getRuns();
            for (XWPFRun run : runs) {
                String text = run.getText(0);
                if (text != null) {
                    boolean isSetText = false;
                    for (Entry<String, Object> entry : param.entrySet()) {
                        String key = entry.getKey();
                        if (text.contains(key)) {
                            isSetText = true;
                            Object value;
                            if (entry.getValue() != null) {
                                value = entry.getValue();
                            } else {
                                value = "";
                            }
                            // 文本替换
                          if (value instanceof String) {
                            // 处理答案中的回车换行
                          if (((String) value).contains("n")) {
                                    String[] lines = ((String) value).split("n");
                                    if (lines.length > 0) {
                                        text = text.replace(key, lines[0]);
                                        for (int j = 1; j < lines.length; j++) {
                                            run.addCarriageReturn();
                                            run.setText(lines[j]);
                                        }
                                    }
                                } else {
                                    text = text.replace(key, value.toString());
                                }
                            } else if (value instanceof Map) {
                                // 图片替换
                                text = text.replace(key, "");
                                Map pic = (Map) value;
                                int width = Integer.parseInt(pic.get("width").toString());
                                int height = Integer.parseInt(pic.get("height").toString());
                                int picType = getPictureType(pic.get("type").toString());
                                String byteArray = (String) pic.get("content");
                                CTInline inline = run.getCTR().addNewDrawing().addNewInline();
                                //插入图片
                                insertPicture(doc, byteArray, inline, width, height, paragraph);
                            }
                        }
                    }
                    if (isSetText) {
                        run.setText(text, 0);
                    }
                }
            }
        }
    }
}

接下来是 insertPicture 方法,用来插入图片,以及 getPictureType 方法获取图片类型。

/**
 * 插入图片
 *
 */
 private static void insertPicture(XWPFDocument document, String filePath, CTInline inline, int width, int height, XWPFParagraph paragraph) throws Exception {
        // 读取图片路径
        InputStream inputStream = new FileInputStream(new File(filePath));;
        
        document.addPictureData(inputStream, XWPFDocument.PICTURE_TYPE_PNG);
        int id = document.getAllPictures().size() - 1;
        final int emu = 9525;
        width *= emu;
        height *= emu;
        String blipId = paragraph.getDocument().getRelationId(document.getAllPictures().get(id));

        String picXml = getPicXml(blipId, width, height);
        XmlToken xmlToken = null;
        try {
            xmlToken = XmlToken.Factory.parse(picXml);
        } catch (XmlException xe) {
            xe.printStackTrace();
        }
        inline.set(xmlToken);
        inline.setDistT(0);
        inline.setDistB(0);
        inline.setDistL(0);
        inline.setDistR(0);
        CTPositiveSize2D extent = inline.addNewExtent();
        extent.setCx(width);
        extent.setCy(height);
        CTNonVisualDrawingProps docPr = inline.addNewDocPr();
        docPr.setId(id);
        docPr.setName("IMG_" + id);
        docPr.setDescr("IMG_" + id);
    }
    
    
/**
 * 根据图片类型,取得对应的图片类型代码
 *
 * @param picType
 * @return int
 */
 private static int getPictureType(String picType) {
    int res = XWPFDocument.PICTURE_TYPE_PICT;
    if (picType != null) {
        if ("png".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_PNG;
        } else if ("dib".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_DIB;
        } else if ("emf".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_EMF;
        } else if ("jpg".equalsIgnoreCase(picType) || "jpeg".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_JPEG;
        } else if ("wmf".equalsIgnoreCase(picType)) {
            res = XWPFDocument.PICTURE_TYPE_WMF;
        }
    }
    return res;
}

private static String getPicXml(String blipId, int width, int height) {
    String picXml = "" + "<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">" + "   <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">" + "      <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">" + "         <pic:nvPicPr>" + "            <pic:cNvPr id="" + 0 + "" name="Generated"/>" + "            <pic:cNvPicPr/>" + "         </pic:nvPicPr>" + "         <pic:blipFill>" + "            <a:blip r:embed="" + blipId + "" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>" + "            <a:stretch>" + "               <a:fillRect/>" + "            </a:stretch>" + "         </pic:blipFill>" + "         <pic:spPr>" + "            <a:xfrm>"
 + "               <a:off x="0" y="0"/>" + "               <a:ext cx="" + width + "" cy="" + height + ""/>" + "            </a:xfrm>" + "            <a:prstGeom prst="rect">" + "               <a:avLst/>" + "            </a:prstGeom>" + "         </pic:spPr>" + "      </pic:pic>" + "   </a:graphicData>" + "</a:graphic>";
    return picXml;
}

最后填充就是这个样子:

image.png

word转pdf

最后在补充一个知识点,有时候会需要我们把 word 文档转成 pdf,其实用的是收费插件 aspose-words,在文末有github地址,我已经把插件放到doc目录下,有需要可自行下载。

<!-- Word文档转换 -->
<dependency>
 <groupId>com.aspose.words</groupId>
 <artifactId>aspose-words-jdk16</artifactId>
 <version>16.4.0</version>
</dependency>

接下来看下代码:

public class AsposeWordUtil {

   private static final String WIN = "win";
   
/**
 * word转pdf 需引入 aspose-words-16.4.0-jdk16.jar包 收费插件windows linux下均可用
 *
 * @param inPath
 * 源文件路径
 * @param outPath
 * 输出文件路径
 */
 public static void convertPdfToDocx(String inPath, String outPath) {
      try {
         FontSettings fontSettings = new FontSettings();
         File file = new File(outPath);
         FileOutputStream os = new FileOutputStream(file);

         Document doc = new Document(inPath); 
         // 另外服务器需要上传中文字体到/usr/share/fonts目录(复制windowsC:WindowsFonts目录下的字体文件即可)
         String cos = System.getProperty("os.name");
         if (cos.toLowerCase().startsWith(WIN)) {
            // windows环境
            fontSettings.setFontsFolder("C:/Windows/Fonts", false);
         } else {
            // Linux环境
            fontSettings.setFontsFolder("/usr/share/fonts", false);
         }
         doc.setFontSettings(fontSettings);
         // 全面支持DOC, DOCX, OOXML, RTF HTML,OpenDocument, PDF,EPUB, XPS, SWF 相互转换
         doc.save(os, SaveFormat.PDF);
         os.close();
      } catch (Exception e) {
         e.printStackTrace();
      }
   }
}

注意:Linux服务器上需要上传中文字体,用法也很简单,只要传入word文档路径和要生成的pdf路径就可以了。

String wordPath = "C:Users\\likun\\Desktop\\人员基本情况.docx";
String pdfPath = "C:Users\\likun\\Desktop\\人员基本情况.pdf";
AsposeWordUtil.convertPdfToDocx(wordPath, pdfPath);

效果图:

image.png

总结

代码已上传Github:https://github.com/chenwuguii/wugui

代码写到这里也就结束了,大伙可根据业务需求自行进行调整,如果有什么不对的地方请多多指教。

image

查看原文

赞 12 收藏 11 评论 10

超大只乌龟 收藏了文章 · 2020-12-01

MySQL递归查询上下级菜单

正文

在传统的后台管理系统里面经常会需要展示多级菜单关系,今天我们来学一下如何使用一条SQL语句展示多级菜单。

现在我们有一张corpinfo单位表,里面有一个belong字段指向上级单位,首先来看一下现在表里有什么数据:

SELECT uid,ubelong FROM corpinfo

image.png

现在是类似下面这样的一个三级菜单,uid为1的是我们的顶级菜单,ubelog为0。

image.png

现在我们想实现传入一个 uid ,把当前 uid 和其下级单位的 uid 都展示出来,当然我们可以使用代码或者网上常见的存储过程来实现,但是今天我们用一条SQL语句来实现该效果:

先来看看我们的SQL语句

SELECT DATA.uid FROM(
      SELECT
      @ids AS _ids,
      (
      SELECT @ids := GROUP_CONCAT(uid)
      FROM corpinfo
      WHERE FIND_IN_SET(ubelong, @ids)
      ) AS cids,
      @l := @l+1 AS level
      FROM corpinfo, (SELECT @ids := (参数) , @l := 0 ) b
      WHERE @ids IS NOT NULL
      ) ID, corpinfo DATA
WHERE FIND_IN_SET(DATA.uid, ID._ids)
ORDER BY level,uid

看下执行结果:

image.png

可以看到传入 uid 为 1 后,列出了 uid 为 1 的所有下级单位,连第三级的菜单也列出来了。

image

比较核心的有下面几个地方:

image.png

GROUP_CONCAT()函数

前言:在有 group by 的查询语句中,select指定的字段要么就包含在 group by 语句的后面,作为分组的依据,要么就包含在聚合函数中。

假设我们有一张 user 用户表,我们想查看名字相同的用户的最小年龄,可以这样写:

SELECT name,age FROM user GROUP BY name

执行结果为:

image.png

现在我们想查询 name 相同的用户的所有年龄,当然我们可以这样写:

SELECT name,age FROM user ORDER BY name

ORDER BY对名字排序,执行结果为:

image.png

但是这样同一个名字出现多次,看上去非常不直观。有没有更直观的方法,既让每个名字都只出现一次,又能够显示所有的名字相同的人的id呢?——使用 GROUP_CONCAT() 函数

功能:group by 产生的同一个分组中的值连接起来,返回一个字符串结果。

语法:GROUP_CONCAT( [distinct] 要连接的字段 [ORDER BY 排序字段 ASC/DESC ] [separator '分隔符'] )

SELECT name,GROUP_CONCAT(age) FROM user GROUP BY name

执行结果为:

image.png

可以看到相同用户名的年龄都放到一起了,以逗号分割。

FIND_IN_SET函数

假设我们有一张 book 书籍表,有书名和作者两个字段:

SELECT name,author FROM book

执行结果为:

image.png

现在我们想查作者包含 小A 的书籍,我们来试一下:

SELECT name,author FROM book WHERE author IN ('小A')

执行结果为:

image.png

实际上这样是不行的,这样只有当 author 字段的值等于'小A'时(和IN前面的字符串完全匹配),查询才有效,否则都得不到结果。

可能你会想到用LIKE实现,我们来试试看:

SELECT name,author FROM book WHERE author LIKE '%小A%';

执行结果为:

image.png

可以看到把小AA的书籍也查出来了,所以用LIKE无法实现该功能。

那么我们如何使用 FIND_IN_SET 函数来实现呢?

SELECT name,author FROM book WHERE FIND_IN_SET('小A',author);

执行结果为:

image.png

语法:FIND_IN_SET(str,strlist)

str :要查询的字符串
strlist :字段名 参数以”,”分隔 如 (1,2,6,8)
查询字段(strlist)中包含(str)的结果,返回结果为null或记录

知道了这两个函数后,现在回过头来看看前面的SQL语句:

image.png

运行选中的代码后可以看到列出了上下级的关系,至于细节这里不再展开描述。

既然我们能查出当前单位的所有下级单位,那么应该也能查询所有上级单位,来看下SQL:

   SELECT uid FROM(
        SELECT
        @id AS _id,
        ( SELECT @id := ubelong
        FROM corpinfo
        WHERE uid = @id
        ) AS _pid,
        @l := @l+1 as level
        FROM corpinfo,
        (SELECT @id := (参数), @l := 0 ) b
        WHERE @id > 0
        ) ID, corpinfo DATA
        WHERE ID._id = DATA.uid
   ORDER BY level DESC

还是我们的corpinfo单位表,执行结果为:

image.png

可以看到当输入 uid 为 5 时,列出了当前单位及其上级所有单位,SQL和上面的差不多,这里不再细说。

最后补充一段代码,既然我们已经拿到想要的单位编号了,接下来就是要递归构建我们的单位树了,来看下代码:

/**
 * 递归将模块树构建成JSON数组
 */
private JSONArray getJsonArray(List<ClCorpinfo> list) {
    Map<Integer, List<ClCorpinfo>> map = new HashMap<>(16);
    List<ClCorpinfo> sonList;
    for (ClCorpinfo clCorpinfo : list) {
        if (map.get(clCorpinfo.getUbelong()) != null) {
            sonList = map.get(clCorpinfo.getUbelong());
        } else {
            sonList = new ArrayList<>();
        }
        sonList.add(clCorpinfo);
        map.put(clCorpinfo.getUbelong(), sonList);
    }
    JSONArray array = new JSONArray();
    if (list.size() > 0) {
        array = getChildrenTree(map, 0, 0);
    }
    return array;
}
/**
 * 递归构建模块树的子类
 */
public JSONArray getChildrenTree(Map<Integer, List<ClCorpinfo>> map, Integer uparentid, Integer level) {
    JSONArray array = new JSONArray();
    for (ClCorpinfo clCorpinfo : map.get(uparentid)) {
        JSONObject obj = new JSONObject();
        obj.put("uid", clCorpinfo.getUid());
        obj.put("ubelong", clCorpinfo.getUbelong());
        obj.put("ucorpname", clCorpinfo.getUcorpname());
        obj.put("uparentname", clCorpinfo.getUparentname());
        if (map.get(clCorpinfo.getUid()) != null) {
            level++;
            obj.put("children", getChildrenTree(map, clCorpinfo.getUid(), level));
        } else {
            obj.put("children", null);
        }
        array.add(obj);
    }
    return array;
}

上面这段只要传入单位集合,接下来会递归来构建我们的单位树,接下来只要前端渲染上去就完事了。

总结

其实网上也有很多其它的解决方案,比如用代码实现,也可以用存储过程实现,今天我们使用SQL语句来实现并不一定是最好的办法,虽然简单但是比较难懂,我这边只是给大伙提供一个可行的方案,如果有什么不对的地方请多多指教。

image

查看原文

超大只乌龟 收藏了文章 · 2020-12-01

MySQL递归查询上下级菜单

正文

在传统的后台管理系统里面经常会需要展示多级菜单关系,今天我们来学一下如何使用一条SQL语句展示多级菜单。

现在我们有一张corpinfo单位表,里面有一个belong字段指向上级单位,首先来看一下现在表里有什么数据:

SELECT uid,ubelong FROM corpinfo

image.png

现在是类似下面这样的一个三级菜单,uid为1的是我们的顶级菜单,ubelog为0。

image.png

现在我们想实现传入一个 uid ,把当前 uid 和其下级单位的 uid 都展示出来,当然我们可以使用代码或者网上常见的存储过程来实现,但是今天我们用一条SQL语句来实现该效果:

先来看看我们的SQL语句

SELECT DATA.uid FROM(
      SELECT
      @ids AS _ids,
      (
      SELECT @ids := GROUP_CONCAT(uid)
      FROM corpinfo
      WHERE FIND_IN_SET(ubelong, @ids)
      ) AS cids,
      @l := @l+1 AS level
      FROM corpinfo, (SELECT @ids := (参数) , @l := 0 ) b
      WHERE @ids IS NOT NULL
      ) ID, corpinfo DATA
WHERE FIND_IN_SET(DATA.uid, ID._ids)
ORDER BY level,uid

看下执行结果:

image.png

可以看到传入 uid 为 1 后,列出了 uid 为 1 的所有下级单位,连第三级的菜单也列出来了。

image

比较核心的有下面几个地方:

image.png

GROUP_CONCAT()函数

前言:在有 group by 的查询语句中,select指定的字段要么就包含在 group by 语句的后面,作为分组的依据,要么就包含在聚合函数中。

假设我们有一张 user 用户表,我们想查看名字相同的用户的最小年龄,可以这样写:

SELECT name,age FROM user GROUP BY name

执行结果为:

image.png

现在我们想查询 name 相同的用户的所有年龄,当然我们可以这样写:

SELECT name,age FROM user ORDER BY name

ORDER BY对名字排序,执行结果为:

image.png

但是这样同一个名字出现多次,看上去非常不直观。有没有更直观的方法,既让每个名字都只出现一次,又能够显示所有的名字相同的人的id呢?——使用 GROUP_CONCAT() 函数

功能:group by 产生的同一个分组中的值连接起来,返回一个字符串结果。

语法:GROUP_CONCAT( [distinct] 要连接的字段 [ORDER BY 排序字段 ASC/DESC ] [separator '分隔符'] )

SELECT name,GROUP_CONCAT(age) FROM user GROUP BY name

执行结果为:

image.png

可以看到相同用户名的年龄都放到一起了,以逗号分割。

FIND_IN_SET函数

假设我们有一张 book 书籍表,有书名和作者两个字段:

SELECT name,author FROM book

执行结果为:

image.png

现在我们想查作者包含 小A 的书籍,我们来试一下:

SELECT name,author FROM book WHERE author IN ('小A')

执行结果为:

image.png

实际上这样是不行的,这样只有当 author 字段的值等于'小A'时(和IN前面的字符串完全匹配),查询才有效,否则都得不到结果。

可能你会想到用LIKE实现,我们来试试看:

SELECT name,author FROM book WHERE author LIKE '%小A%';

执行结果为:

image.png

可以看到把小AA的书籍也查出来了,所以用LIKE无法实现该功能。

那么我们如何使用 FIND_IN_SET 函数来实现呢?

SELECT name,author FROM book WHERE FIND_IN_SET('小A',author);

执行结果为:

image.png

语法:FIND_IN_SET(str,strlist)

str :要查询的字符串
strlist :字段名 参数以”,”分隔 如 (1,2,6,8)
查询字段(strlist)中包含(str)的结果,返回结果为null或记录

知道了这两个函数后,现在回过头来看看前面的SQL语句:

image.png

运行选中的代码后可以看到列出了上下级的关系,至于细节这里不再展开描述。

既然我们能查出当前单位的所有下级单位,那么应该也能查询所有上级单位,来看下SQL:

   SELECT uid FROM(
        SELECT
        @id AS _id,
        ( SELECT @id := ubelong
        FROM corpinfo
        WHERE uid = @id
        ) AS _pid,
        @l := @l+1 as level
        FROM corpinfo,
        (SELECT @id := (参数), @l := 0 ) b
        WHERE @id > 0
        ) ID, corpinfo DATA
        WHERE ID._id = DATA.uid
   ORDER BY level DESC

还是我们的corpinfo单位表,执行结果为:

image.png

可以看到当输入 uid 为 5 时,列出了当前单位及其上级所有单位,SQL和上面的差不多,这里不再细说。

最后补充一段代码,既然我们已经拿到想要的单位编号了,接下来就是要递归构建我们的单位树了,来看下代码:

/**
 * 递归将模块树构建成JSON数组
 */
private JSONArray getJsonArray(List<ClCorpinfo> list) {
    Map<Integer, List<ClCorpinfo>> map = new HashMap<>(16);
    List<ClCorpinfo> sonList;
    for (ClCorpinfo clCorpinfo : list) {
        if (map.get(clCorpinfo.getUbelong()) != null) {
            sonList = map.get(clCorpinfo.getUbelong());
        } else {
            sonList = new ArrayList<>();
        }
        sonList.add(clCorpinfo);
        map.put(clCorpinfo.getUbelong(), sonList);
    }
    JSONArray array = new JSONArray();
    if (list.size() > 0) {
        array = getChildrenTree(map, 0, 0);
    }
    return array;
}
/**
 * 递归构建模块树的子类
 */
public JSONArray getChildrenTree(Map<Integer, List<ClCorpinfo>> map, Integer uparentid, Integer level) {
    JSONArray array = new JSONArray();
    for (ClCorpinfo clCorpinfo : map.get(uparentid)) {
        JSONObject obj = new JSONObject();
        obj.put("uid", clCorpinfo.getUid());
        obj.put("ubelong", clCorpinfo.getUbelong());
        obj.put("ucorpname", clCorpinfo.getUcorpname());
        obj.put("uparentname", clCorpinfo.getUparentname());
        if (map.get(clCorpinfo.getUid()) != null) {
            level++;
            obj.put("children", getChildrenTree(map, clCorpinfo.getUid(), level));
        } else {
            obj.put("children", null);
        }
        array.add(obj);
    }
    return array;
}

上面这段只要传入单位集合,接下来会递归来构建我们的单位树,接下来只要前端渲染上去就完事了。

总结

其实网上也有很多其它的解决方案,比如用代码实现,也可以用存储过程实现,今天我们使用SQL语句来实现并不一定是最好的办法,虽然简单但是比较难懂,我这边只是给大伙提供一个可行的方案,如果有什么不对的地方请多多指教。

image

查看原文

超大只乌龟 收藏了文章 · 2020-11-26

MySQL递归查询上下级菜单

正文

在传统的后台管理系统里面经常会需要展示多级菜单关系,今天我们来学一下如何使用一条SQL语句展示多级菜单。

现在我们有一张corpinfo单位表,里面有一个belong字段指向上级单位,首先来看一下现在表里有什么数据:

SELECT uid,ubelong FROM corpinfo

image.png

现在是类似下面这样的一个三级菜单,uid为1的是我们的顶级菜单,ubelog为0。

image.png

现在我们想实现传入一个 uid ,把当前 uid 和其下级单位的 uid 都展示出来,当然我们可以使用代码或者网上常见的存储过程来实现,但是今天我们用一条SQL语句来实现该效果:

先来看看我们的SQL语句

SELECT DATA.uid FROM(
      SELECT
      @ids AS _ids,
      (
      SELECT @ids := GROUP_CONCAT(uid)
      FROM corpinfo
      WHERE FIND_IN_SET(ubelong, @ids)
      ) AS cids,
      @l := @l+1 AS level
      FROM corpinfo, (SELECT @ids := (参数) , @l := 0 ) b
      WHERE @ids IS NOT NULL
      ) ID, corpinfo DATA
WHERE FIND_IN_SET(DATA.uid, ID._ids)
ORDER BY level,uid

看下执行结果:

image.png

可以看到传入 uid 为 1 后,列出了 uid 为 1 的所有下级单位,连第三级的菜单也列出来了。

image

比较核心的有下面几个地方:

image.png

GROUP_CONCAT()函数

前言:在有 group by 的查询语句中,select指定的字段要么就包含在 group by 语句的后面,作为分组的依据,要么就包含在聚合函数中。

假设我们有一张 user 用户表,我们想查看名字相同的用户的最小年龄,可以这样写:

SELECT name,age FROM user GROUP BY name

执行结果为:

image.png

现在我们想查询 name 相同的用户的所有年龄,当然我们可以这样写:

SELECT name,age FROM user ORDER BY name

ORDER BY对名字排序,执行结果为:

image.png

但是这样同一个名字出现多次,看上去非常不直观。有没有更直观的方法,既让每个名字都只出现一次,又能够显示所有的名字相同的人的id呢?——使用 GROUP_CONCAT() 函数

功能:group by 产生的同一个分组中的值连接起来,返回一个字符串结果。

语法:GROUP_CONCAT( [distinct] 要连接的字段 [ORDER BY 排序字段 ASC/DESC ] [separator '分隔符'] )

SELECT name,GROUP_CONCAT(age) FROM user GROUP BY name

执行结果为:

image.png

可以看到相同用户名的年龄都放到一起了,以逗号分割。

FIND_IN_SET函数

假设我们有一张 book 书籍表,有书名和作者两个字段:

SELECT name,author FROM book

执行结果为:

image.png

现在我们想查作者包含 小A 的书籍,我们来试一下:

SELECT name,author FROM book WHERE author IN ('小A')

执行结果为:

image.png

实际上这样是不行的,这样只有当 author 字段的值等于'小A'时(和IN前面的字符串完全匹配),查询才有效,否则都得不到结果。

可能你会想到用LIKE实现,我们来试试看:

SELECT name,author FROM book WHERE author LIKE '%小A%';

执行结果为:

image.png

可以看到把小AA的书籍也查出来了,所以用LIKE无法实现该功能。

那么我们如何使用 FIND_IN_SET 函数来实现呢?

SELECT name,author FROM book WHERE FIND_IN_SET('小A',author);

执行结果为:

image.png

语法:FIND_IN_SET(str,strlist)

str :要查询的字符串
strlist :字段名 参数以”,”分隔 如 (1,2,6,8)
查询字段(strlist)中包含(str)的结果,返回结果为null或记录

知道了这两个函数后,现在回过头来看看前面的SQL语句:

image.png

运行选中的代码后可以看到列出了上下级的关系,至于细节这里不再展开描述。

既然我们能查出当前单位的所有下级单位,那么应该也能查询所有上级单位,来看下SQL:

   SELECT uid FROM(
        SELECT
        @id AS _id,
        ( SELECT @id := ubelong
        FROM corpinfo
        WHERE uid = @id
        ) AS _pid,
        @l := @l+1 as level
        FROM corpinfo,
        (SELECT @id := (参数), @l := 0 ) b
        WHERE @id > 0
        ) ID, corpinfo DATA
        WHERE ID._id = DATA.uid
   ORDER BY level DESC

还是我们的corpinfo单位表,执行结果为:

image.png

可以看到当输入 uid 为 5 时,列出了当前单位及其上级所有单位,SQL和上面的差不多,这里不再细说。

最后补充一段代码,既然我们已经拿到想要的单位编号了,接下来就是要递归构建我们的单位树了,来看下代码:

/**
 * 递归将模块树构建成JSON数组
 */
private JSONArray getJsonArray(List<ClCorpinfo> list) {
    Map<Integer, List<ClCorpinfo>> map = new HashMap<>(16);
    List<ClCorpinfo> sonList;
    for (ClCorpinfo clCorpinfo : list) {
        if (map.get(clCorpinfo.getUbelong()) != null) {
            sonList = map.get(clCorpinfo.getUbelong());
        } else {
            sonList = new ArrayList<>();
        }
        sonList.add(clCorpinfo);
        map.put(clCorpinfo.getUbelong(), sonList);
    }
    JSONArray array = new JSONArray();
    if (list.size() > 0) {
        array = getChildrenTree(map, 0, 0);
    }
    return array;
}
/**
 * 递归构建模块树的子类
 */
public JSONArray getChildrenTree(Map<Integer, List<ClCorpinfo>> map, Integer uparentid, Integer level) {
    JSONArray array = new JSONArray();
    for (ClCorpinfo clCorpinfo : map.get(uparentid)) {
        JSONObject obj = new JSONObject();
        obj.put("uid", clCorpinfo.getUid());
        obj.put("ubelong", clCorpinfo.getUbelong());
        obj.put("ucorpname", clCorpinfo.getUcorpname());
        obj.put("uparentname", clCorpinfo.getUparentname());
        if (map.get(clCorpinfo.getUid()) != null) {
            level++;
            obj.put("children", getChildrenTree(map, clCorpinfo.getUid(), level));
        } else {
            obj.put("children", null);
        }
        array.add(obj);
    }
    return array;
}

上面这段只要传入单位集合,接下来会递归来构建我们的单位树,接下来只要前端渲染上去就完事了。

总结

其实网上也有很多其它的解决方案,比如用代码实现,也可以用存储过程实现,今天我们使用SQL语句来实现并不一定是最好的办法,虽然简单但是比较难懂,我这边只是给大伙提供一个可行的方案,如果有什么不对的地方请多多指教。

image

查看原文

超大只乌龟 发布了文章 · 2020-11-26

MySQL递归查询上下级菜单

正文

在传统的后台管理系统里面经常会需要展示多级菜单关系,今天我们来学一下如何使用一条SQL语句展示多级菜单。

现在我们有一张corpinfo单位表,里面有一个belong字段指向上级单位,首先来看一下现在表里有什么数据:

SELECT uid,ubelong FROM corpinfo

image.png

现在是类似下面这样的一个三级菜单,uid为1的是我们的顶级菜单,ubelog为0。

image.png

现在我们想实现传入一个 uid ,把当前 uid 和其下级单位的 uid 都展示出来,当然我们可以使用代码或者网上常见的存储过程来实现,但是今天我们用一条SQL语句来实现该效果:

先来看看我们的SQL语句

SELECT DATA.uid FROM(
      SELECT
      @ids AS _ids,
      (
      SELECT @ids := GROUP_CONCAT(uid)
      FROM corpinfo
      WHERE FIND_IN_SET(ubelong, @ids)
      ) AS cids,
      @l := @l+1 AS level
      FROM corpinfo, (SELECT @ids := (参数) , @l := 0 ) b
      WHERE @ids IS NOT NULL
      ) ID, corpinfo DATA
WHERE FIND_IN_SET(DATA.uid, ID._ids)
ORDER BY level,uid

看下执行结果:

image.png

可以看到传入 uid 为 1 后,列出了 uid 为 1 的所有下级单位,连第三级的菜单也列出来了。

image

比较核心的有下面几个地方:

image.png

GROUP_CONCAT()函数

前言:在有 group by 的查询语句中,select指定的字段要么就包含在 group by 语句的后面,作为分组的依据,要么就包含在聚合函数中。

假设我们有一张 user 用户表,我们想查看名字相同的用户的最小年龄,可以这样写:

SELECT name,age FROM user GROUP BY name

执行结果为:

image.png

现在我们想查询 name 相同的用户的所有年龄,当然我们可以这样写:

SELECT name,age FROM user ORDER BY name

ORDER BY对名字排序,执行结果为:

image.png

但是这样同一个名字出现多次,看上去非常不直观。有没有更直观的方法,既让每个名字都只出现一次,又能够显示所有的名字相同的人的id呢?——使用 GROUP_CONCAT() 函数

功能:group by 产生的同一个分组中的值连接起来,返回一个字符串结果。

语法:GROUP_CONCAT( [distinct] 要连接的字段 [ORDER BY 排序字段 ASC/DESC ] [separator '分隔符'] )

SELECT name,GROUP_CONCAT(age) FROM user GROUP BY name

执行结果为:

image.png

可以看到相同用户名的年龄都放到一起了,以逗号分割。

FIND_IN_SET函数

假设我们有一张 book 书籍表,有书名和作者两个字段:

SELECT name,author FROM book

执行结果为:

image.png

现在我们想查作者包含 小A 的书籍,我们来试一下:

SELECT name,author FROM book WHERE author IN ('小A')

执行结果为:

image.png

实际上这样是不行的,这样只有当 author 字段的值等于'小A'时(和IN前面的字符串完全匹配),查询才有效,否则都得不到结果。

可能你会想到用LIKE实现,我们来试试看:

SELECT name,author FROM book WHERE author LIKE '%小A%';

执行结果为:

image.png

可以看到把小AA的书籍也查出来了,所以用LIKE无法实现该功能。

那么我们如何使用 FIND_IN_SET 函数来实现呢?

SELECT name,author FROM book WHERE FIND_IN_SET('小A',author);

执行结果为:

image.png

语法:FIND_IN_SET(str,strlist)

str :要查询的字符串
strlist :字段名 参数以”,”分隔 如 (1,2,6,8)
查询字段(strlist)中包含(str)的结果,返回结果为null或记录

知道了这两个函数后,现在回过头来看看前面的SQL语句:

image.png

运行选中的代码后可以看到列出了上下级的关系,至于细节这里不再展开描述。

既然我们能查出当前单位的所有下级单位,那么应该也能查询所有上级单位,来看下SQL:

   SELECT uid FROM(
        SELECT
        @id AS _id,
        ( SELECT @id := ubelong
        FROM corpinfo
        WHERE uid = @id
        ) AS _pid,
        @l := @l+1 as level
        FROM corpinfo,
        (SELECT @id := (参数), @l := 0 ) b
        WHERE @id > 0
        ) ID, corpinfo DATA
        WHERE ID._id = DATA.uid
   ORDER BY level DESC

还是我们的corpinfo单位表,执行结果为:

image.png

可以看到当输入 uid 为 5 时,列出了当前单位及其上级所有单位,SQL和上面的差不多,这里不再细说。

最后补充一段代码,既然我们已经拿到想要的单位编号了,接下来就是要递归构建我们的单位树了,来看下代码:

/**
 * 递归将模块树构建成JSON数组
 */
private JSONArray getJsonArray(List<ClCorpinfo> list) {
    Map<Integer, List<ClCorpinfo>> map = new HashMap<>(16);
    List<ClCorpinfo> sonList;
    for (ClCorpinfo clCorpinfo : list) {
        if (map.get(clCorpinfo.getUbelong()) != null) {
            sonList = map.get(clCorpinfo.getUbelong());
        } else {
            sonList = new ArrayList<>();
        }
        sonList.add(clCorpinfo);
        map.put(clCorpinfo.getUbelong(), sonList);
    }
    JSONArray array = new JSONArray();
    if (list.size() > 0) {
        array = getChildrenTree(map, 0, 0);
    }
    return array;
}
/**
 * 递归构建模块树的子类
 */
public JSONArray getChildrenTree(Map<Integer, List<ClCorpinfo>> map, Integer uparentid, Integer level) {
    JSONArray array = new JSONArray();
    for (ClCorpinfo clCorpinfo : map.get(uparentid)) {
        JSONObject obj = new JSONObject();
        obj.put("uid", clCorpinfo.getUid());
        obj.put("ubelong", clCorpinfo.getUbelong());
        obj.put("ucorpname", clCorpinfo.getUcorpname());
        obj.put("uparentname", clCorpinfo.getUparentname());
        if (map.get(clCorpinfo.getUid()) != null) {
            level++;
            obj.put("children", getChildrenTree(map, clCorpinfo.getUid(), level));
        } else {
            obj.put("children", null);
        }
        array.add(obj);
    }
    return array;
}

上面这段只要传入单位集合,接下来会递归来构建我们的单位树,接下来只要前端渲染上去就完事了。

总结

其实网上也有很多其它的解决方案,比如用代码实现,也可以用存储过程实现,今天我们使用SQL语句来实现并不一定是最好的办法,虽然简单但是比较难懂,我这边只是给大伙提供一个可行的方案,如果有什么不对的地方请多多指教。

image

查看原文

赞 11 收藏 10 评论 2

认证与成就

  • 获得 267 次点赞
  • 获得 7 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2019-09-15
个人主页被 5.5k 人浏览