整合营销服务商

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

免费咨询热线:

vue快速集成word在线编辑

vue快速集成word在线编辑

ord在线编辑

查看一些文档金格插件WebOffice2015、chrome浏览器插件、only-officeUEditorTinyMCECKEditorwangeditorcanvas-editor

最后选择了only-officecanvas-editor

only-office非常功能强大,word、ppt、excel都支持在线编辑预览,还支持协同,又有免费开源版。

附上本地运行demo:

一、安装docker

二、安装并启动 Onlyoffice 服务:

docker run -i -t -d -p 8701:80 onlyoffice/documentserver:版本号

如果是第 1 次执行这个命令,会先去下载 Onlyoffice,比较慢,约等待 3~10 分钟,网络畅通一点的会快一些。如果是已经安装过则直接进行启动。

三、启动内置服务

先执行 docker ps 查看 Onlyoffice 容器 ID:

# 注意这里要将 id 替换成自己的
docker exec -it f2a3eb675ad1 /bin/bash

然后执行 docker exec -it ID /bin/bash 进入容器,这里将获取到的 ID 替换一下:

# 启动所有的内置服务
supervisorctl restart all
# 退出容器
exit

最后访问 http://IP:8701/example 页面(这里要注意,IP 不能是 localhost 和 127.0.0.1,一定要用真实 IP 来访问)

因为开发周期,后端又比较懒不想花时间去看文档。这一方案被我放弃了

最后选择了canvas-editor

canvas-editor

为什么选它了,开发周期短,界面与word编辑器比较像,可以快速集成到vue,虽然作者没有开箱即用版。

在vue中主要实现方式就是采用开源项目代码。

在组件模块,新建vue文件,html采用开源项目代码,分3个部分,工具栏,侧边菜单,主要内容,底部工具,旁边批注。通过import引入开源样式,注意样式冲突。在onMounted,采用开源main.ts window.onload代码。

<div class="menu" editor-component="menu">
...
</div>
<div class="catalog" editor-component="catalog">
...
</div>
<div id="canvasEditor" class="canvas-editor"></div>
<div class="footer-canvas" editor-component="footer">
...
</div>
const instance=new Editor(
    document.querySelector('#canvasEditor'),
    {
      header: props.header,
      main: props.main,
      footer: props.footer
    },
    options
);
console.log('实例', instance);
editorRef.value=instance;

// 工具栏方法 例:
// 2. | 撤销 | 重做 | 格式刷 | 清除格式 |
const undoDom=document.querySelector('.menu-item__undo');
    undoDom.title=`撤销(${isApple ? '?' : 'Ctrl'}+Z)`;
    undoDom.onclick=function () {
    console.log('undo');
    instance.command.executeUndo();
};
<style lang="scss" scoped>
#canvasEditor {
  display: flex;
  justify-content: center;
  background: #f2f4f7;
}
@import url(@/assets/css/dialog.css);
@import url(@/assets/css/signature.css);
</style>

思路通过富文本编辑器实现在线编辑功能,通过插件提供的api获取图片base64和文本数据,通过接口保存至数据库,通过列表数据复显编辑,后端获取到图片数据转pdf或者其他格式都可以。自己转pdf则是通过html2canvas jspdf

使用方式

例:
<canvas-editor
  v-if="!loadingInit"
  :header="canvasEditor.header"
  :main="canvasEditor.main"
  :footer="canvasEditor.footer"
  :options="canvasEditor.options"
  @getCanvasEditorData="getCanvasEditorData"
/>
import CanvasEditor from '@/components/editor/index.vue';

components: {
  CanvasEditor
},

canvasEditor: {
  main: [],
  options: [],
  header: [],
  footer: []
},

// getCanvasEditorData 事件

主要获取富文本 main内容 image的base64 options配置项 用于数据保存

getCanvasEditorData: ({ value, imgage, options })=> {  
},


主要配置

    // 页眉配置
    header: {
      type: Array,
      default: []
    },
    // 主要编辑内容
    main: {
      type: Array,
      default: []
    },
    // 页脚信息
    footer: {
      type: Array,
      default: []
    },
    // 
    options: {
      type: Object,
      default: {}
    },
    // 批注 TODO
    commentList: {
      type: Array,
      default: []
    }

完整配置

interface IEditorOption {
  mode?: EditorMode // 编辑器模式:编辑、清洁(不显示视觉辅助元素。如:分页符)、只读、表单(仅控件内可编辑)、打印(不显示辅助元素、未书写控件及前后括号)。默认:编辑
  defaultType?: string // 默认元素类型。默认:TEXT
  defaultColor?: string // 默认字体颜色。默认:#000000
  defaultFont?: string // 默认字体。默认:Microsoft YaHei
  defaultSize?: number // 默认字号。默认:16
  minSize?: number // 最小字号。默认:5
  maxSize?: number // 最大字号。默认:72
  defaultBasicRowMarginHeight?: number // 默认行高。默认:8
  defaultRowMargin?: number // 默认行间距。默认:1
  defaultTabWidth?: number // 默认tab宽度。默认:32
  width?: number // 纸张宽度。默认:794
  height?: number // 纸张高度。默认:1123
  scale?: number // 缩放比例。默认:1
  pageGap?: number // 纸张间隔。默认:20
  underlineColor?: string // 下划线颜色。默认:#000000
  strikeoutColor?: string // 删除线颜色。默认:#FF0000
  rangeColor?: string // 选区颜色。默认:#AECBFA
  rangeAlpha?: number // 选区透明度。默认:0.6
  rangeMinWidth?: number // 选区最小宽度。默认:5
  searchMatchColor?: string // 搜索高亮颜色。默认:#FFFF00
  searchNavigateMatchColor?: string // 搜索导航高亮颜色。默认:#AAD280
  searchMatchAlpha?: number // 搜索高亮透明度。默认:0.6
  highlightAlpha?: number // 高亮元素透明度。默认:0.6
  resizerColor?: string // 图片尺寸器颜色。默认:#4182D9
  resizerSize?: number // 图片尺寸器大小。默认:5
  marginIndicatorSize?: number // 页边距指示器长度。默认:35
  marginIndicatorColor?: string // 页边距指示器颜色。默认:#BABABA
  margins?: IMargin // 页面边距。默认:[100, 120, 100, 120]
  pageMode?: PageMode // 纸张模式:连页、分页。默认:分页
  tdPadding?: IPadding // 单元格内边距。默认:[0, 5, 5, 5]
  defaultTrMinHeight?: number // 默认表格行最小高度。默认:42
  defaultColMinWidth?: number // 默认表格列最小宽度(整体宽度足够时应用,否则会按比例缩小)。默认:40
  defaultHyperlinkColor?: string // 默认超链接颜色。默认:#0000FF
  header?: IHeader // 页眉信息。{top?:number; maxHeightRadio?:MaxHeightRatio;}
  footer?: IFooter // 页脚信息。{bottom?:number; maxHeightRadio?:MaxHeightRatio;}
  pageNumber?: IPageNumber // 页码信息。{bottom:number; size:number; font:string; color:string; rowFlex:RowFlex; format:string; numberType:NumberType;}
  paperDirection?: PaperDirection // 纸张方向:纵向、横向
  inactiveAlpha?: number // 正文内容失焦时透明度。默认值:0.6
  historyMaxRecordCount?: number // 历史(撤销重做)最大记录次数。默认:100次
  printPixelRatio?: number // 打印像素比率(值越大越清晰,但尺寸越大)。默认:3
  maskMargin?: IMargin // 编辑器上的遮盖边距(如悬浮到编辑器上的菜单栏、底部工具栏)。默认:[0, 0, 0, 0]
  letterClass?: string[] // 排版支持的字母类。默认:a-zA-Z。内置可选择的字母表类:LETTER_CLASS
  contextMenuDisableKeys?: string[] // 禁用的右键菜单。默认:[]
  scrollContainerSelector?: string // 滚动区域选择器。默认:document
  wordBreak?: WordBreak // 单词与标点断行:BREAK_WORD首行不出现标点&单词不拆分、BREAK_ALL按字符宽度撑满后折行。默认:BREAK_WORD
  watermark?: IWatermark // 水印信息。{data:string; color?:string; opacity?:number; size?:number; font?:string;}
  control?: IControlOption // 控件信息。 {placeholderColor?:string; bracketColor?:string; prefix?:string; postfix?:string; borderWidth?: number; borderColor?: string;}
  checkbox?: ICheckboxOption // 复选框信息。{width?:number; height?:number; gap?:number; lineWidth?:number; fillStyle?:string; strokeStyle?: string;}
  radio?: IRadioOption // 单选框信息。{width?:number; height?:number; gap?:number; lineWidth?:number; fillStyle?:string; strokeStyle?: string;}
  cursor?: ICursorOption // 光标样式。{width?: number; color?: string; dragWidth?: number; dragColor?: string;}
  title?: ITitleOption // 标题配置。{ defaultFirstSize?: number; defaultSecondSize?: number; defaultThirdSize?: number defaultFourthSize?: number; defaultFifthSize?: number; defaultSixthSize?: number;}
  placeholder?: IPlaceholder // 编辑器空白占位文本
  group?: IGroup // 成组配置。{opacity?:number; backgroundColor?:string; activeOpacity?:number; activeBackgroundColor?:string; disabled?:boolean}
  pageBreak?: IPageBreak // 分页符配置。{font?:string; fontSize?:number; lineDash?:number[];}
  zone?: IZoneOption // 编辑器区域配置。{tipDisabled?:boolean;}
  background?: IBackgroundOption // 背景配置。{color?:string; image?:string; size?:BackgroundSize; repeat?:BackgroundRepeat;}。默认:{color: '#FFFFFF'}
  lineBreak?: ILineBreakOption // 换行符配置。{disabled?:boolean; color?:string; lineWidth?:number;}
  separator?: ISeparatorOption // 分隔符配置。{lineWidth?:number; strokeStyle?:string;}
}

页眉配置

interface IHeader {
  top?: number // 距离页面顶部大小。默认:30
  maxHeightRadio?: MaxHeightRatio // 占页面最大高度比。默认:HALF
  disabled?: boolean // 是否禁用
}

页码配置

interface IPageNumber {
  bottom?: number // 距离页面底部大小。默认:60
  size?: number // 字体大小。默认:12
  font?: string // 字体。默认:Microsoft YaHei
  color?: string // 字体颜色。默认:#000000
  rowFlex?: RowFlex // 行对齐方式。默认:CENTER
  format?: string // 页码格式。默认:{pageNo}。示例:第{pageNo}页/共{pageCount}页
  numberType?: NumberType // 数字类型。默认:ARABIC
  disabled?: boolean // 是否禁用
  startPageNo?: number // 起始页码。默认:1
  fromPageNo?: number // 从第几页开始出现页码。默认:0
}

水印配置

interface IWatermark {
  data: string // 文本。
  color?: string // 颜色。默认:#AEB5C0
  opacity?: number // 透明度。默认:0.3
  size?: number // 字体大小。默认:200
  font?: string // 字体。默认:Microsoft YaHei
}

占位文本配置

ploadBigFile/Index.cshtml



@{
    ViewData["Title"]="Index";
}

<h1>Index</h1>

<!--上传视频-->
<div>
    <div>
        <div>
            <div>
                <span class="">上传视频</span>
            </div>
            <input id="uploadvideofile" type="file" accept="video/mp4,video/quicktime" class="up-video" />
        </div>
    </div>
    <div id="videoplayer" style="display: none"></div>
    <div id="videooutput"></div>
    <div>
        <p style="color: #797979; margin-top: 5px; font-size: 12px;">视频格式必须为: mp4或mov。视频时长须在15秒以内,超出时长系统将自动截取前15秒内容。</p>
    </div>
</div>

<script src="~/Scripts/ajax.1.5.2.js"></script>
<!--传视频相关-->
<script type="text/javascript">
    $('#uploadvideofile').change(function() {
        var file, videoURL, windowURL;
        var filemaxsize=1024 * 1024 * 20; //200M
        if (fileValid(this, filemaxsize, 'video')) {
            file=this.files[0];
            try {
                var temp=ajax.upload_big(
                    "UpLoadBigFile/UploadVideo?opration=2222", //文件上传地址
                    "#uploadvideofile", //input=file 选择器
                    1024 * 1024, //切割文件大小
                    "*", //文件限制类型 mime类型
                    function(index) { //上传成功事件
                        //console.log("slice [ " + index + "] uploaded")
                    },
                    function(index, length) { //上传进度事件
                        //console.log(index + "/" + length);
                        //var ratio=(index / length) * 100.00 | 0.00;
                        //var progress=$(".progress > div");
                        //progress.css("width", ratio + "%");
                        //progress.attr("aria-valuenow", index);
                        //progress.attr("aria-valuemax", length);
                        //progress.children("span").text("" + ratio + "%");
                    },
                    function(index, length) { //超时处理事件
                        console.log(index + "/" + length);
                    }
                );
            } catch (e) {
                console.log(e.name + " " + e.message);
            }
            videoURL=null;
            windowURL=window.URL || window.webkitURL;
            videoURL=windowURL.createObjectURL(file);
            $('#videoplayer').html('<video src="' + videoURL + '" controls="controls"></video>');
            setTimeout(function() { createIMG(); }, 800);
        }
    });

    //验证上传文件大小和类型
    /**
     *
     * param {this} value_ [获取input对象,一般为this]
     * param {[number]} size_ [文件限制的大小,单位为kb]
     * param {[string]} type_ [文件类别]
     * param {[function]} callback [验证通过的回调]
     */
    function fileValid(value_, size_, type_, callback) {
        var file=value_.files[0];
        var fileSize=(file.size / 1024).toFixed(0); //文件大小
        var fileType=value_.value.substring(value_.value.lastIndexOf(".")); //文件类型

        if (fileSize > size_) {
            alert('视频过大,请选择小于200MB的视频!');
            return false;
        }
        switch (type_) {
        case 'video':
            if (!fileType.match(/.mp4|.mov/i)) {
                alert('请上传正确格式的视频!');
                return false;
            }
            break;
        default:
            alert('参数设置不正确!');
            return false;
            break;
        }
        return true;
    }

    var createIMG=function() {
        var scale=0.25,
            video=$('#videoplayer').find('video')[0],
            canvas=document.createElement("canvas"),
            canvasFill=canvas.getContext('2d');
        canvas.width=video.videoWidth * scale;
        canvas.height=video.videoHeight * scale;
        canvasFill.drawImage(video, 0, 0, canvas.width, canvas.height);
        var src=canvas.toDataURL("image/jpeg");
        $('#videooutput').html('<img id="imgSmallView" src="' + src + '" alt="预览图" />');
    }
</script>

<script>
    (function () {
        //$("#uploadvideofile").change(function () {
        //    var temp=ajax.upload_big(
        //        "AjaxHandlers/UploadVideoHandler.ashx", //文件上传地址
        //        "#uploadvideofile", //input=file 选择器
        //        1024 * 1024, //切割文件大小
        //        "*", //文件限制类型 mime类型
        //        function(index) { //上传成功事件
        //            //console.log("slice [ " + index + "] uploaded")
        //        },
        //        function(index, length) { //上传进度事件
        //            //console.log(index + "/" + length);
        //            //var ratio=(index / length) * 100.00 | 0.00;
        //            //var progress=$(".progress > div");
        //            //progress.css("width", ratio + "%");
        //            //progress.attr("aria-valuenow", index);
        //            //progress.attr("aria-valuemax", length);
        //            //progress.children("span").text("" + ratio + "%");
        //        },
        //        function(index, length) { //超时处理事件
        //            console.log(index + "/" + length);
        //        }
        //    );
        //    console.log(temp);
        //});
    })();
</script>

UploadBigFileController.cs

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VideoDemo.Common;
using VideoDemo.Models;

namespace VideoDemo.Controllers
{
    public class UploadBigFileController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        [HttpPost]
        //[ValidateAntiForgeryToken]
        public int UploadVideo([FromForm] VideoFileModel filemodel, [FromQuery]string opration)
        {
            try
            {
                int slicenum=FileHelper.UploadVideo(filemodel);
                return slicenum;

                //return RedirectToAction(nameof(Index));
            }
            catch(Exception e)
            {
                Console.Write(e.ToString());
                return -1;
            }
        }


        public ActionResult Details(int id)
        {
            return View();
        }

        public ActionResult Create()
        {
            return View();
        }

        // GET: UpLoadBigFileController/Edit/5
        public ActionResult Edit(int id)
        {
            return View();
        }

        // POST: UpLoadBigFileController/Edit/5
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Edit(int id, IFormCollection collection)
        {
            try
            {
                return RedirectToAction(nameof(Index));
            }
            catch
            {
                return View();
            }
        }

     
    }
}

FileHelper.cs

using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using VideoDemo.Models;
using Xabe.FFmpeg;

namespace VideoDemo.Common
{
    public class FileHelper
    {
        private static  string finalpath="";

        public static int UploadVideo(VideoFileModel videoFile)
        {
            //前端传输是否为切割文件最后一个小文件
            var isLast=videoFile.isLast;
            //前端传输当前为第几次切割小文件
            var count=videoFile.Count;
            //获取前端处理过的传输文件名
            string fileName=videoFile.Name;
            //存储接受到的切割文件
            if (videoFile.Files.Length <=0)
            {
                return -1;
            }

            IFormFile formFile=videoFile.Files;

            //处理文件名称(去除.part*,还原真实文件名称)
            string newFileName=fileName.Substring(0, fileName.LastIndexOf('.'));

            //临时存储文件夹路径
            string desPath=Directory.GetCurrentDirectory() + @"/uploads/slice/" + newFileName;

            //判断指定目录是否存在临时存储文件夹,没有就创建
            if (!System.IO.Directory.Exists(desPath))
            {
                //不存在就创建目录 
                System.IO.Directory.CreateDirectory(desPath);
            }

            //存储文件
            using (var stream=new FileStream(desPath+"/"+ fileName, FileMode.Create))
            {
                formFile.CopyTo(stream);
            }
            //file.SaveAs("E:\\uploads\\slice\\" + newFileName + "\\" + fileName);


            //判断是否为最后一次切割文件传输
            if (isLast=="true")
            {
                //判断组合的文件是否存在
                finalpath=Directory.GetCurrentDirectory() + @"/uploads/" + newFileName;
                if (File.Exists(finalpath)) //如果文件存在
                {
                    File.Delete(finalpath); //先删除,否则新文件就不能创建
                }

                //创建空的文件流
                FileStream FileOut=new FileStream(finalpath, FileMode.CreateNew, FileAccess.ReadWrite);
                BinaryWriter bw=new BinaryWriter(FileOut);
                //获取临时存储目录下的所有切割文件
                string[] allFile=Directory.GetFiles(desPath);
                //将文件进行排序拼接
                allFile=allFile.OrderBy(s=> int.Parse(Regex.Match(s, @"\d+$").Value)).ToArray();
                for (int i=0; i < allFile.Length; i++)
                {
                    FileStream FileIn=new FileStream(allFile[i], FileMode.Open);
                    BinaryReader br=new BinaryReader(FileIn);
                    byte[] data=new byte[1048576]; //流读取,缓存空间
                    int readLen=0; //每次实际读取的字节大小
                    readLen=br.Read(data, 0, data.Length);
                    bw.Write(data, 0, readLen);
                    //关闭输入流
                    FileIn.Close();
                }

                //关闭二进制写入
                bw.Close();
                FileOut.Close();
                ClipVideo();
            }

            return int.Parse(count) + 1;
        }

        public static async void ClipVideo()
        {
            /**
             * 支持视频格式:mpeg,mpg,avi,dat,mkv,rmvb,rm,mov.
             *不支持:wmv
             * **/
            //FFmpeg.SetExecutablesPath("ffmpeg.exe");
           //ffmpeg.exe的路径,控制台程序会在执行目录(....FFmpeg测试\bin\Debug)下找此文件,

            //视频路径
            string finalname=Path.GetFileNameWithoutExtension(finalpath);
            string videoFilePath=finalpath; //"d:\\01.avi";
            finalpath=finalpath.Replace(finalname, finalname + "15S");


         
            //IMediaInfo videoFile=await FFmpeg.GetMediaInfo(videoFilePath);
            //TimeSpan totaotp=videoFile.Duration;
            //string totalTime=string.Format("{0:00}:{1:00}:{2:00}", (int)totaotp.TotalHours, totaotp.Minutes,totaotp.Seconds);

            #region 自定义逻辑

            //string sourceFileName=Path.GetFileName(upFile.get_FileName()); //取出上传的视频的文件名,进而取出该文件的扩展名
            //string sourceFileName="02.avi";
            //string flv_file=System.IO.Path.ChangeExtension("d:\\01.avi", ".flv");
            //string Command=" -i \"" + FromName + "\" -y -ab 32 -ar 22050 -b 800000 -s  480*360 \"" + ExportName + "\""; //Flv格式  

            //转换视频为flv
            //ffmpeg -i F:\01.wmv -ab 56 -ar 22050 -b 500 -r 15 -s 320x240 f:\test.flv

            //视频截图,fileName视频地址,imgFile图片地址
            //ffmpeg -i input.flv -y -f image2 -ss 10.11 -t 0.001 -s 240x180 catchimg.jpg;
            //ImgstartInfo.Arguments="   -i   " + fileName + "  -y  -f  image2   -ss 2 -vframes 1  -s   " + FlvImgSize + "   " + flv_img;

            //string Command=" -i \"test.wmv\" -y -ab 32 -ar 22050 -b 800000 -s 320*240 \"2.flv\"";
            //string Command="E:\\FFmpeg\\ffmpeg.exe -i E:\\ClibDemo\\VideoPath\\admin\\a.wmv -y -ab 56 -ar 22050 -b 500 -r 15 -s 320*240 " ExportName;

            //3.重新编码进行剪切
            //ffmpeg -ss [start] -t [duration] -i [in].mp4  -c:v libx264 -c:a aac -strict experimental -b:a 98k [out].mp4
            //相对来说比较精确,可是还是不是特别精确

            //string Command=" -ss 00:00:00 -t 00:00:15 -i  d:\\01.avi   d:\\output.avi"; // ffmpeg -ss 00:00:10 -t 00:01:22 -i 五月天-突然好想你.mp3  out.mp3  
            string Command=" -ss 00:00:00 -t 00:00:15 -i  " + videoFilePath + "   " + finalpath;
            string Command1=        " -i d:\\01.avi -vf \"drawtext=fontfile=simhei.ttf: text='南通极客如皋张HC':x=w-tw-10:y=10:fontsize=28:fontcolor=red:shadowy=2\" d:\\output1.avi";
            string Command2=        " -i d:\\01.avi -vf \"drawtext=fontfile=simhei.ttf: text='南通极客QQ(827XXXXXX)':y=h-line_h-10:x=(w-mod(30*n\\,w+tw)):fontsize=34:fontcolor=yellow:shadowy=2\" d:\\output2.avi";

            System.Diagnostics.Process p=new System.Diagnostics.Process();
            //非控制台程序必须写完整路径
            p.StartInfo.FileName=AppContext.BaseDirectory + ("/ffmpeg.exe");
            p.StartInfo.Arguments=Command;
            //p.StartInfo.Arguments=Command1;
            //Asp.net 获取当前目录
            p.StartInfo.WorkingDirectory=AppContext.BaseDirectory; //HttpContext.Current.Request.MapPath("~/"); //Environment.CurrentDirectory;
            p.StartInfo.UseShellExecute=false;
            p.StartInfo.RedirectStandardInput=true;
            p.StartInfo.RedirectStandardOutput=true;
            p.StartInfo.RedirectStandardError=true;
            p.StartInfo.CreateNoWindow=false;
            //开始执行
            p.Start();
            p.BeginErrorReadLine();
            p.WaitForExit();
            p.Close();
            p.Dispose();

            #endregion

            //Console.WriteLine("时间长度:{0}", totalTime);
            //Console.WriteLine("高度:{0}", videoFile.Height);
            //Console.WriteLine("宽度:{0}", videoFile.Width);
            //Console.WriteLine("数据速率:{0}", videoFile.VideoBitRate);
            //Console.WriteLine("数据格式:{0}", videoFile.VideoFormat);
            //Console.WriteLine("比特率:{0}", videoFile.BitRate);
            //Console.WriteLine("文件路径:{0}", videoFile.Path);
            Console.ReadKey();
        }

    }
}

如图:

ainless Lab 是 Elasticsearch 7.13 引入的实验性功能,是一个交互式代码编辑器,可以实时测试和调试 Painless 脚本。

本文展开解读 Painless Lab 如何应用于企业级实战开发中的脚本调试环节!

1、Painless Lab 是什么?

Painless Lab是一个交互式的测试版代码编辑器,用于实时测试和调试Painless脚本。

咱们可以通过打开主菜单,点击开发工具,然后选择 Painless Lab 来访问它。

如下图所示,左侧是:脚本输入区域。右侧由三部分组成:

  • Output:代表结果输出,确切说是调试结果输出。
  • Parameters:代表参数输入。
  • Context:代表上下文,确切说是不同脚本类型选型与选择。

2、Painless Lab 能干什么?

一句话:Painless Lab 可以实时测试和调试 Painless 脚本。

Painless Lab 允许我们创建 Kibana 运行时字段(runtime fields)、处理重新索引的数据(reindex)、定义复杂的 Watcher 条件(付费功能),并在其他上下文中处理数据。

下面的 Context 部分展开就是 Painless Lab 的核心功能区域。

三种类型进一步展开:

进一步再展开解读。

https://www.elastic.co/guide/en/elasticsearch/painless/8.11/painless-execute-api.html#_contexts

上下文描述painless_test默认上下文,如果没有指定其他上下文则使用此上下文。用于通用脚本测试,例如调试和验证脚本逻辑。filter将脚本视为在脚本查询中运行。用于过滤数据。score将脚本视为在 function_score 查询中的 script_score 函数中运行。用于评分数据。

2.1 painless_test 类型

默认上下文,如果没有指定其他上下文则使用此上下文。用于通用脚本测试,例如调试和验证脚本逻辑。

2.2 filter 类型

将脚本视为在脚本查询中运行。用于过滤数据。

2.3 score 类型

将脚本视为在 function_score 查询中的 script_score 函数中运行。用于评分数据。

我们逐一详尽展开解读,确保大家跟着过一遍,就能学得会!

3、 Basic painless_test 基础调试

Basic 上下文允许我们独立测试脚本逻辑,并将结果转换为字符串输出。

样例数据可以放到 params 中作为输入。

实战举例如下:

对于 Ingest pipeline 脚本,参考官方示例,由于脚本是在 Ingest Pipeline 中处理数据的,并且没有涉及到查询过滤 filter 或评分 score,因此 Basic 上下文是最合适的选择。

https://www.elastic.co/guide/en/elasticsearch/reference/current/script-processor.html

如上图所示,我们在 parameters 中输入如下数据:

{
  "ctx": {
    "env": "小米-笔记本-电脑-雷军"
  },
  "params": {
    "delimiter": "-",
    "position": 1
  }
}

我们在左侧输入如下的 painless script 脚本,如下所示:

// 获取 ctx 数据
def ctx=params.ctx;

// 获取参数
def delimiter=params.params.delimiter;
def position=params.params.position;

// 执行脚本逻辑
String[] envSplit=ctx.env.splitOnToken(delimiter);
ArrayList tags=new ArrayList();
//tags.add(envSplit[position].trim());
for (def tag : envSplit) {
   tags.add(tag.trim());
}
ctx.tags=tags;

// 返回结果以供调试
return ctx.tags;

执行结果如下图右侧 Output 所示。

上述脚本实现的核心功能就是:以分隔符截断字符串,形成独立字符串,插入到 tags 集合中。

这样调试过之后,再微调一下就可以应用到 ingest pipeline 中。

4、filter 过滤调试

区别于刚才的逻辑,这里需要我们先创建索引,然后基于我们构造的索引数据进行展开 filter 过滤检索。

POST /hockey/_doc/1
{
  "first": "johnny",
  "last": "gaudreau",
  "goals": [9, 27, 1],
  "assists": [17, 46, 0],
  "gp": [26, 82, 1]
}

POST /hockey/_doc/2
{
  "first": "john",
  "last": "doe",
  "goals": [2, 3, 4],
  "assists": [1, 2, 3],
  "gp": [4, 5, 6]
}

上述索引必须构建,否则会报错如下图所示。

错误原因可能是:索引不存在或者Mapping 不存在。

正确的执行步骤如下所示:

结合上面三个步骤以及左侧的脚本,主要验证左侧脚本正确与否。注意:返回值必须是 Bool 类型。执行结果如下:

最终结合上述调试成功的脚本,整合到 script query 检索语句中,就能得到满足用户预期的结果数据。

POST /hockey/_search
{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": {
            "lang": "painless",
            "source": """
            // 获取 goals 字段的值
            def goals=doc['goals'];
            
            // 初始化总和变量
            int sum=0;
            
            // 遍历 goals 数组并计算总和
            for (int i=0; i < goals.size(); i++) {
              sum +=goals.get(i);
            }
            
            // 输出调试信息
            //Debug.explain(sum);
            
            // 返回 true 以匹配所有文档,仅用于调试目的
            return sum>10;
            """
          }
        }
      }
    }
  }
}

5、评分 score 类型调试

在 Elasticsearch 中,score 类型调试上下文用于在 function_score 查询中的 script_score 函数中运行脚本。

该方式允许用户编写脚本来动态计算文档的评分,从而影响搜索结果的排序。


5.1 真实企业场景再现

假设我们有一个包含产品信息的索引 products,每个文档包含以下字段:

  • 1.name: 产品名称
  • 2.price: 产品价格
  • 3.rating: 产品评分

我们希望根据价格和评分来动态计算每个产品的分数,具体规则如下:

  • 1.价格越低,分数越高
  • 2.评分越高,分数越高
POST /products/_doc/1
{
  "name": "Product A",
  "price": 100,
  "rating": 4.5
}

POST /products/_doc/2
{
  "name": "Product B",
  "price": 200,
  "rating": 4.0
}

POST /products/_doc/3
{
  "name": "Product C",
  "price": 150,
  "rating": 3.5
}

5.2 评分查询脚本调试示例

我们将编写一个 function_score 查询,使用 Painless 脚本来计算每个文档的分数,并根据计算结果排序。

核心逻辑:

  • 1、获取字段值;
  • 2、脚本重新计算评分;
  • 3、返回自定义评分。

在 Painless Lab 中,可以使用类似的脚本来调试和验证评分逻辑:

  • 构造参数 Parameters 部分
{
    "price": 100,
    "rating": 4.5
}

左侧脚本部分

// 获取参数值
long price=params.price;
double rating=params.rating;

// 检查字段值是否存在
if (price==0 || rating==0) {
    // 如果任一字段值为 0,则返回默认分数(例如 0)
    return 0.0;
}

// 自定义评分逻辑
double score=(1 / (float)price) * rating;

// 返回评分结果
return score;

执行结果如下所示:

上述脚本通过使用 score 上下文中的 script_score 函数,可以根据自定义逻辑动态计算文档的分数,从而影响搜索结果的排序。

这在需要根据复杂规则排序搜索结果时非常有用。

通过在 Painless Lab 中调试和验证上述脚本,可以确保评分逻辑的正确性和有效性。

进而,可以组合写出如下的评分脚本检索语句。

POST /products/_search
{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "script_score": {
        "script": {
          "source": """
            long price=doc['price'].value;
            double rating=doc['rating'].value;
            
            // 自定义评分逻辑
            double score=(1 / (float)price) * rating;
            return score;
          """
        }
      },
      "boost_mode": "replace"  // 使用脚本计算的分数替换原始分数
    }
  }
}

实现核心:根据自定义逻辑计算分数:score=(1 / price) * rating。价格越低,评分越高,分数越高。

boost_mode: 设置为 replace,使用脚本计算的分数替换原始分数。

6、小结

Kibana Painless Lab 是 Elasticsearch 7.13 引入的实验性功能,为开发者提供交互式代码编辑器,用于实时测试和调试 Painless 脚本。

通过 painless_test、filter 和 score 上下文三种测试方式,开发者可以创建和调试 Kibana 运行时字段、处理重新索引的数据、定义复杂的 Watcher 条件,并根据复杂规则动态计算文档分数,提高脚本开发和优化效率。


作者:铭毅天下

来源-微信公众号:铭毅天下Elasticsearch

出处:https://mp.weixin.qq.com/s/x9FrVCyrmpvJsc7pNGBm2g